@aglyn/shared-util-email 1.0.0-beta.143

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +104 -0
  3. package/package.json +37 -0
  4. package/src/index.d.ts +37 -0
  5. package/src/index.js +46 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/email-delivery-events.d.ts +198 -0
  8. package/src/lib/email-delivery-events.js +310 -0
  9. package/src/lib/email-delivery-events.js.map +1 -0
  10. package/src/lib/email-health.d.ts +154 -0
  11. package/src/lib/email-health.js +264 -0
  12. package/src/lib/email-health.js.map +1 -0
  13. package/src/lib/email-media-src.d.ts +33 -0
  14. package/src/lib/email-media-src.js +113 -0
  15. package/src/lib/email-media-src.js.map +1 -0
  16. package/src/lib/email-merge.d.ts +31 -0
  17. package/src/lib/email-merge.js +36 -0
  18. package/src/lib/email-merge.js.map +1 -0
  19. package/src/lib/email-render.d.ts +114 -0
  20. package/src/lib/email-render.js +293 -0
  21. package/src/lib/email-render.js.map +1 -0
  22. package/src/lib/email-revenue-window.d.ts +55 -0
  23. package/src/lib/email-revenue-window.js +58 -0
  24. package/src/lib/email-revenue-window.js.map +1 -0
  25. package/src/lib/host-email-render.d.ts +99 -0
  26. package/src/lib/host-email-render.js +126 -0
  27. package/src/lib/host-email-render.js.map +1 -0
  28. package/src/lib/host-sender.d.ts +86 -0
  29. package/src/lib/host-sender.js +131 -0
  30. package/src/lib/host-sender.js.map +1 -0
  31. package/src/lib/marketing-send.d.ts +466 -0
  32. package/src/lib/marketing-send.js +459 -0
  33. package/src/lib/marketing-send.js.map +1 -0
  34. package/src/lib/platform-sending-domain.d.ts +362 -0
  35. package/src/lib/platform-sending-domain.js +697 -0
  36. package/src/lib/platform-sending-domain.js.map +1 -0
  37. package/src/lib/received-email.d.ts +86 -0
  38. package/src/lib/received-email.js +124 -0
  39. package/src/lib/received-email.js.map +1 -0
  40. package/src/lib/send-ceilings.d.ts +394 -0
  41. package/src/lib/send-ceilings.js +341 -0
  42. package/src/lib/send-ceilings.js.map +1 -0
  43. package/src/lib/send-email.d.ts +385 -0
  44. package/src/lib/send-email.js +586 -0
  45. package/src/lib/send-email.js.map +1 -0
  46. package/src/lib/send-rate.d.ts +298 -0
  47. package/src/lib/send-rate.js +310 -0
  48. package/src/lib/send-rate.js.map +1 -0
  49. package/src/lib/sender-reputation.d.ts +357 -0
  50. package/src/lib/sender-reputation.js +392 -0
  51. package/src/lib/sender-reputation.js.map +1 -0
  52. package/src/lib/sending-domain.d.ts +744 -0
  53. package/src/lib/sending-domain.js +798 -0
  54. package/src/lib/sending-domain.js.map +1 -0
  55. package/src/lib/sending-mailbox.d.ts +132 -0
  56. package/src/lib/sending-mailbox.js +192 -0
  57. package/src/lib/sending-mailbox.js.map +1 -0
  58. package/src/lib/stored-email-nodes.d.ts +38 -0
  59. package/src/lib/stored-email-nodes.js +78 -0
  60. package/src/lib/stored-email-nodes.js.map +1 -0
  61. package/src/lib/svix-signature.d.ts +38 -0
  62. package/src/lib/svix-signature.js +55 -0
  63. package/src/lib/svix-signature.js.map +1 -0
  64. package/src/lib/system-email-catalog.d.ts +170 -0
  65. package/src/lib/system-email-catalog.js +828 -0
  66. package/src/lib/system-email-catalog.js.map +1 -0
  67. package/src/lib/tenant-email-catalog.d.ts +78 -0
  68. package/src/lib/tenant-email-catalog.js +452 -0
  69. package/src/lib/tenant-email-catalog.js.map +1 -0
  70. package/src/lib/text-email-html.d.ts +32 -0
  71. package/src/lib/text-email-html.js +119 -0
  72. package/src/lib/text-email-html.js.map +1 -0
@@ -0,0 +1,264 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { getEmailConfig } from "./send-email.js";
18
+ /**
19
+ * Where the credential probe asks its question.
20
+ *
21
+ * Deliberately NOT the send endpoint. A probe aimed at `/emails` is a send
22
+ * attempt however empty its body is: it consumes an API call, and Resend
23
+ * records it in the account's logs as a `422` on `POST /emails` with no
24
+ * recipient, no subject and nothing identifying the caller — a line an
25
+ * operator reading that dashboard has to treat as failed mail. A domain read
26
+ * cannot create a message and cannot be mistaken for one.
27
+ */ export const RESEND_DOMAINS_ENDPOINT = 'https://api.resend.com/domains';
28
+ /**
29
+ * Resend error names that mean the key itself was not accepted, as opposed to
30
+ * a key that authenticated and merely lacks read scope. Matched by NAME, not
31
+ * status: `401` and `403` each cover both meanings.
32
+ */ const REFUSED_KEY_ERRORS = new Set([
33
+ 'missing_api_key',
34
+ 'validation_error',
35
+ 'suspended_api_key'
36
+ ]);
37
+ /**
38
+ * Resend error names that mean the key authenticated and was then denied this
39
+ * particular read. A sending-scoped key — the shape Aglyn provisions — always
40
+ * lands here, and reaching this answer at all required Resend to recognize
41
+ * the credential, which is exactly what the probe is asking.
42
+ */ const AUTHENTICATED_BUT_UNSCOPED_ERRORS = new Set([
43
+ 'restricted_api_key',
44
+ 'invalid_permission'
45
+ ]);
46
+ /** The `name` Resend puts on an error body, or `''` for anything else. */ function resendErrorName(body) {
47
+ try {
48
+ const parsed = JSON.parse(body);
49
+ return typeof (parsed == null ? void 0 : parsed.name) === 'string' ? parsed.name : '';
50
+ } catch (unused) {
51
+ return '';
52
+ }
53
+ }
54
+ /**
55
+ * Describes the email configuration without revealing the API key.
56
+ *
57
+ * Answers "is this environment able to send mail?" — the question that is
58
+ * otherwise only answerable by emailing a real person and waiting.
59
+ */ export function describeEmailConfig() {
60
+ var _ref, _ref1;
61
+ const { apiKey, from } = getEmailConfig();
62
+ const match = from == null ? void 0 : from.match(/<([^>]+)>/);
63
+ const address = ((_ref = (_ref1 = match == null ? void 0 : match[1]) != null ? _ref1 : from) != null ? _ref : '').trim();
64
+ const domain = address.includes('@') ? address.split('@').pop() : null;
65
+ return {
66
+ configured: Boolean(apiKey && from),
67
+ hasApiKey: Boolean(apiKey),
68
+ hasFrom: Boolean(from),
69
+ from: from != null ? from : null,
70
+ fromDomain: domain
71
+ };
72
+ }
73
+ /**
74
+ * Checks whether `RESEND_API_KEY` is actually accepted by Resend — without
75
+ * sending anything to anybody, and without leaving anything behind that reads
76
+ * as failed mail.
77
+ *
78
+ * How: a `GET` of the domains collection. The question is only ever "does
79
+ * Resend recognize this credential", so the probe reads the ERROR NAME rather
80
+ * than the status, because `401` and `403` each carry both meanings:
81
+ *
82
+ * - `2xx` — the key is accepted and has read scope → `ok`
83
+ * - `restricted_api_key` / `invalid_permission` — Resend authenticated the
84
+ * key and then denied it this read. A sending-scoped key, which is what
85
+ * Aglyn provisions, always answers this way, and getting the answer proves
86
+ * the credential works → `ok`
87
+ * - `missing_api_key` / `validation_error` / `suspended_api_key` — the key
88
+ * itself was refused → `invalid-key`
89
+ * - anything else → `unknown`
90
+ *
91
+ * An unrecognized rejection is `unknown`, never `invalid-key`: this feeds a
92
+ * staff diagnostics screen whose whole value is that a red line means
93
+ * something, and a shape we have not seen before is not evidence that a
94
+ * working key is broken.
95
+ *
96
+ * It cannot confirm that *domain verification* has completed — only a real
97
+ * send does that.
98
+ */ export async function checkEmailCredentials() {
99
+ const { apiKey } = getEmailConfig();
100
+ if (!apiKey) return {
101
+ status: 'unconfigured'
102
+ };
103
+ try {
104
+ const response = await fetch(RESEND_DOMAINS_ENDPOINT, {
105
+ method: 'GET',
106
+ headers: {
107
+ Authorization: `Bearer ${apiKey}`
108
+ }
109
+ });
110
+ const detail = (await response.text().catch(()=>'')).slice(0, 300);
111
+ if (response.status >= 200 && response.status < 300) {
112
+ return {
113
+ status: 'ok',
114
+ probeStatus: response.status
115
+ };
116
+ }
117
+ if (response.status === 401 || response.status === 403) {
118
+ const name = resendErrorName(detail);
119
+ if (AUTHENTICATED_BUT_UNSCOPED_ERRORS.has(name)) {
120
+ return {
121
+ status: 'ok',
122
+ probeStatus: response.status
123
+ };
124
+ }
125
+ if (REFUSED_KEY_ERRORS.has(name)) {
126
+ return {
127
+ status: 'invalid-key',
128
+ probeStatus: response.status,
129
+ detail
130
+ };
131
+ }
132
+ }
133
+ return {
134
+ status: 'unknown',
135
+ probeStatus: response.status,
136
+ detail
137
+ };
138
+ } catch (error) {
139
+ var _ref;
140
+ return {
141
+ status: 'unknown',
142
+ detail: String((_ref = error == null ? void 0 : error.message) != null ? _ref : error).slice(0, 300)
143
+ };
144
+ }
145
+ }
146
+ /**
147
+ * Whether the shared platform pool can actually carry mail.
148
+ *
149
+ * The pool is the delivery floor: a site with no domain of its own sends its
150
+ * receipts, password resets and booking confirmations from a member of it. So
151
+ * a degraded pool is not a warning about a future problem, it is every such
152
+ * site's transactional mail already failing — which is why the caller treats
153
+ * this as a blocker rather than a note.
154
+ *
155
+ * Read with `RESEND_READ_API_KEY`, deliberately, and never with the sending
156
+ * key. A sending-scoped key has no read permission, so asking it about domains
157
+ * yields an authorization error that says nothing about the domains — which is
158
+ * exactly how a pool the key could not send from reported healthy. Without a
159
+ * read key this answers `unreadable`, which is the honest answer and not a
160
+ * pass: the caller must not treat "I could not look" as "I looked and it was
161
+ * fine".
162
+ *
163
+ * `not-applicable` covers the self-host shape. The pool is a property of the
164
+ * Aglyn platform; an operator running their own deployment sends from their
165
+ * own domain and has no pool to be degraded.
166
+ */ export async function checkSharedSendingPool(options) {
167
+ var _options_readApiKey;
168
+ const pool = options.pool.filter(Boolean);
169
+ if (!pool.length) {
170
+ return {
171
+ status: 'not-applicable',
172
+ domains: [],
173
+ unusable: [],
174
+ untracked: []
175
+ };
176
+ }
177
+ const key = String((_options_readApiKey = options.readApiKey) != null ? _options_readApiKey : '').trim();
178
+ if (!key) {
179
+ return {
180
+ status: 'unreadable',
181
+ domains: [],
182
+ unusable: [],
183
+ untracked: [],
184
+ detail: 'RESEND_READ_API_KEY is not set, so the shared pool cannot be ' + 'inspected. The sending key has no read permission and would report ' + 'an authorization error rather than the state of the domains.'
185
+ };
186
+ }
187
+ try {
188
+ var _options_endpoint, _ref;
189
+ const response = await fetch((_options_endpoint = options.endpoint) != null ? _options_endpoint : RESEND_DOMAINS_ENDPOINT, {
190
+ method: 'GET',
191
+ headers: {
192
+ Authorization: `Bearer ${key}`
193
+ }
194
+ });
195
+ if (response.status < 200 || response.status >= 300) {
196
+ return {
197
+ status: 'unreadable',
198
+ domains: [],
199
+ unusable: [],
200
+ untracked: [],
201
+ detail: `The provider answered ${response.status} to the domain read.`
202
+ };
203
+ }
204
+ const payload = await response.json().catch(()=>null);
205
+ /**
206
+ * A tri-state, and the third state matters: a listing that does not carry
207
+ * the field must not be reported as the field being false. Off is a fault
208
+ * somebody should fix; unknown is a question this probe could not ask.
209
+ */ const flag = (value)=>typeof value === 'boolean' ? value : null;
210
+ const byName = new Map();
211
+ for (const row of (_ref = payload == null ? void 0 : payload.data) != null ? _ref : []){
212
+ var _ref1, _ref2;
213
+ const name = String((_ref1 = row == null ? void 0 : row.name) != null ? _ref1 : '').toLowerCase();
214
+ if (!name) continue;
215
+ byName.set(name, {
216
+ status: String((_ref2 = row == null ? void 0 : row.status) != null ? _ref2 : 'unknown'),
217
+ click: flag(row == null ? void 0 : row.click_tracking),
218
+ open: flag(row == null ? void 0 : row.open_tracking)
219
+ });
220
+ }
221
+ const domains = pool.map((domain)=>{
222
+ var _ref, _ref1, _ref2;
223
+ const row = byName.get(domain.toLowerCase());
224
+ return {
225
+ domain,
226
+ status: (_ref = row == null ? void 0 : row.status) != null ? _ref : 'absent',
227
+ present: row !== undefined,
228
+ clickTracking: (_ref1 = row == null ? void 0 : row.click) != null ? _ref1 : null,
229
+ openTracking: (_ref2 = row == null ? void 0 : row.open) != null ? _ref2 : null
230
+ };
231
+ });
232
+ const unusable = domains.filter((entry)=>entry.status !== 'verified').map((entry)=>entry.domain);
233
+ /*
234
+ * Only a definite `false` counts. A member the provider did not describe
235
+ * is already reported by `present`, and listing it here as well would
236
+ * send an operator to fix a setting they cannot see.
237
+ */ const untracked = domains.filter((entry)=>entry.clickTracking === false).map((entry)=>entry.domain);
238
+ return _extends({
239
+ /*
240
+ * `untracked` deliberately does NOT degrade the pool. `degraded` is
241
+ * read as "transactional mail is failing right now" and is treated as a
242
+ * blocker; a domain that delivers but does not count clicks is neither.
243
+ * Folding the two together would either raise a false alarm about
244
+ * delivery or teach an operator to ignore the real one.
245
+ */ status: unusable.length ? 'degraded' : 'ok',
246
+ domains,
247
+ unusable,
248
+ untracked
249
+ }, untracked.length ? {
250
+ detail: `Click tracking is off for ${untracked.join(', ')}. Mail from ` + 'these domains delivers normally and reports a click rate of ' + 'exactly 0%, because the provider rewrites links only on a ' + 'domain that has a verified tracking subdomain.'
251
+ } : {});
252
+ } catch (error) {
253
+ var _ref3;
254
+ return {
255
+ status: 'unreadable',
256
+ domains: [],
257
+ unusable: [],
258
+ untracked: [],
259
+ detail: String((_ref3 = error == null ? void 0 : error.message) != null ? _ref3 : error).slice(0, 300)
260
+ };
261
+ }
262
+ }
263
+
264
+ //# sourceMappingURL=email-health.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/email-health.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\nimport { getEmailConfig } from './send-email'\n\n/**\n * Where the credential probe asks its question.\n *\n * Deliberately NOT the send endpoint. A probe aimed at `/emails` is a send\n * attempt however empty its body is: it consumes an API call, and Resend\n * records it in the account's logs as a `422` on `POST /emails` with no\n * recipient, no subject and nothing identifying the caller — a line an\n * operator reading that dashboard has to treat as failed mail. A domain read\n * cannot create a message and cannot be mistaken for one.\n */\nexport const RESEND_DOMAINS_ENDPOINT = 'https://api.resend.com/domains'\n\n/**\n * Resend error names that mean the key itself was not accepted, as opposed to\n * a key that authenticated and merely lacks read scope. Matched by NAME, not\n * status: `401` and `403` each cover both meanings.\n */\nconst REFUSED_KEY_ERRORS = new Set([\n 'missing_api_key',\n 'validation_error',\n 'suspended_api_key',\n])\n\n/**\n * Resend error names that mean the key authenticated and was then denied this\n * particular read. A sending-scoped key — the shape Aglyn provisions — always\n * lands here, and reaching this answer at all required Resend to recognize\n * the credential, which is exactly what the probe is asking.\n */\nconst AUTHENTICATED_BUT_UNSCOPED_ERRORS = new Set([\n 'restricted_api_key',\n 'invalid_permission',\n])\n\n/** The `name` Resend puts on an error body, or `''` for anything else. */\nfunction resendErrorName(body: string): string {\n try {\n const parsed = JSON.parse(body) as { name?: unknown }\n return typeof parsed?.name === 'string' ? parsed.name : ''\n } catch {\n return ''\n }\n}\n\nexport interface EmailConfigReport {\n /** Both env vars present — mail will at least be attempted. */\n configured: boolean\n hasApiKey: boolean\n hasFrom: boolean\n /**\n * The configured sender, e.g. `Aglyn <noreply@aglyn.com>`. Not a secret —\n * it appears in the headers of every message we send.\n */\n from: string | null\n /** Domain part of the sender, which is what must be verified in Resend. */\n fromDomain: string | null\n}\n\n/**\n * Describes the email configuration without revealing the API key.\n *\n * Answers \"is this environment able to send mail?\" — the question that is\n * otherwise only answerable by emailing a real person and waiting.\n */\nexport function describeEmailConfig(): EmailConfigReport {\n const { apiKey, from } = getEmailConfig()\n const match = from?.match(/<([^>]+)>/)\n const address = (match?.[1] ?? from ?? '').trim()\n const domain = address.includes('@') ? address.split('@').pop()! : null\n return {\n configured: Boolean(apiKey && from),\n hasApiKey: Boolean(apiKey),\n hasFrom: Boolean(from),\n from: from ?? null,\n fromDomain: domain,\n }\n}\n\nexport type EmailCredentialStatus =\n | 'ok'\n | 'unconfigured'\n | 'invalid-key'\n | 'unknown'\n\nexport interface EmailCredentialReport {\n status: EmailCredentialStatus\n /** HTTP status Resend answered the probe with, when it answered. */\n probeStatus?: number\n detail?: string\n}\n\n/**\n * Checks whether `RESEND_API_KEY` is actually accepted by Resend — without\n * sending anything to anybody, and without leaving anything behind that reads\n * as failed mail.\n *\n * How: a `GET` of the domains collection. The question is only ever \"does\n * Resend recognize this credential\", so the probe reads the ERROR NAME rather\n * than the status, because `401` and `403` each carry both meanings:\n *\n * - `2xx` — the key is accepted and has read scope → `ok`\n * - `restricted_api_key` / `invalid_permission` — Resend authenticated the\n * key and then denied it this read. A sending-scoped key, which is what\n * Aglyn provisions, always answers this way, and getting the answer proves\n * the credential works → `ok`\n * - `missing_api_key` / `validation_error` / `suspended_api_key` — the key\n * itself was refused → `invalid-key`\n * - anything else → `unknown`\n *\n * An unrecognized rejection is `unknown`, never `invalid-key`: this feeds a\n * staff diagnostics screen whose whole value is that a red line means\n * something, and a shape we have not seen before is not evidence that a\n * working key is broken.\n *\n * It cannot confirm that *domain verification* has completed — only a real\n * send does that.\n */\nexport async function checkEmailCredentials(): Promise<EmailCredentialReport> {\n const { apiKey } = getEmailConfig()\n if (!apiKey) return { status: 'unconfigured' }\n\n try {\n const response = await fetch(RESEND_DOMAINS_ENDPOINT, {\n method: 'GET',\n headers: { Authorization: `Bearer ${apiKey}` },\n })\n const detail = (await response.text().catch(() => '')).slice(0, 300)\n\n if (response.status >= 200 && response.status < 300) {\n return { status: 'ok', probeStatus: response.status }\n }\n if (response.status === 401 || response.status === 403) {\n const name = resendErrorName(detail)\n if (AUTHENTICATED_BUT_UNSCOPED_ERRORS.has(name)) {\n return { status: 'ok', probeStatus: response.status }\n }\n if (REFUSED_KEY_ERRORS.has(name)) {\n return { status: 'invalid-key', probeStatus: response.status, detail }\n }\n }\n return { status: 'unknown', probeStatus: response.status, detail }\n } catch (error) {\n return {\n status: 'unknown',\n detail: String((error as Error)?.message ?? error).slice(0, 300),\n }\n }\n}\n\n/** One pool member, as the provider reports it. */\nexport interface SharedPoolDomainReport {\n domain: string\n /** `verified` when the provider will accept mail on it. */\n status: string\n /** False when the provider has never heard of it. */\n present: boolean\n /**\n * Whether the provider will rewrite links on this domain to measure clicks.\n *\n * Reported because the symptom of it being off is INVISIBLE. A provider\n * counts a click by rewriting every `<a href>` to a tracking host, so a\n * domain without one produces a click rate of exactly 0% — which reads on a\n * dashboard as an audience that does not click, not as a domain that cannot\n * count. The last time this was wrong nobody found it by looking at the\n * numbers; it was found by reading a delivered message's source.\n *\n * `null` when the provider's listing does not say, so \"it did not tell us\"\n * is never reported as \"it is off\".\n */\n clickTracking: boolean | null\n /** The same, for the open pixel. */\n openTracking: boolean | null\n}\n\nexport type SharedPoolStatus =\n /** No platform pool applies to this deployment. */\n | 'not-applicable'\n /** No read credential, so the pool cannot be inspected. */\n | 'unreadable'\n | 'ok'\n | 'degraded'\n\nexport interface SharedPoolReport {\n status: SharedPoolStatus\n domains: SharedPoolDomainReport[]\n /** Members the provider will not accept mail on right now. */\n unusable: string[]\n /**\n * Members that will carry mail but cannot measure a click on it.\n *\n * Reported APART from {@link unusable} because the two are different\n * severities and conflating them would be wrong in both directions: a\n * member here is delivering perfectly, and a member in `unusable` is not\n * delivering at all. This one does not degrade the pool — see\n * {@link SharedPoolReport.status} — it is a measurement fault, and stopping\n * mail over it would be the control causing the outage.\n */\n untracked: string[]\n detail?: string\n}\n\n/**\n * Whether the shared platform pool can actually carry mail.\n *\n * The pool is the delivery floor: a site with no domain of its own sends its\n * receipts, password resets and booking confirmations from a member of it. So\n * a degraded pool is not a warning about a future problem, it is every such\n * site's transactional mail already failing — which is why the caller treats\n * this as a blocker rather than a note.\n *\n * Read with `RESEND_READ_API_KEY`, deliberately, and never with the sending\n * key. A sending-scoped key has no read permission, so asking it about domains\n * yields an authorization error that says nothing about the domains — which is\n * exactly how a pool the key could not send from reported healthy. Without a\n * read key this answers `unreadable`, which is the honest answer and not a\n * pass: the caller must not treat \"I could not look\" as \"I looked and it was\n * fine\".\n *\n * `not-applicable` covers the self-host shape. The pool is a property of the\n * Aglyn platform; an operator running their own deployment sends from their\n * own domain and has no pool to be degraded.\n */\nexport async function checkSharedSendingPool(options: {\n pool: string[]\n readApiKey?: string\n endpoint?: string\n}): Promise<SharedPoolReport> {\n const pool = options.pool.filter(Boolean)\n if (!pool.length) {\n return {\n status: 'not-applicable',\n domains: [],\n unusable: [],\n untracked: [],\n }\n }\n\n const key = String(options.readApiKey ?? '').trim()\n if (!key) {\n return {\n status: 'unreadable',\n domains: [],\n unusable: [],\n untracked: [],\n detail:\n 'RESEND_READ_API_KEY is not set, so the shared pool cannot be ' +\n 'inspected. The sending key has no read permission and would report ' +\n 'an authorization error rather than the state of the domains.',\n }\n }\n\n try {\n const response = await fetch(options.endpoint ?? RESEND_DOMAINS_ENDPOINT, {\n method: 'GET',\n headers: { Authorization: `Bearer ${key}` },\n })\n if (response.status < 200 || response.status >= 300) {\n return {\n status: 'unreadable',\n domains: [],\n unusable: [],\n untracked: [],\n detail: `The provider answered ${response.status} to the domain read.`,\n }\n }\n const payload = (await response.json().catch(() => null)) as\n | {\n data?: Array<{\n name?: unknown\n status?: unknown\n click_tracking?: unknown\n open_tracking?: unknown\n }>\n }\n | null\n /**\n * A tri-state, and the third state matters: a listing that does not carry\n * the field must not be reported as the field being false. Off is a fault\n * somebody should fix; unknown is a question this probe could not ask.\n */\n const flag = (value: unknown): boolean | null =>\n typeof value === 'boolean' ? value : null\n const byName = new Map<\n string,\n { status: string; click: boolean | null; open: boolean | null }\n >()\n for (const row of payload?.data ?? []) {\n const name = String(row?.name ?? '').toLowerCase()\n if (!name) continue\n byName.set(name, {\n status: String(row?.status ?? 'unknown'),\n click: flag(row?.click_tracking),\n open: flag(row?.open_tracking),\n })\n }\n const domains = pool.map((domain) => {\n const row = byName.get(domain.toLowerCase())\n return {\n domain,\n status: row?.status ?? 'absent',\n present: row !== undefined,\n clickTracking: row?.click ?? null,\n openTracking: row?.open ?? null,\n }\n })\n const unusable = domains\n .filter((entry) => entry.status !== 'verified')\n .map((entry) => entry.domain)\n /*\n * Only a definite `false` counts. A member the provider did not describe\n * is already reported by `present`, and listing it here as well would\n * send an operator to fix a setting they cannot see.\n */\n const untracked = domains\n .filter((entry) => entry.clickTracking === false)\n .map((entry) => entry.domain)\n return {\n /*\n * `untracked` deliberately does NOT degrade the pool. `degraded` is\n * read as \"transactional mail is failing right now\" and is treated as a\n * blocker; a domain that delivers but does not count clicks is neither.\n * Folding the two together would either raise a false alarm about\n * delivery or teach an operator to ignore the real one.\n */\n status: unusable.length ? 'degraded' : 'ok',\n domains,\n unusable,\n untracked,\n ...(untracked.length\n ? {\n detail:\n `Click tracking is off for ${untracked.join(', ')}. Mail from ` +\n 'these domains delivers normally and reports a click rate of ' +\n 'exactly 0%, because the provider rewrites links only on a ' +\n 'domain that has a verified tracking subdomain.',\n }\n : {}),\n }\n } catch (error) {\n return {\n status: 'unreadable',\n domains: [],\n unusable: [],\n untracked: [],\n detail: String((error as Error)?.message ?? error).slice(0, 300),\n }\n }\n}\n"],"names":["getEmailConfig","RESEND_DOMAINS_ENDPOINT","REFUSED_KEY_ERRORS","Set","AUTHENTICATED_BUT_UNSCOPED_ERRORS","resendErrorName","body","parsed","JSON","parse","name","describeEmailConfig","match","apiKey","from","address","trim","domain","includes","split","pop","configured","Boolean","hasApiKey","hasFrom","fromDomain","checkEmailCredentials","status","response","fetch","method","headers","Authorization","detail","text","catch","slice","probeStatus","has","error","String","message","checkSharedSendingPool","options","pool","filter","length","domains","unusable","untracked","key","readApiKey","endpoint","payload","json","flag","value","byName","Map","row","data","toLowerCase","set","click","click_tracking","open","open_tracking","map","get","present","undefined","clickTracking","openTracking","entry","join"],"mappings":"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"}
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /** Mirrors `resolveMediaSrc`. */
18
+ export declare function resolveEmailMediaSrc(value: string | undefined | null, hostId?: string | null): string | undefined;
19
+ /**
20
+ * Mirrors `hostPublicOrigin` — now including the part that was configurable
21
+ * (AGL-2195).
22
+ *
23
+ * The docblock said "mirrors" while the body wrote `aglyn.app` in flat, so
24
+ * every absolute link and image `src` in an email a self-hosted deployment
25
+ * sent pointed at Aglyn's apex. An email is the sharpest place to get this
26
+ * wrong: the URL leaves the deployment and is clicked days later from an
27
+ * inbox, where nothing can rewrite it.
28
+ *
29
+ */
30
+ export declare function hostEmailOrigin(host: {
31
+ cname?: string | null;
32
+ subdomain?: string | null;
33
+ } | null | undefined): string | undefined;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /**
17
+ * Absolute image URLs for email (AGL-1224).
18
+ *
19
+ * ## Why this is a mirror rather than an import
20
+ *
21
+ * The authority is `resolveMediaSrc` / `hostPublicOrigin` in
22
+ * `libs/aglyn/src/lib/app-utils/` and this file must not diverge from it. It
23
+ * cannot import it: `shared-util-email` is tagged `scope:shared`, and the
24
+ * module-boundary rule makes shared libs LEAVES — `scope:shared` may depend
25
+ * only on `scope:shared`, while `scope:aglyn` and `aglyn:framework` may depend
26
+ * on shared. The arrow points one way on purpose, so that every send site can
27
+ * import this renderer without dragging the framework in behind it.
28
+ *
29
+ * The same file already solves the same problem the same way: see
30
+ * `EMAIL_NODE_ROOT_ID` in `./email-render`, a copy of the besigner's
31
+ * `CANVAS_ROOT_ELEMENT_ID` held here with a drift guard in the console specs.
32
+ * So: copy, name it a copy, and PIN it —
33
+ * `apps/console/specs/email-media-src-drift.spec.ts` runs both implementations
34
+ * over a shared table of inputs and fails if they ever disagree. That spec
35
+ * lives in the console because the console is allowed to import both.
36
+ *
37
+ * ## What it does
38
+ *
39
+ * A picked image is stored as `media:{scope}/{mediaId}`, optionally with a
40
+ * content pin (`@{contentHash}`, AGL-2685), and resolves to the SITE-RELATIVE
41
+ * path `/api/media/cdn/{scope}/{mediaId}`. A browser has a page
42
+ * to resolve that against; an inbox does not. So the reference is resolved and
43
+ * then prefixed with the origin of whichever host is sending.
44
+ */ const MEDIA_REF_PREFIX = 'media:';
45
+ const MEDIA_REF_HASH_SEPARATOR = '@';
46
+ const MEDIA_CDN_ROUTE = '/api/media/cdn';
47
+ const ORG_SCOPE_PREFIX = 'org:';
48
+ /** Mirrors `SEGMENT` in media-ref.ts, which mirrors the CDN's own grammar. */ const SEGMENT = /^[A-Za-z0-9_-]{1,64}$/;
49
+ /** Mirrors `isMediaCdnScope`. */ function isCdnScope(scope) {
50
+ if (!scope.startsWith(ORG_SCOPE_PREFIX)) return SEGMENT.test(scope);
51
+ const parts = scope.slice(ORG_SCOPE_PREFIX.length).split(':');
52
+ if (parts.length < 1 || parts.length > 2) return false;
53
+ return parts.every((part)=>SEGMENT.test(part));
54
+ }
55
+ /** Mirrors `hostQualifiedScope`. */ function hostQualified(scope, hostId) {
56
+ if (!hostId || !SEGMENT.test(hostId)) return scope;
57
+ if (!scope.startsWith(ORG_SCOPE_PREFIX)) return scope;
58
+ const orgId = scope.slice(ORG_SCOPE_PREFIX.length).split(':')[0];
59
+ if (!orgId || !SEGMENT.test(orgId)) return scope;
60
+ return `${ORG_SCOPE_PREFIX}${orgId}:${hostId}`;
61
+ }
62
+ /** Mirrors `resolveMediaSrc`. */ export function resolveEmailMediaSrc(value, hostId) {
63
+ if (!value) return undefined;
64
+ if (!value.startsWith(MEDIA_REF_PREFIX)) return value;
65
+ const rest = value.slice(MEDIA_REF_PREFIX.length);
66
+ // The scope may contain ':' but never '/', so the FIRST slash is the
67
+ // boundary and the media id is everything after it.
68
+ const slash = rest.indexOf('/');
69
+ if (slash <= 0) return undefined;
70
+ const scope = rest.slice(0, slash);
71
+ // A reference may carry a content pin, `…/{mediaId}@{contentHash}`
72
+ // (AGL-2685). Neither half can contain '@', so the first one is the
73
+ // boundary, and the pin itself names no URL.
74
+ const tail = rest.slice(slash + 1);
75
+ const at = tail.indexOf(MEDIA_REF_HASH_SEPARATOR);
76
+ const mediaId = at === -1 ? tail : tail.slice(0, at);
77
+ if (!isCdnScope(scope) || !SEGMENT.test(mediaId)) return undefined;
78
+ // The stable URL, pinned or not (AGL-2798). An inbox is the consumer that
79
+ // can least be re-rendered, which makes it the last place to hand a URL
80
+ // whose year-long `immutable` response no replace can reach.
81
+ return `${MEDIA_CDN_ROUTE}/${hostQualified(scope, hostId)}/${mediaId}`;
82
+ }
83
+ /**
84
+ * The tenant apex, mirroring `TENANT_APEX` (AGL-2195).
85
+ *
86
+ * NOT an import of it, and the reason is a lint rule rather than taste:
87
+ * `shared-util-email` is tagged `scope:shared`, `@aglyn/aglyn` is not, and
88
+ * `@nx/enforce-module-boundaries` refuses the edge. So the copy is structural.
89
+ * What keeps it honest is `apps/console/specs/email-media-src-drift.spec.ts`,
90
+ * which lives in a project that CAN see both and asserts the two agree —
91
+ * including under a configured apex.
92
+ *
93
+ * The expression is byte-for-byte `TENANT_APEX`'s, trim included: a mirror
94
+ * that normalised differently would disagree only on the malformed inputs
95
+ * nobody writes a test for.
96
+ */ const TENANT_APEX = (process.env.NEXT_PUBLIC_TENANT_DOMAIN || '').trim() || 'aglyn.app';
97
+ /**
98
+ * Mirrors `hostPublicOrigin` — now including the part that was configurable
99
+ * (AGL-2195).
100
+ *
101
+ * The docblock said "mirrors" while the body wrote `aglyn.app` in flat, so
102
+ * every absolute link and image `src` in an email a self-hosted deployment
103
+ * sent pointed at Aglyn's apex. An email is the sharpest place to get this
104
+ * wrong: the URL leaves the deployment and is clicked days later from an
105
+ * inbox, where nothing can rewrite it.
106
+ *
107
+ */ export function hostEmailOrigin(host) {
108
+ if (host == null ? void 0 : host.cname) return `https://${host.cname}`;
109
+ if (host == null ? void 0 : host.subdomain) return `https://${host.subdomain}.${TENANT_APEX}`;
110
+ return undefined;
111
+ }
112
+
113
+ //# sourceMappingURL=email-media-src.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/email-media-src.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 * Absolute image URLs for email (AGL-1224).\n *\n * ## Why this is a mirror rather than an import\n *\n * The authority is `resolveMediaSrc` / `hostPublicOrigin` in\n * `libs/aglyn/src/lib/app-utils/` and this file must not diverge from it. It\n * cannot import it: `shared-util-email` is tagged `scope:shared`, and the\n * module-boundary rule makes shared libs LEAVES — `scope:shared` may depend\n * only on `scope:shared`, while `scope:aglyn` and `aglyn:framework` may depend\n * on shared. The arrow points one way on purpose, so that every send site can\n * import this renderer without dragging the framework in behind it.\n *\n * The same file already solves the same problem the same way: see\n * `EMAIL_NODE_ROOT_ID` in `./email-render`, a copy of the besigner's\n * `CANVAS_ROOT_ELEMENT_ID` held here with a drift guard in the console specs.\n * So: copy, name it a copy, and PIN it —\n * `apps/console/specs/email-media-src-drift.spec.ts` runs both implementations\n * over a shared table of inputs and fails if they ever disagree. That spec\n * lives in the console because the console is allowed to import both.\n *\n * ## What it does\n *\n * A picked image is stored as `media:{scope}/{mediaId}`, optionally with a\n * content pin (`@{contentHash}`, AGL-2685), and resolves to the SITE-RELATIVE\n * path `/api/media/cdn/{scope}/{mediaId}`. A browser has a page\n * to resolve that against; an inbox does not. So the reference is resolved and\n * then prefixed with the origin of whichever host is sending.\n */\n\nconst MEDIA_REF_PREFIX = 'media:'\nconst MEDIA_REF_HASH_SEPARATOR = '@'\nconst MEDIA_CDN_ROUTE = '/api/media/cdn'\nconst ORG_SCOPE_PREFIX = 'org:'\n/** Mirrors `SEGMENT` in media-ref.ts, which mirrors the CDN's own grammar. */\nconst SEGMENT = /^[A-Za-z0-9_-]{1,64}$/\n\n/** Mirrors `isMediaCdnScope`. */\nfunction isCdnScope(scope: string): boolean {\n if (!scope.startsWith(ORG_SCOPE_PREFIX)) return SEGMENT.test(scope)\n const parts = scope.slice(ORG_SCOPE_PREFIX.length).split(':')\n if (parts.length < 1 || parts.length > 2) return false\n return parts.every((part) => SEGMENT.test(part))\n}\n\n/** Mirrors `hostQualifiedScope`. */\nfunction hostQualified(scope: string, hostId: string | undefined | null) {\n if (!hostId || !SEGMENT.test(hostId)) return scope\n if (!scope.startsWith(ORG_SCOPE_PREFIX)) return scope\n const orgId = scope.slice(ORG_SCOPE_PREFIX.length).split(':')[0]\n if (!orgId || !SEGMENT.test(orgId)) return scope\n return `${ORG_SCOPE_PREFIX}${orgId}:${hostId}`\n}\n\n/** Mirrors `resolveMediaSrc`. */\nexport function resolveEmailMediaSrc(\n value: string | undefined | null,\n hostId?: string | null,\n): string | undefined {\n if (!value) return undefined\n if (!value.startsWith(MEDIA_REF_PREFIX)) return value\n const rest = value.slice(MEDIA_REF_PREFIX.length)\n // The scope may contain ':' but never '/', so the FIRST slash is the\n // boundary and the media id is everything after it.\n const slash = rest.indexOf('/')\n if (slash <= 0) return undefined\n const scope = rest.slice(0, slash)\n // A reference may carry a content pin, `…/{mediaId}@{contentHash}`\n // (AGL-2685). Neither half can contain '@', so the first one is the\n // boundary, and the pin itself names no URL.\n const tail = rest.slice(slash + 1)\n const at = tail.indexOf(MEDIA_REF_HASH_SEPARATOR)\n const mediaId = at === -1 ? tail : tail.slice(0, at)\n if (!isCdnScope(scope) || !SEGMENT.test(mediaId)) return undefined\n // The stable URL, pinned or not (AGL-2798). An inbox is the consumer that\n // can least be re-rendered, which makes it the last place to hand a URL\n // whose year-long `immutable` response no replace can reach.\n return `${MEDIA_CDN_ROUTE}/${hostQualified(scope, hostId)}/${mediaId}`\n}\n\n/**\n * The tenant apex, mirroring `TENANT_APEX` (AGL-2195).\n *\n * NOT an import of it, and the reason is a lint rule rather than taste:\n * `shared-util-email` is tagged `scope:shared`, `@aglyn/aglyn` is not, and\n * `@nx/enforce-module-boundaries` refuses the edge. So the copy is structural.\n * What keeps it honest is `apps/console/specs/email-media-src-drift.spec.ts`,\n * which lives in a project that CAN see both and asserts the two agree —\n * including under a configured apex.\n *\n * The expression is byte-for-byte `TENANT_APEX`'s, trim included: a mirror\n * that normalised differently would disagree only on the malformed inputs\n * nobody writes a test for.\n */\nconst TENANT_APEX =\n (process.env.NEXT_PUBLIC_TENANT_DOMAIN || '').trim() || 'aglyn.app'\n\n/**\n * Mirrors `hostPublicOrigin` — now including the part that was configurable\n * (AGL-2195).\n *\n * The docblock said \"mirrors\" while the body wrote `aglyn.app` in flat, so\n * every absolute link and image `src` in an email a self-hosted deployment\n * sent pointed at Aglyn's apex. An email is the sharpest place to get this\n * wrong: the URL leaves the deployment and is clicked days later from an\n * inbox, where nothing can rewrite it.\n *\n */\nexport function hostEmailOrigin(\n host: { cname?: string | null; subdomain?: string | null } | null | undefined,\n): string | undefined {\n if (host?.cname) return `https://${host.cname}`\n if (host?.subdomain) return `https://${host.subdomain}.${TENANT_APEX}`\n return undefined\n}\n"],"names":["MEDIA_REF_PREFIX","MEDIA_REF_HASH_SEPARATOR","MEDIA_CDN_ROUTE","ORG_SCOPE_PREFIX","SEGMENT","isCdnScope","scope","startsWith","test","parts","slice","length","split","every","part","hostQualified","hostId","orgId","resolveEmailMediaSrc","value","undefined","rest","slash","indexOf","tail","at","mediaId","TENANT_APEX","process","env","NEXT_PUBLIC_TENANT_DOMAIN","trim","hostEmailOrigin","host","cname","subdomain"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BC,GAED,MAAMA,mBAAmB;AACzB,MAAMC,2BAA2B;AACjC,MAAMC,kBAAkB;AACxB,MAAMC,mBAAmB;AACzB,4EAA4E,GAC5E,MAAMC,UAAU;AAEhB,+BAA+B,GAC/B,SAASC,WAAWC,KAAa;IAC/B,IAAI,CAACA,MAAMC,UAAU,CAACJ,mBAAmB,OAAOC,QAAQI,IAAI,CAACF;IAC7D,MAAMG,QAAQH,MAAMI,KAAK,CAACP,iBAAiBQ,MAAM,EAAEC,KAAK,CAAC;IACzD,IAAIH,MAAME,MAAM,GAAG,KAAKF,MAAME,MAAM,GAAG,GAAG,OAAO;IACjD,OAAOF,MAAMI,KAAK,CAAC,CAACC,OAASV,QAAQI,IAAI,CAACM;AAC5C;AAEA,kCAAkC,GAClC,SAASC,cAAcT,KAAa,EAAEU,MAAiC;IACrE,IAAI,CAACA,UAAU,CAACZ,QAAQI,IAAI,CAACQ,SAAS,OAAOV;IAC7C,IAAI,CAACA,MAAMC,UAAU,CAACJ,mBAAmB,OAAOG;IAChD,MAAMW,QAAQX,MAAMI,KAAK,CAACP,iBAAiBQ,MAAM,EAAEC,KAAK,CAAC,IAAI,CAAC,EAAE;IAChE,IAAI,CAACK,SAAS,CAACb,QAAQI,IAAI,CAACS,QAAQ,OAAOX;IAC3C,OAAO,GAAGH,mBAAmBc,MAAM,CAAC,EAAED,QAAQ;AAChD;AAEA,+BAA+B,GAC/B,OAAO,SAASE,qBACdC,KAAgC,EAChCH,MAAsB;IAEtB,IAAI,CAACG,OAAO,OAAOC;IACnB,IAAI,CAACD,MAAMZ,UAAU,CAACP,mBAAmB,OAAOmB;IAChD,MAAME,OAAOF,MAAMT,KAAK,CAACV,iBAAiBW,MAAM;IAChD,qEAAqE;IACrE,oDAAoD;IACpD,MAAMW,QAAQD,KAAKE,OAAO,CAAC;IAC3B,IAAID,SAAS,GAAG,OAAOF;IACvB,MAAMd,QAAQe,KAAKX,KAAK,CAAC,GAAGY;IAC5B,mEAAmE;IACnE,oEAAoE;IACpE,6CAA6C;IAC7C,MAAME,OAAOH,KAAKX,KAAK,CAACY,QAAQ;IAChC,MAAMG,KAAKD,KAAKD,OAAO,CAACtB;IACxB,MAAMyB,UAAUD,OAAO,CAAC,IAAID,OAAOA,KAAKd,KAAK,CAAC,GAAGe;IACjD,IAAI,CAACpB,WAAWC,UAAU,CAACF,QAAQI,IAAI,CAACkB,UAAU,OAAON;IACzD,0EAA0E;IAC1E,wEAAwE;IACxE,6DAA6D;IAC7D,OAAO,GAAGlB,gBAAgB,CAAC,EAAEa,cAAcT,OAAOU,QAAQ,CAAC,EAAEU,SAAS;AACxE;AAEA;;;;;;;;;;;;;CAaC,GACD,MAAMC,cACJ,AAACC,CAAAA,QAAQC,GAAG,CAACC,yBAAyB,IAAI,EAAC,EAAGC,IAAI,MAAM;AAE1D;;;;;;;;;;CAUC,GACD,OAAO,SAASC,gBACdC,IAA6E;IAE7E,IAAIA,wBAAAA,KAAMC,KAAK,EAAE,OAAO,CAAC,QAAQ,EAAED,KAAKC,KAAK,EAAE;IAC/C,IAAID,wBAAAA,KAAME,SAAS,EAAE,OAAO,CAAC,QAAQ,EAAEF,KAAKE,SAAS,CAAC,CAAC,EAAER,aAAa;IACtE,OAAOP;AACT"}
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * Campaign merge tags (AGL-272): `{{name}}`, `{{firstName}}`, and
19
+ * `{{email}}` personalize the subject and body per recipient at send
20
+ * time. A pipe sets the fallback when the value is unknown —
21
+ * `{{name|there}}` renders "there" for recipients without a stored name.
22
+ * Unknown tags render their fallback (or empty), never the raw token.
23
+ */
24
+ export interface MergeTagRecipient {
25
+ email: string;
26
+ /** Display name when the audience source stores one (leads/contacts). */
27
+ name?: string;
28
+ }
29
+ export declare function resolveMergeTags(template: string, recipient: MergeTagRecipient): string;
30
+ /** True when the template uses at least one recognized merge tag. */
31
+ export declare function hasMergeTags(template: string): boolean;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /**
17
+ * Campaign merge tags (AGL-272): `{{name}}`, `{{firstName}}`, and
18
+ * `{{email}}` personalize the subject and body per recipient at send
19
+ * time. A pipe sets the fallback when the value is unknown —
20
+ * `{{name|there}}` renders "there" for recipients without a stored name.
21
+ * Unknown tags render their fallback (or empty), never the raw token.
22
+ */ const MERGE_TAG_PATTERN = /\{\{\s*([a-zA-Z]+)\s*(?:\|([^}]*))?\}\}/g;
23
+ export function resolveMergeTags(template, recipient) {
24
+ return template.replace(MERGE_TAG_PATTERN, (_match, tag, fallback)=>{
25
+ var _recipient_name;
26
+ const name = ((_recipient_name = recipient.name) != null ? _recipient_name : '').trim();
27
+ const value = tag === 'email' ? recipient.email : tag === 'name' ? name : tag === 'firstName' ? name.split(/\s+/)[0] : '';
28
+ return value || String(fallback != null ? fallback : '').trim();
29
+ });
30
+ }
31
+ /** True when the template uses at least one recognized merge tag. */ export function hasMergeTags(template) {
32
+ MERGE_TAG_PATTERN.lastIndex = 0;
33
+ return MERGE_TAG_PATTERN.test(template);
34
+ }
35
+
36
+ //# sourceMappingURL=email-merge.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/email-merge.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 * Campaign merge tags (AGL-272): `{{name}}`, `{{firstName}}`, and\n * `{{email}}` personalize the subject and body per recipient at send\n * time. A pipe sets the fallback when the value is unknown —\n * `{{name|there}}` renders \"there\" for recipients without a stored name.\n * Unknown tags render their fallback (or empty), never the raw token.\n */\nexport interface MergeTagRecipient {\n email: string\n /** Display name when the audience source stores one (leads/contacts). */\n name?: string\n}\n\nconst MERGE_TAG_PATTERN = /\\{\\{\\s*([a-zA-Z]+)\\s*(?:\\|([^}]*))?\\}\\}/g\n\nexport function resolveMergeTags(\n template: string,\n recipient: MergeTagRecipient,\n): string {\n return template.replace(MERGE_TAG_PATTERN, (_match, tag, fallback) => {\n const name = (recipient.name ?? '').trim()\n const value =\n tag === 'email'\n ? recipient.email\n : tag === 'name'\n ? name\n : tag === 'firstName'\n ? name.split(/\\s+/)[0]\n : ''\n return value || String(fallback ?? '').trim()\n })\n}\n\n/** True when the template uses at least one recognized merge tag. */\nexport function hasMergeTags(template: string): boolean {\n MERGE_TAG_PATTERN.lastIndex = 0\n return MERGE_TAG_PATTERN.test(template)\n}\n"],"names":["MERGE_TAG_PATTERN","resolveMergeTags","template","recipient","replace","_match","tag","fallback","name","trim","value","email","split","String","hasMergeTags","lastIndex","test"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;CAMC,GAOD,MAAMA,oBAAoB;AAE1B,OAAO,SAASC,iBACdC,QAAgB,EAChBC,SAA4B;IAE5B,OAAOD,SAASE,OAAO,CAACJ,mBAAmB,CAACK,QAAQC,KAAKC;YACzCJ;QAAd,MAAMK,OAAO,EAACL,kBAAAA,UAAUK,IAAI,YAAdL,kBAAkB,IAAIM,IAAI;QACxC,MAAMC,QACJJ,QAAQ,UACJH,UAAUQ,KAAK,GACfL,QAAQ,SACNE,OACAF,QAAQ,cACNE,KAAKI,KAAK,CAAC,MAAM,CAAC,EAAE,GACpB;QACV,OAAOF,SAASG,OAAON,mBAAAA,WAAY,IAAIE,IAAI;IAC7C;AACF;AAEA,mEAAmE,GACnE,OAAO,SAASK,aAAaZ,QAAgB;IAC3CF,kBAAkBe,SAAS,GAAG;IAC9B,OAAOf,kBAAkBgB,IAAI,CAACd;AAChC"}