@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,99 @@
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
+ import { type EmailRenderOptions } from './email-render';
18
+ import { type TenantEmailEntry } from './tenant-email-catalog';
19
+ /**
20
+ * The slice of the Admin Firestore chain this resolver uses. Structural on
21
+ * purpose (AGL-770): the plugins that call this already hold an Admin
22
+ * Firestore instance, so passing it in keeps `@aglyn/shared-util-email` free
23
+ * of a firebase-admin dependency and importable from every send site.
24
+ */
25
+ interface AdminDocSnapshotLike {
26
+ exists: boolean;
27
+ get(field: string): unknown;
28
+ }
29
+ interface AdminDocRefLike {
30
+ get(): Promise<AdminDocSnapshotLike>;
31
+ collection(path: string): AdminCollectionRefLike;
32
+ }
33
+ interface AdminCollectionRefLike {
34
+ doc(id: string): AdminDocRefLike;
35
+ }
36
+ export interface AdminFirestoreLike {
37
+ collection(path: string): AdminCollectionRefLike;
38
+ }
39
+ export interface RenderedHostEmail {
40
+ subject: string;
41
+ html: string;
42
+ text: string;
43
+ }
44
+ /**
45
+ * A site-owner-designed template loaded once, ready to render for any number
46
+ * of recipients without another Firestore read (AGL-770) — the booking
47
+ * reminder job renders one per booking.
48
+ */
49
+ export interface LoadedHostEmail {
50
+ entry: TenantEmailEntry;
51
+ nodes: Record<string, unknown>;
52
+ subjectTemplate: string;
53
+ preheaderTemplate: string;
54
+ /** Host doc id, so an org-scoped media reference can be host-qualified. */
55
+ hostId: string;
56
+ /**
57
+ * The site's absolute origin, for absolutizing picked images (AGL-1224).
58
+ * Undefined only for a host with neither a custom domain nor a subdomain,
59
+ * in which case those images are dropped rather than sent broken.
60
+ */
61
+ origin?: string;
62
+ }
63
+ /**
64
+ * Loads a site's published email template, or `null` when there is nothing
65
+ * usable (no document, no published version, an empty node map, an unknown or
66
+ * non-designable key). The host-scoped mirror of the platform
67
+ * `loadSystemEmail`: `hosts/{hostId}/emailTemplates/{key}`.
68
+ *
69
+ * The caller passes its own Admin Firestore, so this reads through the Admin
70
+ * SDK — the send path runs as the server, not a signed-in user.
71
+ *
72
+ * TWO reads, not one (AGL-1224): the host document is read for the site's
73
+ * origin, which picked images need to be fetchable from an inbox. It is read
74
+ * LAST, only once there is something to render, so a site with nothing
75
+ * published still costs a single read — and it is skipped entirely when the
76
+ * caller already knows the origin and passes it. Still once per batch, not
77
+ * once per recipient, which is the property AGL-770 cared about.
78
+ */
79
+ export declare function loadHostEmail(firestore: AdminFirestoreLike, hostId: string, templateKey: string, options?: {
80
+ origin?: string;
81
+ }): Promise<LoadedHostEmail | null>;
82
+ /**
83
+ * Renders a pre-loaded template for one recipient's merge values (AGL-770).
84
+ * No Firestore access; returns `null` if the node map renders empty so the
85
+ * caller still falls back to its built-in copy.
86
+ */
87
+ export declare function renderLoadedHostEmail(loaded: LoadedHostEmail, merge: Record<string, string>, sanitize: EmailRenderOptions['sanitize']): RenderedHostEmail | null;
88
+ /**
89
+ * Renders a site's designed email, or `null` when nothing usable is published
90
+ * so the send site keeps its built-in copy (AGL-770). Single-recipient
91
+ * convenience over {@link loadHostEmail} + {@link renderLoadedHostEmail}; a
92
+ * batch should call those two so it reads the template once.
93
+ */
94
+ export declare function renderHostEmail(firestore: AdminFirestoreLike, hostId: string, templateKey: string, merge: Record<string, string>, options: {
95
+ origin?: string;
96
+ /** See {@link EmailRenderOptions.sanitize} — required, deliberately. */
97
+ sanitize: EmailRenderOptions['sanitize'];
98
+ }): Promise<RenderedHostEmail | null>;
99
+ export {};
@@ -0,0 +1,126 @@
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
+ */ import { hostEmailOrigin } from "./email-media-src.js";
17
+ import { decodeEmailNodes } from "./stored-email-nodes.js";
18
+ import { EMAIL_NODE_ROOT_ID, renderEmailHtml, substituteMergeTokens } from "./email-render.js";
19
+ import { getTenantEmail, isTenantEmailEditable, TENANT_EMAIL_COLLECTION } from "./tenant-email-catalog.js";
20
+ /** Blanks any `{{token}}` the caller did not supply, so a customer never
21
+ * sees a raw tag. Mirrors the platform system-email resolver. */ function blankUnresolvedTokens(value) {
22
+ return value.replace(/\{\{[^}]*\}\}/g, '');
23
+ }
24
+ /**
25
+ * Loads a site's published email template, or `null` when there is nothing
26
+ * usable (no document, no published version, an empty node map, an unknown or
27
+ * non-designable key). The host-scoped mirror of the platform
28
+ * `loadSystemEmail`: `hosts/{hostId}/emailTemplates/{key}`.
29
+ *
30
+ * The caller passes its own Admin Firestore, so this reads through the Admin
31
+ * SDK — the send path runs as the server, not a signed-in user.
32
+ *
33
+ * TWO reads, not one (AGL-1224): the host document is read for the site's
34
+ * origin, which picked images need to be fetchable from an inbox. It is read
35
+ * LAST, only once there is something to render, so a site with nothing
36
+ * published still costs a single read — and it is skipped entirely when the
37
+ * caller already knows the origin and passes it. Still once per batch, not
38
+ * once per recipient, which is the property AGL-770 cared about.
39
+ */ export async function loadHostEmail(firestore, hostId, templateKey, options = {}) {
40
+ const entry = getTenantEmail(templateKey);
41
+ if (!entry || !isTenantEmailEditable(entry)) return null;
42
+ if (!hostId) return null;
43
+ try {
44
+ var _templateSnapshot_get, _templateSnapshot_get1;
45
+ const hostRef = firestore.collection('hosts').doc(hostId);
46
+ const templateRef = hostRef.collection(TENANT_EMAIL_COLLECTION).doc(templateKey);
47
+ const templateSnapshot = await templateRef.get();
48
+ if (!templateSnapshot.exists) return null;
49
+ const versionId = templateSnapshot.get('versionId');
50
+ if (!versionId) return null;
51
+ const versionSnapshot = await templateRef.collection('versions').doc(String(versionId)).get();
52
+ // BOTH stored forms. The email besigner compresses like every other
53
+ // besigner document (AGL-1151), and every version written before it did
54
+ // is still a plain map — a plain map comes back unchanged, so a single
55
+ // call serves both and keeps serving both.
56
+ //
57
+ // Reading the field raw is the failure the guard below cannot catch: over
58
+ // a `Buffer`, `Object.keys` counts BYTE INDICES, so the emptiness test
59
+ // passes and the caller renders an empty email instead of falling back to
60
+ // its built-in copy (AGL-1223).
61
+ const nodes = decodeEmailNodes(versionSnapshot.get('nodes'));
62
+ if (!nodes || !Object.keys(nodes).length) return null;
63
+ let origin = options.origin;
64
+ if (!origin) {
65
+ const hostSnapshot = await hostRef.get();
66
+ origin = hostEmailOrigin({
67
+ cname: hostSnapshot.get('cname'),
68
+ subdomain: hostSnapshot.get('subdomain')
69
+ });
70
+ }
71
+ return {
72
+ entry,
73
+ nodes,
74
+ hostId,
75
+ origin,
76
+ subjectTemplate: String((_templateSnapshot_get = templateSnapshot.get('subject')) != null ? _templateSnapshot_get : '') || entry.defaultSubject || '',
77
+ preheaderTemplate: String((_templateSnapshot_get1 = templateSnapshot.get('preheader')) != null ? _templateSnapshot_get1 : '')
78
+ };
79
+ } catch (error) {
80
+ // Never let a template problem block the send — the caller falls back to
81
+ // its built-in copy and the customer still gets their email.
82
+ console.error(`host email template ${templateKey} failed to load`, error);
83
+ return null;
84
+ }
85
+ }
86
+ /**
87
+ * Renders a pre-loaded template for one recipient's merge values (AGL-770).
88
+ * No Firestore access; returns `null` if the node map renders empty so the
89
+ * caller still falls back to its built-in copy.
90
+ */ export function renderLoadedHostEmail(loaded, merge = {}, sanitize) {
91
+ var _rendered_text;
92
+ const rendered = renderEmailHtml({
93
+ nodes: loaded.nodes,
94
+ // A site owner designs these in the besigner, so a template's richtext is
95
+ // author markup exactly as a campaign's is. The policy has to arrive from
96
+ // above for the reason this lib documents everywhere: `scope:shared`
97
+ // cannot import the aglyn-scoped sanitizer.
98
+ sanitize,
99
+ // Besigner maps are rooted at '_@_' (AGL-765).
100
+ rootId: EMAIL_NODE_ROOT_ID,
101
+ subject: substituteMergeTokens(loaded.subjectTemplate, merge),
102
+ preheader: substituteMergeTokens(loaded.preheaderTemplate, merge),
103
+ merge,
104
+ // A picked image is stored as a reference; the site's own origin is what
105
+ // makes it fetchable from a recipient's inbox (AGL-1224).
106
+ mediaOrigin: loaded.origin,
107
+ mediaHostId: loaded.hostId
108
+ });
109
+ if (!(rendered == null ? void 0 : rendered.html)) return null;
110
+ return {
111
+ subject: blankUnresolvedTokens(substituteMergeTokens(loaded.subjectTemplate, merge)),
112
+ html: blankUnresolvedTokens(rendered.html),
113
+ text: blankUnresolvedTokens((_rendered_text = rendered.text) != null ? _rendered_text : '')
114
+ };
115
+ }
116
+ /**
117
+ * Renders a site's designed email, or `null` when nothing usable is published
118
+ * so the send site keeps its built-in copy (AGL-770). Single-recipient
119
+ * convenience over {@link loadHostEmail} + {@link renderLoadedHostEmail}; a
120
+ * batch should call those two so it reads the template once.
121
+ */ export async function renderHostEmail(firestore, hostId, templateKey, merge = {}, options) {
122
+ const loaded = await loadHostEmail(firestore, hostId, templateKey, options);
123
+ return loaded ? renderLoadedHostEmail(loaded, merge, options.sanitize) : null;
124
+ }
125
+
126
+ //# sourceMappingURL=host-email-render.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/host-email-render.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 { hostEmailOrigin } from './email-media-src'\nimport { decodeEmailNodes } from './stored-email-nodes'\nimport {\n EMAIL_NODE_ROOT_ID,\n renderEmailHtml,\n substituteMergeTokens,\n type EmailRenderOptions,\n} from './email-render'\nimport {\n getTenantEmail,\n isTenantEmailEditable,\n TENANT_EMAIL_COLLECTION,\n type TenantEmailEntry,\n} from './tenant-email-catalog'\n\n/**\n * The slice of the Admin Firestore chain this resolver uses. Structural on\n * purpose (AGL-770): the plugins that call this already hold an Admin\n * Firestore instance, so passing it in keeps `@aglyn/shared-util-email` free\n * of a firebase-admin dependency and importable from every send site.\n */\ninterface AdminDocSnapshotLike {\n exists: boolean\n get(field: string): unknown\n}\ninterface AdminDocRefLike {\n get(): Promise<AdminDocSnapshotLike>\n collection(path: string): AdminCollectionRefLike\n}\ninterface AdminCollectionRefLike {\n doc(id: string): AdminDocRefLike\n}\nexport interface AdminFirestoreLike {\n collection(path: string): AdminCollectionRefLike\n}\n\nexport interface RenderedHostEmail {\n subject: string\n html: string\n text: string\n}\n\n/**\n * A site-owner-designed template loaded once, ready to render for any number\n * of recipients without another Firestore read (AGL-770) — the booking\n * reminder job renders one per booking.\n */\nexport interface LoadedHostEmail {\n entry: TenantEmailEntry\n nodes: Record<string, unknown>\n subjectTemplate: string\n preheaderTemplate: string\n /** Host doc id, so an org-scoped media reference can be host-qualified. */\n hostId: string\n /**\n * The site's absolute origin, for absolutizing picked images (AGL-1224).\n * Undefined only for a host with neither a custom domain nor a subdomain,\n * in which case those images are dropped rather than sent broken.\n */\n origin?: string\n}\n\n/** Blanks any `{{token}}` the caller did not supply, so a customer never\n * sees a raw tag. Mirrors the platform system-email resolver. */\nfunction blankUnresolvedTokens(value: string): string {\n return value.replace(/\\{\\{[^}]*\\}\\}/g, '')\n}\n\n/**\n * Loads a site's published email template, or `null` when there is nothing\n * usable (no document, no published version, an empty node map, an unknown or\n * non-designable key). The host-scoped mirror of the platform\n * `loadSystemEmail`: `hosts/{hostId}/emailTemplates/{key}`.\n *\n * The caller passes its own Admin Firestore, so this reads through the Admin\n * SDK — the send path runs as the server, not a signed-in user.\n *\n * TWO reads, not one (AGL-1224): the host document is read for the site's\n * origin, which picked images need to be fetchable from an inbox. It is read\n * LAST, only once there is something to render, so a site with nothing\n * published still costs a single read — and it is skipped entirely when the\n * caller already knows the origin and passes it. Still once per batch, not\n * once per recipient, which is the property AGL-770 cared about.\n */\nexport async function loadHostEmail(\n firestore: AdminFirestoreLike,\n hostId: string,\n templateKey: string,\n options: { origin?: string } = {},\n): Promise<LoadedHostEmail | null> {\n const entry = getTenantEmail(templateKey)\n if (!entry || !isTenantEmailEditable(entry)) return null\n if (!hostId) return null\n\n try {\n const hostRef = firestore.collection('hosts').doc(hostId)\n const templateRef = hostRef\n .collection(TENANT_EMAIL_COLLECTION)\n .doc(templateKey)\n const templateSnapshot = await templateRef.get()\n if (!templateSnapshot.exists) return null\n\n const versionId = templateSnapshot.get('versionId')\n if (!versionId) return null\n\n const versionSnapshot = await templateRef\n .collection('versions')\n .doc(String(versionId))\n .get()\n // BOTH stored forms. The email besigner compresses like every other\n // besigner document (AGL-1151), and every version written before it did\n // is still a plain map — a plain map comes back unchanged, so a single\n // call serves both and keeps serving both.\n //\n // Reading the field raw is the failure the guard below cannot catch: over\n // a `Buffer`, `Object.keys` counts BYTE INDICES, so the emptiness test\n // passes and the caller renders an empty email instead of falling back to\n // its built-in copy (AGL-1223).\n const nodes = decodeEmailNodes<Record<string, unknown>>(\n versionSnapshot.get('nodes'),\n )\n if (!nodes || !Object.keys(nodes).length) return null\n\n let origin = options.origin\n if (!origin) {\n const hostSnapshot = await hostRef.get()\n origin = hostEmailOrigin({\n cname: hostSnapshot.get('cname') as string | undefined,\n subdomain: hostSnapshot.get('subdomain') as string | undefined,\n })\n }\n\n return {\n entry,\n nodes,\n hostId,\n origin,\n subjectTemplate:\n String(templateSnapshot.get('subject') ?? '') ||\n entry.defaultSubject ||\n '',\n preheaderTemplate: String(templateSnapshot.get('preheader') ?? ''),\n }\n } catch (error) {\n // Never let a template problem block the send — the caller falls back to\n // its built-in copy and the customer still gets their email.\n console.error(`host email template ${templateKey} failed to load`, error)\n return null\n }\n}\n\n/**\n * Renders a pre-loaded template for one recipient's merge values (AGL-770).\n * No Firestore access; returns `null` if the node map renders empty so the\n * caller still falls back to its built-in copy.\n */\nexport function renderLoadedHostEmail(\n loaded: LoadedHostEmail,\n merge: Record<string, string> = {},\n sanitize: EmailRenderOptions['sanitize'],\n): RenderedHostEmail | null {\n const rendered = renderEmailHtml({\n nodes: loaded.nodes as never,\n // A site owner designs these in the besigner, so a template's richtext is\n // author markup exactly as a campaign's is. The policy has to arrive from\n // above for the reason this lib documents everywhere: `scope:shared`\n // cannot import the aglyn-scoped sanitizer.\n sanitize,\n // Besigner maps are rooted at '_@_' (AGL-765).\n rootId: EMAIL_NODE_ROOT_ID,\n subject: substituteMergeTokens(loaded.subjectTemplate, merge),\n preheader: substituteMergeTokens(loaded.preheaderTemplate, merge),\n merge,\n // A picked image is stored as a reference; the site's own origin is what\n // makes it fetchable from a recipient's inbox (AGL-1224).\n mediaOrigin: loaded.origin,\n mediaHostId: loaded.hostId,\n })\n if (!rendered?.html) return null\n return {\n subject: blankUnresolvedTokens(\n substituteMergeTokens(loaded.subjectTemplate, merge),\n ),\n html: blankUnresolvedTokens(rendered.html),\n text: blankUnresolvedTokens(rendered.text ?? ''),\n }\n}\n\n/**\n * Renders a site's designed email, or `null` when nothing usable is published\n * so the send site keeps its built-in copy (AGL-770). Single-recipient\n * convenience over {@link loadHostEmail} + {@link renderLoadedHostEmail}; a\n * batch should call those two so it reads the template once.\n */\nexport async function renderHostEmail(\n firestore: AdminFirestoreLike,\n hostId: string,\n templateKey: string,\n merge: Record<string, string> = {},\n options: {\n origin?: string\n /** See {@link EmailRenderOptions.sanitize} — required, deliberately. */\n sanitize: EmailRenderOptions['sanitize']\n },\n): Promise<RenderedHostEmail | null> {\n const loaded = await loadHostEmail(firestore, hostId, templateKey, options)\n return loaded ? renderLoadedHostEmail(loaded, merge, options.sanitize) : null\n}\n"],"names":["hostEmailOrigin","decodeEmailNodes","EMAIL_NODE_ROOT_ID","renderEmailHtml","substituteMergeTokens","getTenantEmail","isTenantEmailEditable","TENANT_EMAIL_COLLECTION","blankUnresolvedTokens","value","replace","loadHostEmail","firestore","hostId","templateKey","options","entry","templateSnapshot","hostRef","collection","doc","templateRef","get","exists","versionId","versionSnapshot","String","nodes","Object","keys","length","origin","hostSnapshot","cname","subdomain","subjectTemplate","defaultSubject","preheaderTemplate","error","console","renderLoadedHostEmail","loaded","merge","sanitize","rendered","rootId","subject","preheader","mediaOrigin","mediaHostId","html","text","renderHostEmail"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,eAAe,QAAQ,uBAAmB;AACnD,SAASC,gBAAgB,QAAQ,0BAAsB;AACvD,SACEC,kBAAkB,EAClBC,eAAe,EACfC,qBAAqB,QAEhB,oBAAgB;AACvB,SACEC,cAAc,EACdC,qBAAqB,EACrBC,uBAAuB,QAElB,4BAAwB;AAiD/B;+DAC+D,GAC/D,SAASC,sBAAsBC,KAAa;IAC1C,OAAOA,MAAMC,OAAO,CAAC,kBAAkB;AACzC;AAEA;;;;;;;;;;;;;;;CAeC,GACD,OAAO,eAAeC,cACpBC,SAA6B,EAC7BC,MAAc,EACdC,WAAmB,EACnBC,UAA+B,CAAC,CAAC;IAEjC,MAAMC,QAAQX,eAAeS;IAC7B,IAAI,CAACE,SAAS,CAACV,sBAAsBU,QAAQ,OAAO;IACpD,IAAI,CAACH,QAAQ,OAAO;IAEpB,IAAI;YA4CSI,uBAGiBA;QA9C5B,MAAMC,UAAUN,UAAUO,UAAU,CAAC,SAASC,GAAG,CAACP;QAClD,MAAMQ,cAAcH,QACjBC,UAAU,CAACZ,yBACXa,GAAG,CAACN;QACP,MAAMG,mBAAmB,MAAMI,YAAYC,GAAG;QAC9C,IAAI,CAACL,iBAAiBM,MAAM,EAAE,OAAO;QAErC,MAAMC,YAAYP,iBAAiBK,GAAG,CAAC;QACvC,IAAI,CAACE,WAAW,OAAO;QAEvB,MAAMC,kBAAkB,MAAMJ,YAC3BF,UAAU,CAAC,YACXC,GAAG,CAACM,OAAOF,YACXF,GAAG;QACN,oEAAoE;QACpE,wEAAwE;QACxE,uEAAuE;QACvE,2CAA2C;QAC3C,EAAE;QACF,0EAA0E;QAC1E,uEAAuE;QACvE,0EAA0E;QAC1E,gCAAgC;QAChC,MAAMK,QAAQ1B,iBACZwB,gBAAgBH,GAAG,CAAC;QAEtB,IAAI,CAACK,SAAS,CAACC,OAAOC,IAAI,CAACF,OAAOG,MAAM,EAAE,OAAO;QAEjD,IAAIC,SAAShB,QAAQgB,MAAM;QAC3B,IAAI,CAACA,QAAQ;YACX,MAAMC,eAAe,MAAMd,QAAQI,GAAG;YACtCS,SAAS/B,gBAAgB;gBACvBiC,OAAOD,aAAaV,GAAG,CAAC;gBACxBY,WAAWF,aAAaV,GAAG,CAAC;YAC9B;QACF;QAEA,OAAO;YACLN;YACAW;YACAd;YACAkB;YACAI,iBACET,QAAOT,wBAAAA,iBAAiBK,GAAG,CAAC,sBAArBL,wBAAmC,OAC1CD,MAAMoB,cAAc,IACpB;YACFC,mBAAmBX,QAAOT,yBAAAA,iBAAiBK,GAAG,CAAC,wBAArBL,yBAAqC;QACjE;IACF,EAAE,OAAOqB,OAAO;QACd,yEAAyE;QACzE,6DAA6D;QAC7DC,QAAQD,KAAK,CAAC,CAAC,oBAAoB,EAAExB,YAAY,eAAe,CAAC,EAAEwB;QACnE,OAAO;IACT;AACF;AAEA;;;;CAIC,GACD,OAAO,SAASE,sBACdC,MAAuB,EACvBC,QAAgC,CAAC,CAAC,EAClCC,QAAwC;QAyBVC;IAvB9B,MAAMA,WAAWzC,gBAAgB;QAC/BwB,OAAOc,OAAOd,KAAK;QACnB,0EAA0E;QAC1E,0EAA0E;QAC1E,qEAAqE;QACrE,4CAA4C;QAC5CgB;QACA,+CAA+C;QAC/CE,QAAQ3C;QACR4C,SAAS1C,sBAAsBqC,OAAON,eAAe,EAAEO;QACvDK,WAAW3C,sBAAsBqC,OAAOJ,iBAAiB,EAAEK;QAC3DA;QACA,yEAAyE;QACzE,0DAA0D;QAC1DM,aAAaP,OAAOV,MAAM;QAC1BkB,aAAaR,OAAO5B,MAAM;IAC5B;IACA,IAAI,EAAC+B,4BAAAA,SAAUM,IAAI,GAAE,OAAO;IAC5B,OAAO;QACLJ,SAAStC,sBACPJ,sBAAsBqC,OAAON,eAAe,EAAEO;QAEhDQ,MAAM1C,sBAAsBoC,SAASM,IAAI;QACzCC,MAAM3C,uBAAsBoC,iBAAAA,SAASO,IAAI,YAAbP,iBAAiB;IAC/C;AACF;AAEA;;;;;CAKC,GACD,OAAO,eAAeQ,gBACpBxC,SAA6B,EAC7BC,MAAc,EACdC,WAAmB,EACnB4B,QAAgC,CAAC,CAAC,EAClC3B,OAIC;IAED,MAAM0B,SAAS,MAAM9B,cAAcC,WAAWC,QAAQC,aAAaC;IACnE,OAAO0B,SAASD,sBAAsBC,QAAQC,OAAO3B,QAAQ4B,QAAQ,IAAI;AAC3E"}
@@ -0,0 +1,86 @@
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
+ /** The subcollection under `hosts/{hostId}` that holds a site's senders. */
18
+ export declare const HOST_SENDERS_COLLECTION = "senders";
19
+ /**
20
+ * The id the site's original sender lives at.
21
+ *
22
+ * A fixed id rather than a generated one, because this document is the
23
+ * materialized form of something that already existed: the three sender fields
24
+ * on the host. Materializing it under a random id would make "which row is the
25
+ * one the host document describes" a question with no answer for anybody
26
+ * reading the data directly, and would let a second materialization mint a
27
+ * duplicate of the same sender.
28
+ */
29
+ export declare const DEFAULT_HOST_SENDER_ID = "default";
30
+ /**
31
+ * How many senders one site may hold.
32
+ *
33
+ * A ceiling rather than a page. Every sender is a mailbox somebody has to
34
+ * serve — bounces and replies arrive at it whether or not anyone reads them —
35
+ * so a list long enough to need paging is a list nobody is operating, and the
36
+ * composer control that renders it is a picker rather than a directory.
37
+ */
38
+ export declare const HOST_SENDER_LIMIT = 25;
39
+ /** One sender, normalized. Every field present; the optional ones as `''`. */
40
+ export interface HostSenderRecord {
41
+ id: string;
42
+ /** The mailbox — the part before the `@`. Never empty on a stored row. */
43
+ localPart: string;
44
+ /** The display name in front of the address, or `''`. */
45
+ fromName: string;
46
+ /** Where replies go when it is not the sending address, or `''`. */
47
+ replyTo: string;
48
+ /** Whether an unnamed send leaves on this one. Exactly one row carries it. */
49
+ isDefault: boolean;
50
+ createdAtMs: number;
51
+ }
52
+ /**
53
+ * One stored sender document, normalized into {@link HostSenderRecord}.
54
+ *
55
+ * Every field is re-normalized on the way out rather than trusted. The route
56
+ * validates on the way in, and a document can also arrive from a restore, a
57
+ * hand edit or a version of the writer that predates a rule — and these values
58
+ * reach a `From:` header, where an unflattened one is the injection shape.
59
+ */
60
+ export declare function readHostSender(options: {
61
+ id: string;
62
+ data: Record<string, unknown> | null | undefined;
63
+ /** `hosts/{hostId}.defaultSenderId`. Decides {@link HostSenderRecord.isDefault}. */
64
+ defaultSenderId?: string | null;
65
+ }): HostSenderRecord;
66
+ /**
67
+ * Which row an unnamed send leaves on, when the stored pointer names nobody.
68
+ *
69
+ * `defaultSenderId` is written beside every change that could move it, so the
70
+ * ordinary case is that it names a row in this list. It can still fail to: a
71
+ * default deleted out of band, a pointer written before the row it names, a
72
+ * partial restore. Answering with a row rather than with nothing is what keeps
73
+ * such a site sending — and the order is fixed, so two readers of the same data
74
+ * cannot disagree about which sender that is.
75
+ */
76
+ export declare function defaultHostSender(senders: readonly HostSenderRecord[]): HostSenderRecord | null;
77
+ /**
78
+ * The whole address one sender leaves on, or `null` on a site whose mailbox is
79
+ * not its own to name.
80
+ *
81
+ * The ONE place a sending address is assembled from its two halves. A surface
82
+ * that built `${localPart}@${domain}` itself would be a second derivation of
83
+ * the address, and the two would disagree the first time either moved — which
84
+ * is the same rule the identity route's `IdentityOption.from` already keeps.
85
+ */
86
+ export declare function hostSenderAddress(localPart: string | null | undefined, domain: string | null | undefined): string | null;
@@ -0,0 +1,131 @@
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
+ * THE SENDERS ONE SITE HOLDS — `hosts/{hostId}/senders/{senderId}`.
18
+ *
19
+ * A sender is a mailbox this site may send as, together with the display name
20
+ * and reply address that ordinarily go with it. A site holds several and a
21
+ * composer picks among them; which one an unnamed send uses is the site's
22
+ * DEFAULT.
23
+ *
24
+ * ## Chosen BY ID, never typed per send
25
+ *
26
+ * `sending-mailbox.ts` states the rule this collection exists to keep: a
27
+ * mailbox addresses something real — a bounce returns to it, and a client
28
+ * ignoring `Reply-To:` answers to it — so a mailbox that exists in one
29
+ * campaign's headers and nowhere else is an address nobody serves.
30
+ * `campaign-send.ts` states the same closure from the other end: the sending
31
+ * identity named in a send request is read by nothing.
32
+ *
33
+ * A sender id does not reopen that path. It names a mailbox that was
34
+ * configured once, validated once and stored on this site, so the set of
35
+ * addresses a send can reach is the set an org admin already approved. A free
36
+ * local part in a send body would be the thing that was closed; a key into a
37
+ * managed list is not.
38
+ *
39
+ * ## The DEFAULT is projected onto the host document
40
+ *
41
+ * `hosts/{hostId}` has carried `sendingLocalPart`, `sendingFromName` and
42
+ * `sendingReplyTo` since before this collection existed, and
43
+ * `resolveHostSendingIdentity` reads the first of them on every tenant send.
44
+ * Those fields remain the projection of the default sender rather than being
45
+ * migrated away: a site already sending as `test@` must not revert to `hello@`
46
+ * because a subcollection it never had is empty.
47
+ *
48
+ * So the two are read in a fixed order. An empty subcollection means the site
49
+ * has exactly one sender, and it is the one the host fields describe —
50
+ * {@link DEFAULT_HOST_SENDER_ID} is the id it is synthesized under, and the id
51
+ * it is materialized at the first time anything writes here.
52
+ *
53
+ * ## Pure, for the reason the modules either side of it are pure
54
+ *
55
+ * The document shape is read by the console route that writes it, by the send
56
+ * path that resolves a chosen one, and by the surfaces that render the list.
57
+ * A shape written down three times is a shape that disagrees with itself the
58
+ * first time any of the three moves.
59
+ */ import { normalizeLocalPart } from "./sending-domain.js";
60
+ import { headerSafeText, SENDING_FROM_NAME_MAX, SENDING_REPLY_TO_MAX } from "./sending-mailbox.js";
61
+ /** The subcollection under `hosts/{hostId}` that holds a site's senders. */ export const HOST_SENDERS_COLLECTION = 'senders';
62
+ /**
63
+ * The id the site's original sender lives at.
64
+ *
65
+ * A fixed id rather than a generated one, because this document is the
66
+ * materialized form of something that already existed: the three sender fields
67
+ * on the host. Materializing it under a random id would make "which row is the
68
+ * one the host document describes" a question with no answer for anybody
69
+ * reading the data directly, and would let a second materialization mint a
70
+ * duplicate of the same sender.
71
+ */ export const DEFAULT_HOST_SENDER_ID = 'default';
72
+ /**
73
+ * How many senders one site may hold.
74
+ *
75
+ * A ceiling rather than a page. Every sender is a mailbox somebody has to
76
+ * serve — bounces and replies arrive at it whether or not anyone reads them —
77
+ * so a list long enough to need paging is a list nobody is operating, and the
78
+ * composer control that renders it is a picker rather than a directory.
79
+ */ export const HOST_SENDER_LIMIT = 25;
80
+ /**
81
+ * One stored sender document, normalized into {@link HostSenderRecord}.
82
+ *
83
+ * Every field is re-normalized on the way out rather than trusted. The route
84
+ * validates on the way in, and a document can also arrive from a restore, a
85
+ * hand edit or a version of the writer that predates a rule — and these values
86
+ * reach a `From:` header, where an unflattened one is the injection shape.
87
+ */ export function readHostSender(options) {
88
+ var _ref, _ref1, _data_localPart, _data_fromName, _data_replyTo, _ref2;
89
+ const data = (_ref = options == null ? void 0 : options.data) != null ? _ref : {};
90
+ const id = String((_ref1 = options == null ? void 0 : options.id) != null ? _ref1 : '');
91
+ const createdAtMs = Number(data['createdAtMs']);
92
+ return {
93
+ id,
94
+ localPart: normalizeLocalPart(String((_data_localPart = data['localPart']) != null ? _data_localPart : '')),
95
+ fromName: headerSafeText(String((_data_fromName = data['fromName']) != null ? _data_fromName : ''), SENDING_FROM_NAME_MAX),
96
+ replyTo: headerSafeText(String((_data_replyTo = data['replyTo']) != null ? _data_replyTo : ''), SENDING_REPLY_TO_MAX).toLowerCase(),
97
+ isDefault: Boolean(id) && id === String((_ref2 = options == null ? void 0 : options.defaultSenderId) != null ? _ref2 : ''),
98
+ createdAtMs: Number.isFinite(createdAtMs) ? createdAtMs : 0
99
+ };
100
+ }
101
+ /**
102
+ * Which row an unnamed send leaves on, when the stored pointer names nobody.
103
+ *
104
+ * `defaultSenderId` is written beside every change that could move it, so the
105
+ * ordinary case is that it names a row in this list. It can still fail to: a
106
+ * default deleted out of band, a pointer written before the row it names, a
107
+ * partial restore. Answering with a row rather than with nothing is what keeps
108
+ * such a site sending — and the order is fixed, so two readers of the same data
109
+ * cannot disagree about which sender that is.
110
+ */ export function defaultHostSender(senders) {
111
+ var _ref, _senders_find;
112
+ if (!(senders == null ? void 0 : senders.length)) return null;
113
+ return (_ref = (_senders_find = senders.find((sender)=>sender.isDefault)) != null ? _senders_find : senders.find((sender)=>sender.id === DEFAULT_HOST_SENDER_ID)) != null ? _ref : [
114
+ ...senders
115
+ ].sort((left, right)=>left.createdAtMs - right.createdAtMs || left.id.localeCompare(right.id))[0];
116
+ }
117
+ /**
118
+ * The whole address one sender leaves on, or `null` on a site whose mailbox is
119
+ * not its own to name.
120
+ *
121
+ * The ONE place a sending address is assembled from its two halves. A surface
122
+ * that built `${localPart}@${domain}` itself would be a second derivation of
123
+ * the address, and the two would disagree the first time either moved — which
124
+ * is the same rule the identity route's `IdentityOption.from` already keeps.
125
+ */ export function hostSenderAddress(localPart, domain) {
126
+ const mailbox = normalizeLocalPart(String(localPart != null ? localPart : ''));
127
+ const zone = String(domain != null ? domain : '').trim().toLowerCase();
128
+ return mailbox && zone ? `${mailbox}@${zone}` : null;
129
+ }
130
+
131
+ //# sourceMappingURL=host-sender.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/host-sender.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 * THE SENDERS ONE SITE HOLDS — `hosts/{hostId}/senders/{senderId}`.\n *\n * A sender is a mailbox this site may send as, together with the display name\n * and reply address that ordinarily go with it. A site holds several and a\n * composer picks among them; which one an unnamed send uses is the site's\n * DEFAULT.\n *\n * ## Chosen BY ID, never typed per send\n *\n * `sending-mailbox.ts` states the rule this collection exists to keep: a\n * mailbox addresses something real — a bounce returns to it, and a client\n * ignoring `Reply-To:` answers to it — so a mailbox that exists in one\n * campaign's headers and nowhere else is an address nobody serves.\n * `campaign-send.ts` states the same closure from the other end: the sending\n * identity named in a send request is read by nothing.\n *\n * A sender id does not reopen that path. It names a mailbox that was\n * configured once, validated once and stored on this site, so the set of\n * addresses a send can reach is the set an org admin already approved. A free\n * local part in a send body would be the thing that was closed; a key into a\n * managed list is not.\n *\n * ## The DEFAULT is projected onto the host document\n *\n * `hosts/{hostId}` has carried `sendingLocalPart`, `sendingFromName` and\n * `sendingReplyTo` since before this collection existed, and\n * `resolveHostSendingIdentity` reads the first of them on every tenant send.\n * Those fields remain the projection of the default sender rather than being\n * migrated away: a site already sending as `test@` must not revert to `hello@`\n * because a subcollection it never had is empty.\n *\n * So the two are read in a fixed order. An empty subcollection means the site\n * has exactly one sender, and it is the one the host fields describe —\n * {@link DEFAULT_HOST_SENDER_ID} is the id it is synthesized under, and the id\n * it is materialized at the first time anything writes here.\n *\n * ## Pure, for the reason the modules either side of it are pure\n *\n * The document shape is read by the console route that writes it, by the send\n * path that resolves a chosen one, and by the surfaces that render the list.\n * A shape written down three times is a shape that disagrees with itself the\n * first time any of the three moves.\n */\n\nimport { normalizeLocalPart } from './sending-domain'\nimport {\n headerSafeText,\n SENDING_FROM_NAME_MAX,\n SENDING_REPLY_TO_MAX,\n} from './sending-mailbox'\n\n/** The subcollection under `hosts/{hostId}` that holds a site's senders. */\nexport const HOST_SENDERS_COLLECTION = 'senders'\n\n/**\n * The id the site's original sender lives at.\n *\n * A fixed id rather than a generated one, because this document is the\n * materialized form of something that already existed: the three sender fields\n * on the host. Materializing it under a random id would make \"which row is the\n * one the host document describes\" a question with no answer for anybody\n * reading the data directly, and would let a second materialization mint a\n * duplicate of the same sender.\n */\nexport const DEFAULT_HOST_SENDER_ID = 'default'\n\n/**\n * How many senders one site may hold.\n *\n * A ceiling rather than a page. Every sender is a mailbox somebody has to\n * serve — bounces and replies arrive at it whether or not anyone reads them —\n * so a list long enough to need paging is a list nobody is operating, and the\n * composer control that renders it is a picker rather than a directory.\n */\nexport const HOST_SENDER_LIMIT = 25\n\n/** One sender, normalized. Every field present; the optional ones as `''`. */\nexport interface HostSenderRecord {\n id: string\n /** The mailbox — the part before the `@`. Never empty on a stored row. */\n localPart: string\n /** The display name in front of the address, or `''`. */\n fromName: string\n /** Where replies go when it is not the sending address, or `''`. */\n replyTo: string\n /** Whether an unnamed send leaves on this one. Exactly one row carries it. */\n isDefault: boolean\n createdAtMs: number\n}\n\n/**\n * One stored sender document, normalized into {@link HostSenderRecord}.\n *\n * Every field is re-normalized on the way out rather than trusted. The route\n * validates on the way in, and a document can also arrive from a restore, a\n * hand edit or a version of the writer that predates a rule — and these values\n * reach a `From:` header, where an unflattened one is the injection shape.\n */\nexport function readHostSender(options: {\n id: string\n data: Record<string, unknown> | null | undefined\n /** `hosts/{hostId}.defaultSenderId`. Decides {@link HostSenderRecord.isDefault}. */\n defaultSenderId?: string | null\n}): HostSenderRecord {\n const data = options?.data ?? {}\n const id = String(options?.id ?? '')\n const createdAtMs = Number(data['createdAtMs'])\n return {\n id,\n localPart: normalizeLocalPart(String(data['localPart'] ?? '')),\n fromName: headerSafeText(\n String(data['fromName'] ?? ''),\n SENDING_FROM_NAME_MAX,\n ),\n replyTo: headerSafeText(\n String(data['replyTo'] ?? ''),\n SENDING_REPLY_TO_MAX,\n ).toLowerCase(),\n isDefault: Boolean(id) && id === String(options?.defaultSenderId ?? ''),\n createdAtMs: Number.isFinite(createdAtMs) ? createdAtMs : 0,\n }\n}\n\n/**\n * Which row an unnamed send leaves on, when the stored pointer names nobody.\n *\n * `defaultSenderId` is written beside every change that could move it, so the\n * ordinary case is that it names a row in this list. It can still fail to: a\n * default deleted out of band, a pointer written before the row it names, a\n * partial restore. Answering with a row rather than with nothing is what keeps\n * such a site sending — and the order is fixed, so two readers of the same data\n * cannot disagree about which sender that is.\n */\nexport function defaultHostSender(\n senders: readonly HostSenderRecord[],\n): HostSenderRecord | null {\n if (!senders?.length) return null\n return (\n senders.find((sender) => sender.isDefault) ??\n senders.find((sender) => sender.id === DEFAULT_HOST_SENDER_ID) ??\n [...senders].sort(\n (left, right) =>\n left.createdAtMs - right.createdAtMs || left.id.localeCompare(right.id),\n )[0]\n )\n}\n\n/**\n * The whole address one sender leaves on, or `null` on a site whose mailbox is\n * not its own to name.\n *\n * The ONE place a sending address is assembled from its two halves. A surface\n * that built `${localPart}@${domain}` itself would be a second derivation of\n * the address, and the two would disagree the first time either moved — which\n * is the same rule the identity route's `IdentityOption.from` already keeps.\n */\nexport function hostSenderAddress(\n localPart: string | null | undefined,\n domain: string | null | undefined,\n): string | null {\n const mailbox = normalizeLocalPart(String(localPart ?? ''))\n const zone = String(domain ?? '')\n .trim()\n .toLowerCase()\n return mailbox && zone ? `${mailbox}@${zone}` : null\n}\n"],"names":["normalizeLocalPart","headerSafeText","SENDING_FROM_NAME_MAX","SENDING_REPLY_TO_MAX","HOST_SENDERS_COLLECTION","DEFAULT_HOST_SENDER_ID","HOST_SENDER_LIMIT","readHostSender","options","data","id","String","createdAtMs","Number","localPart","fromName","replyTo","toLowerCase","isDefault","Boolean","defaultSenderId","isFinite","defaultHostSender","senders","length","find","sender","sort","left","right","localeCompare","hostSenderAddress","domain","mailbox","zone","trim"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2CC,GAED,SAASA,kBAAkB,QAAQ,sBAAkB;AACrD,SACEC,cAAc,EACdC,qBAAqB,EACrBC,oBAAoB,QACf,uBAAmB;AAE1B,0EAA0E,GAC1E,OAAO,MAAMC,0BAA0B,UAAS;AAEhD;;;;;;;;;CASC,GACD,OAAO,MAAMC,yBAAyB,UAAS;AAE/C;;;;;;;CAOC,GACD,OAAO,MAAMC,oBAAoB,GAAE;AAgBnC;;;;;;;CAOC,GACD,OAAO,SAASC,eAAeC,OAK9B;qBAMwCC,iBAE5BA,gBAIAA;IAXX,MAAMA,eAAOD,2BAAAA,QAASC,IAAI,mBAAI,CAAC;IAC/B,MAAMC,KAAKC,gBAAOH,2BAAAA,QAASE,EAAE,oBAAI;IACjC,MAAME,cAAcC,OAAOJ,IAAI,CAAC,cAAc;IAC9C,OAAO;QACLC;QACAI,WAAWd,mBAAmBW,QAAOF,kBAAAA,IAAI,CAAC,YAAY,YAAjBA,kBAAqB;QAC1DM,UAAUd,eACRU,QAAOF,iBAAAA,IAAI,CAAC,WAAW,YAAhBA,iBAAoB,KAC3BP;QAEFc,SAASf,eACPU,QAAOF,gBAAAA,IAAI,CAAC,UAAU,YAAfA,gBAAmB,KAC1BN,sBACAc,WAAW;QACbC,WAAWC,QAAQT,OAAOA,OAAOC,gBAAOH,2BAAAA,QAASY,eAAe,oBAAI;QACpER,aAAaC,OAAOQ,QAAQ,CAACT,eAAeA,cAAc;IAC5D;AACF;AAEA;;;;;;;;;CASC,GACD,OAAO,SAASU,kBACdC,OAAoC;QAIlCA,MAAAA;IAFF,IAAI,EAACA,2BAAAA,QAASC,MAAM,GAAE,OAAO;IAC7B,QACED,QAAAA,gBAAAA,QAAQE,IAAI,CAAC,CAACC,SAAWA,OAAOR,SAAS,aAAzCK,gBACAA,QAAQE,IAAI,CAAC,CAACC,SAAWA,OAAOhB,EAAE,KAAKL,mCADvCkB,OAEA;WAAIA;KAAQ,CAACI,IAAI,CACf,CAACC,MAAMC,QACLD,KAAKhB,WAAW,GAAGiB,MAAMjB,WAAW,IAAIgB,KAAKlB,EAAE,CAACoB,aAAa,CAACD,MAAMnB,EAAE,EACzE,CAAC,EAAE;AAER;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASqB,kBACdjB,SAAoC,EACpCkB,MAAiC;IAEjC,MAAMC,UAAUjC,mBAAmBW,OAAOG,oBAAAA,YAAa;IACvD,MAAMoB,OAAOvB,OAAOqB,iBAAAA,SAAU,IAC3BG,IAAI,GACJlB,WAAW;IACd,OAAOgB,WAAWC,OAAO,GAAGD,QAAQ,CAAC,EAAEC,MAAM,GAAG;AAClD"}