@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.
- package/LICENSE +201 -0
- package/README.md +104 -0
- package/package.json +37 -0
- package/src/index.d.ts +37 -0
- package/src/index.js +46 -0
- package/src/index.js.map +1 -0
- package/src/lib/email-delivery-events.d.ts +198 -0
- package/src/lib/email-delivery-events.js +310 -0
- package/src/lib/email-delivery-events.js.map +1 -0
- package/src/lib/email-health.d.ts +154 -0
- package/src/lib/email-health.js +264 -0
- package/src/lib/email-health.js.map +1 -0
- package/src/lib/email-media-src.d.ts +33 -0
- package/src/lib/email-media-src.js +113 -0
- package/src/lib/email-media-src.js.map +1 -0
- package/src/lib/email-merge.d.ts +31 -0
- package/src/lib/email-merge.js +36 -0
- package/src/lib/email-merge.js.map +1 -0
- package/src/lib/email-render.d.ts +114 -0
- package/src/lib/email-render.js +293 -0
- package/src/lib/email-render.js.map +1 -0
- package/src/lib/email-revenue-window.d.ts +55 -0
- package/src/lib/email-revenue-window.js +58 -0
- package/src/lib/email-revenue-window.js.map +1 -0
- package/src/lib/host-email-render.d.ts +99 -0
- package/src/lib/host-email-render.js +126 -0
- package/src/lib/host-email-render.js.map +1 -0
- package/src/lib/host-sender.d.ts +86 -0
- package/src/lib/host-sender.js +131 -0
- package/src/lib/host-sender.js.map +1 -0
- package/src/lib/marketing-send.d.ts +466 -0
- package/src/lib/marketing-send.js +459 -0
- package/src/lib/marketing-send.js.map +1 -0
- package/src/lib/platform-sending-domain.d.ts +362 -0
- package/src/lib/platform-sending-domain.js +697 -0
- package/src/lib/platform-sending-domain.js.map +1 -0
- package/src/lib/received-email.d.ts +86 -0
- package/src/lib/received-email.js +124 -0
- package/src/lib/received-email.js.map +1 -0
- package/src/lib/send-ceilings.d.ts +394 -0
- package/src/lib/send-ceilings.js +341 -0
- package/src/lib/send-ceilings.js.map +1 -0
- package/src/lib/send-email.d.ts +385 -0
- package/src/lib/send-email.js +586 -0
- package/src/lib/send-email.js.map +1 -0
- package/src/lib/send-rate.d.ts +298 -0
- package/src/lib/send-rate.js +310 -0
- package/src/lib/send-rate.js.map +1 -0
- package/src/lib/sender-reputation.d.ts +357 -0
- package/src/lib/sender-reputation.js +392 -0
- package/src/lib/sender-reputation.js.map +1 -0
- package/src/lib/sending-domain.d.ts +744 -0
- package/src/lib/sending-domain.js +798 -0
- package/src/lib/sending-domain.js.map +1 -0
- package/src/lib/sending-mailbox.d.ts +132 -0
- package/src/lib/sending-mailbox.js +192 -0
- package/src/lib/sending-mailbox.js.map +1 -0
- package/src/lib/stored-email-nodes.d.ts +38 -0
- package/src/lib/stored-email-nodes.js +78 -0
- package/src/lib/stored-email-nodes.js.map +1 -0
- package/src/lib/svix-signature.d.ts +38 -0
- package/src/lib/svix-signature.js +55 -0
- package/src/lib/svix-signature.js.map +1 -0
- package/src/lib/system-email-catalog.d.ts +170 -0
- package/src/lib/system-email-catalog.js +828 -0
- package/src/lib/system-email-catalog.js.map +1 -0
- package/src/lib/tenant-email-catalog.d.ts +78 -0
- package/src/lib/tenant-email-catalog.js +452 -0
- package/src/lib/tenant-email-catalog.js.map +1 -0
- package/src/lib/text-email-html.d.ts +32 -0
- package/src/lib/text-email-html.js +119 -0
- package/src/lib/text-email-html.js.map +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/tenant-email-catalog.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 type {\n SystemEmailDefaultBlock,\n SystemEmailMergeToken,\n} from './system-email-catalog'\n\n/**\n * How a site owner controls a tenant email's copy (AGL-769/770):\n *\n * `besigner` — designable here, per site, in the email besigner. The send\n * site resolves the designed template and falls back to built-in copy.\n * `external` — already authored in another part of the console (a marketing\n * campaign, a workflow), so the reference links there instead of offering a\n * second editor.\n * `fixed` — fixed copy inside the plugin, not customizable yet. Listed for\n * visibility; becomes `besigner` once its send site is wired.\n */\nexport type TenantEmailControl = 'besigner' | 'external' | 'fixed'\n\nexport interface TenantEmailEntry {\n /** Stable key — the `hosts/{hostId}/emailTemplates` document id. */\n key: string\n name: string\n description: string\n /** Plugin that owns the send (matches org.enabledPlugins). */\n pluginId: string\n plugin: string\n control: TenantEmailControl\n /** `external`: where the copy is authored, shown as the link/label. */\n authoredIn?: string\n /** `besigner`: subject used when no template is published. */\n defaultSubject?: string\n /** `besigner`: tokens the send site supplies. */\n mergeTokens?: readonly SystemEmailMergeToken[]\n /** `besigner`: starting content the editor seeds and the default renders. */\n defaultBody?: readonly SystemEmailDefaultBlock[]\n /**\n * Entitlement flag the SEND is gated on, when one is (AGL-2081).\n *\n * `enabledPlugins` decides whether a template appears at all; this is the\n * narrower question of whether the send behind an appearing template can\n * actually happen. `abandoned-cart` is the case that named it: the template\n * has always been listed and designable on every plan, while\n * `process-abandoned.ts` refuses to send it without `abandonedCart` — so\n * the Emails page showed a template that could never send, and read\n * exactly like one that could.\n *\n * A plain string rather than `keyof OrgFeatureFlags` to keep this lib off\n * the billing lib's dependency edge; the console resolves it through\n * `checkEntitlement`, and a spec asserts every value here is a real flag.\n */\n requiresFeature?: string\n}\n\n/**\n * The transactional emails a SITE sends to its own end-users (AGL-769/770).\n *\n * Distinct from the platform system emails (mail Aglyn sends about the\n * account). These are tenant-owned: a site sends them to its customers. Flat\n * list keyed for the host-scoped template store and the `renderHostEmail`\n * resolver; the console UI groups by `pluginId`. Code-defined and fixed — the\n * set is whatever the plugins actually send.\n */\nexport const TENANT_EMAILS: readonly TenantEmailEntry[] = [\n // ── Bookings ──────────────────────────────────────────────────────────\n {\n key: 'booking-confirmed',\n name: 'Booking confirmed',\n description: 'Confirms a booking to the customer, for paid and free services.',\n pluginId: 'bookings',\n plugin: 'Bookings',\n control: 'besigner',\n defaultSubject: 'Booking confirmed: {{service.name}}',\n mergeTokens: [\n { name: 'name', description: \"The customer's name\", sample: 'Alex' },\n {\n name: 'service.name',\n description: 'The booked service',\n sample: 'Consultation',\n },\n {\n name: 'when',\n description: 'Formatted date and time of the booking',\n sample: 'Monday, June 1, 2026 at 9:00 AM',\n },\n {\n name: 'timezone',\n description: 'Timezone the time is shown in',\n sample: 'UTC',\n },\n {\n name: 'booking.ref',\n description: 'Booking reference id',\n sample: 'bk_123',\n },\n ],\n defaultBody: [\n { block: 'text', text: 'Booking confirmed', variant: 'heading' },\n {\n block: 'text',\n text:\n 'Hi {{name}}, your booking for \"{{service.name}}\" is confirmed for ' +\n '{{when}} ({{timezone}}).',\n variant: 'body',\n },\n { block: 'text', text: 'Reference: {{booking.ref}}', variant: 'caption' },\n ],\n },\n {\n key: 'booking-reminder',\n name: 'Booking reminder',\n description: 'Reminds the customer of an upcoming booking.',\n pluginId: 'bookings',\n plugin: 'Bookings',\n control: 'besigner',\n defaultSubject: 'Reminder: {{service.name}} is coming up',\n mergeTokens: [\n { name: 'name', description: \"The customer's name\", sample: 'Alex' },\n {\n name: 'service.name',\n description: 'The booked service',\n sample: 'Consultation',\n },\n {\n name: 'when',\n description: 'Formatted date and time of the booking',\n sample: 'Tomorrow at 9:00 AM',\n },\n ],\n defaultBody: [\n {\n block: 'text',\n text:\n 'Hi {{name}}, this is a reminder that \"{{service.name}}\" is ' +\n 'coming up on {{when}}.',\n variant: 'body',\n },\n ],\n },\n // ── Commerce (fixed for now; flips to `besigner` when the send site is\n // wired to renderHostEmail — AGL-770) ────────────────────────────────\n {\n key: 'order-receipt',\n name: 'Order receipt',\n description: 'Sent to the buyer after a successful order or checkout.',\n pluginId: 'commerce',\n plugin: 'Commerce',\n control: 'besigner',\n defaultSubject: 'Receipt for your order',\n mergeTokens: [\n {\n name: 'order.summary',\n description: 'The ordered items, one per line (with any license/download links)',\n sample: 'House Blend × 2 — $24.00',\n },\n {\n name: 'order.total',\n description: 'Order total',\n sample: '$24.00',\n },\n {\n name: 'order.ref',\n description: 'Order reference id',\n sample: 'cs_test_123',\n },\n ],\n defaultBody: [\n { block: 'text', text: 'Thanks for your purchase!', variant: 'heading' },\n { block: 'text', text: '{{order.summary}}', variant: 'body' },\n { block: 'text', text: 'Total: {{order.total}}', variant: 'body' },\n {\n block: 'text',\n text: 'Order reference: {{order.ref}}',\n variant: 'caption',\n },\n ],\n },\n {\n key: 'sale-notification',\n name: 'New sale',\n description: 'Notifies the seller when an order is placed.',\n pluginId: 'commerce',\n plugin: 'Commerce',\n control: 'besigner',\n defaultSubject: 'New order on {{site.name}}',\n mergeTokens: [\n {\n name: 'site.name',\n description: 'The site the sale happened on',\n sample: 'Northwind Coffee',\n },\n {\n name: 'order.summary',\n description: 'The ordered items',\n sample: 'House Blend — $12.00',\n },\n { name: 'order.total', description: 'Order total', sample: '$12.00' },\n {\n name: 'buyer.email',\n description: \"The buyer's email\",\n sample: 'buyer@example.com',\n },\n {\n name: 'order.ref',\n description: 'Order reference id',\n sample: 'cs_test_123',\n },\n ],\n defaultBody: [\n { block: 'text', text: 'You made a sale', variant: 'heading' },\n {\n block: 'text',\n text: 'A new order came in on {{site.name}}.',\n variant: 'body',\n },\n { block: 'text', text: '{{order.summary}}', variant: 'body' },\n { block: 'text', text: 'Total: {{order.total}}', variant: 'body' },\n {\n block: 'text',\n text: 'Buyer: {{buyer.email}} · Order {{order.ref}}',\n variant: 'caption',\n },\n ],\n },\n {\n key: 'reservation-confirmed',\n name: 'Reservation confirmed',\n description: 'Confirms a paid reservation to the customer.',\n pluginId: 'commerce',\n plugin: 'Commerce',\n control: 'besigner',\n defaultSubject: 'Reservation confirmed',\n mergeTokens: [\n {\n name: 'reservation.checkIn',\n description: 'Check-in date',\n sample: 'Mon, 01 Jun 2026',\n },\n {\n name: 'reservation.nights',\n description: 'Number of nights',\n sample: '2',\n },\n {\n name: 'reservation.paid',\n description: 'Amount paid today',\n sample: '$240.00',\n },\n {\n name: 'reservation.ref',\n description: 'Reservation reference id',\n sample: 'resv_123',\n },\n ],\n defaultBody: [\n { block: 'text', text: 'Reservation confirmed', variant: 'heading' },\n { block: 'text', text: 'Your stay is confirmed!', variant: 'body' },\n {\n block: 'text',\n text: 'Check-in: {{reservation.checkIn}}',\n variant: 'body',\n },\n { block: 'text', text: 'Nights: {{reservation.nights}}', variant: 'body' },\n {\n block: 'text',\n text: 'Paid today: {{reservation.paid}}',\n variant: 'body',\n },\n {\n block: 'text',\n text: 'Reference: {{reservation.ref}}',\n variant: 'caption',\n },\n ],\n },\n {\n key: 'gift-card',\n name: 'Gift card delivery',\n description: 'Delivers a purchased gift card to its recipient.',\n pluginId: 'commerce',\n plugin: 'Commerce',\n control: 'besigner',\n defaultSubject: 'Your gift card',\n mergeTokens: [\n {\n name: 'giftcard.code',\n description: 'The gift card code',\n sample: 'GC-ABC123DEF456',\n },\n {\n name: 'giftcard.value',\n description: 'The gift card value',\n sample: '$25.00',\n },\n ],\n defaultBody: [\n { block: 'text', text: 'Your gift card', variant: 'heading' },\n {\n block: 'text',\n text: 'Gift card code: {{giftcard.code}}',\n variant: 'body',\n },\n { block: 'text', text: 'Value: {{giftcard.value}}', variant: 'body' },\n {\n block: 'text',\n text: 'Enter it at checkout to apply the balance.',\n variant: 'body',\n },\n ],\n },\n {\n key: 'supplier-fulfillment',\n name: 'Supplier fulfillment',\n description: 'Tells a dropship supplier there is a new order to fulfill.',\n pluginId: 'commerce',\n plugin: 'Commerce',\n control: 'fixed',\n },\n {\n key: 'back-in-stock',\n name: 'Back in stock',\n description: 'Tells a customer a product they watched is available again.',\n pluginId: 'commerce',\n plugin: 'Commerce',\n control: 'besigner',\n defaultSubject: 'Back in stock: {{product.name}}',\n mergeTokens: [\n {\n name: 'product.name',\n description: 'The product that restocked',\n sample: 'House Blend',\n },\n {\n name: 'product.url',\n description: 'Link to the product page',\n sample: '/products/house-blend',\n },\n ],\n defaultBody: [\n {\n block: 'text',\n text:\n '{{product.name}} is available again — grab it before it sells out.',\n variant: 'body',\n },\n { block: 'button', label: 'View product', href: '{{product.url}}' },\n ],\n },\n {\n key: 'abandoned-cart',\n name: 'Abandoned cart',\n description: 'Reminds a shopper of items left in their cart.',\n // The cron refuses to send this without the entitlement\n // (`process-abandoned.ts:92`), so the console has to say so (AGL-2081).\n requiresFeature: 'abandonedCart',\n pluginId: 'commerce',\n plugin: 'Commerce',\n control: 'besigner',\n defaultSubject: 'You left something in your cart',\n mergeTokens: [\n {\n name: 'cart.url',\n description: 'Link back to the shopper cart',\n sample: 'https://shop.example.com/cart',\n },\n ],\n defaultBody: [\n {\n block: 'text',\n text: 'Your cart is still waiting — pick up where you left off.',\n variant: 'body',\n },\n { block: 'button', label: 'Return to cart', href: '{{cart.url}}' },\n {\n block: 'text',\n text:\n 'Your items are held but not reserved, so they may sell out.',\n variant: 'caption',\n },\n ],\n },\n {\n key: 'member-post',\n name: 'New member post',\n description: 'Notifies members when new members-only content is posted.',\n pluginId: 'commerce',\n plugin: 'Commerce',\n control: 'fixed',\n },\n {\n key: 'member-password-reset',\n name: 'Member password reset',\n description:\n \"Resets a site member's password — a store-member account, separate \" +\n 'from a console login.',\n pluginId: 'commerce',\n plugin: 'Commerce',\n control: 'fixed',\n },\n // ── Marketing / Workflows (authored in their own UIs) ──────────────────\n {\n key: 'campaign',\n name: 'Campaign broadcast',\n description: \"A campaign sent to the site's subscriber list.\",\n pluginId: 'marketing',\n plugin: 'Marketing',\n control: 'external',\n authoredIn: 'Marketing → Campaigns',\n },\n {\n key: 'workflow-email',\n name: 'Workflow email step',\n description:\n \"An email sent by a workflow's send-email action when its trigger \" +\n 'fires.',\n pluginId: 'workflows',\n plugin: 'Workflows',\n control: 'external',\n authoredIn: 'the workflow that sends it',\n },\n]\n\n/** Firestore subcollection under a host holding its designed templates. */\nexport const TENANT_EMAIL_COLLECTION = 'emailTemplates'\n\nexport function getTenantEmail(key: string): TenantEmailEntry | undefined {\n return TENANT_EMAILS.find((entry) => entry.key === key)\n}\n\n/** True when a site owner can design this email in the besigner. */\nexport function isTenantEmailEditable(entry: TenantEmailEntry): boolean {\n return entry.control === 'besigner'\n}\n"],"names":["TENANT_EMAILS","key","name","description","pluginId","plugin","control","defaultSubject","mergeTokens","sample","defaultBody","block","text","variant","label","href","requiresFeature","authoredIn","TENANT_EMAIL_COLLECTION","getTenantEmail","find","entry","isTenantEmailEditable"],"mappings":"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"}
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
* Renders a plain-text body as the HTML part of the same message.
|
|
19
|
+
*
|
|
20
|
+
* Blank-line-separated blocks become paragraphs and single newlines become
|
|
21
|
+
* `<br />`, which is what the senders' template literals already mean by
|
|
22
|
+
* them. Returns an empty string for empty input so a caller can treat "no
|
|
23
|
+
* text" and "no html" the same way.
|
|
24
|
+
*
|
|
25
|
+
* @param text The plain-text body being sent alongside this.
|
|
26
|
+
* @param subject Used only for the document `<title>`.
|
|
27
|
+
* @param preheader The hidden line inboxes show after the subject. Same
|
|
28
|
+
* markup `renderEmailHtml` emits for a designed email, so a campaign
|
|
29
|
+
* gets the same treatment whichever body it carries.
|
|
30
|
+
*/
|
|
31
|
+
export declare function renderTextEmailHtml(text: string, subject?: string, preheader?: string): string;
|
|
32
|
+
export default renderTextEmailHtml;
|
|
@@ -0,0 +1,119 @@
|
|
|
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 { escapeEmailHtml } from "./email-render.js";
|
|
17
|
+
/**
|
|
18
|
+
* THE HTML PART EVERY MESSAGE GETS.
|
|
19
|
+
*
|
|
20
|
+
* ## What was wrong
|
|
21
|
+
*
|
|
22
|
+
* `sendEmail` forwarded exactly what it was handed, and almost nothing hands
|
|
23
|
+
* it `html`. Twelve senders have no HTML path at all; the other twenty-seven
|
|
24
|
+
* write `...(designed?.html ? { html: designed.html } : {})`, which produces
|
|
25
|
+
* an HTML part only when a staff-designed template is published for that key.
|
|
26
|
+
* With none published, every message on the domain went out as `"html": ""` —
|
|
27
|
+
* a `text/plain` part and nothing else.
|
|
28
|
+
*
|
|
29
|
+
* Two consequences, and the second is the one that hid the first:
|
|
30
|
+
*
|
|
31
|
+
* 1. A URL in a plain-text part is not a link. It renders as the characters
|
|
32
|
+
* of the URL; whether it is clickable at all is the mail client's guess,
|
|
33
|
+
* and a long one wraps mid-string and stops being followable.
|
|
34
|
+
* 2. **Click tracking cannot work.** Resend measures clicks by rewriting
|
|
35
|
+
* `<a href>` in the HTML part. No HTML part means no anchors, which means
|
|
36
|
+
* no rewriting, which means the click rate is structurally 0% and would
|
|
37
|
+
* have stayed there however long anyone waited on it.
|
|
38
|
+
*
|
|
39
|
+
* ## Why it lives here rather than at the call sites
|
|
40
|
+
*
|
|
41
|
+
* The same reasoning `contextTag` records one file over: threading an HTML
|
|
42
|
+
* body through thirty-nine call sites asks thirty-nine places to remember,
|
|
43
|
+
* which is the shape that produces the fortieth that does not. A sender that
|
|
44
|
+
* builds real HTML still wins — this is a fallback, consulted only when
|
|
45
|
+
* `html` is absent — so no existing caller changes and no designed template
|
|
46
|
+
* is overridden.
|
|
47
|
+
*
|
|
48
|
+
* ## What it is not
|
|
49
|
+
*
|
|
50
|
+
* Not a template. It carries no logo, no brand color and no footer, because
|
|
51
|
+
* it stands in for copy that was written to be read as plain text and it must
|
|
52
|
+
* not imply a design decision nobody made. It is the plain-text body, in a
|
|
53
|
+
* readable column, with its links actually linked.
|
|
54
|
+
*/ /**
|
|
55
|
+
* Trailing characters stripped from a matched URL.
|
|
56
|
+
*
|
|
57
|
+
* A sentence that ends "…see {@link https://example.com/billing}." puts the
|
|
58
|
+
* period inside the match, because a period is a legal URL character and the
|
|
59
|
+
* regex cannot tell prose from path. Closing brackets are handled separately
|
|
60
|
+
* below, since a URL may legitimately end in one.
|
|
61
|
+
*
|
|
62
|
+
* `;` is deliberately absent: by the time this runs the text is already
|
|
63
|
+
* escaped, so a query string reads `?a=1&b=2` and trimming `;` would cut
|
|
64
|
+
* an entity in half and corrupt the link.
|
|
65
|
+
*/ const URL_TRAILING_PUNCTUATION = /[.,!?'"]+$/;
|
|
66
|
+
/**
|
|
67
|
+
* Bare absolute URLs in already-escaped text.
|
|
68
|
+
*
|
|
69
|
+
* Matched AFTER escaping, not before, so the href and the visible label are
|
|
70
|
+
* the same string and neither can reintroduce markup: `&` inside a query
|
|
71
|
+
* string is `&` by then, which is what an href attribute is supposed to
|
|
72
|
+
* carry and parses back to `&` in the client.
|
|
73
|
+
*
|
|
74
|
+
* `http`/`https` only. Every URL our system copy emits is absolute — the
|
|
75
|
+
* senders share one `consoleOrigin()` precisely because a mail client has no
|
|
76
|
+
* page to resolve a relative path against — and matching bare `www.` or
|
|
77
|
+
* addresses would turn ordinary prose into links nobody wrote.
|
|
78
|
+
*/ const BARE_URL = /https?:\/\/[^\s<>"]+/g;
|
|
79
|
+
/**
|
|
80
|
+
* Links the bare URLs in one escaped line.
|
|
81
|
+
*
|
|
82
|
+
* Balanced closing parens are kept, because a URL can genuinely end in one
|
|
83
|
+
* and a wrapping "(see https://…/a_(b))" is the rarer case. Anything the
|
|
84
|
+
* paren count says is unbalanced belongs to the prose.
|
|
85
|
+
*/ function linkifyEscaped(escaped) {
|
|
86
|
+
return escaped.replace(BARE_URL, (match)=>{
|
|
87
|
+
let url = match.replace(URL_TRAILING_PUNCTUATION, '');
|
|
88
|
+
while(url.endsWith(')') && url.split(')').length > url.split('(').length){
|
|
89
|
+
url = url.slice(0, -1);
|
|
90
|
+
}
|
|
91
|
+
if (!url) return match;
|
|
92
|
+
const trailer = match.slice(url.length);
|
|
93
|
+
return `<a href="${url}" target="_blank" ` + `style="color:#1a73e8;text-decoration:underline;word-break:break-word;">` + `${url}</a>${trailer}`;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Renders a plain-text body as the HTML part of the same message.
|
|
98
|
+
*
|
|
99
|
+
* Blank-line-separated blocks become paragraphs and single newlines become
|
|
100
|
+
* `<br />`, which is what the senders' template literals already mean by
|
|
101
|
+
* them. Returns an empty string for empty input so a caller can treat "no
|
|
102
|
+
* text" and "no html" the same way.
|
|
103
|
+
*
|
|
104
|
+
* @param text The plain-text body being sent alongside this.
|
|
105
|
+
* @param subject Used only for the document `<title>`.
|
|
106
|
+
* @param preheader The hidden line inboxes show after the subject. Same
|
|
107
|
+
* markup `renderEmailHtml` emits for a designed email, so a campaign
|
|
108
|
+
* gets the same treatment whichever body it carries.
|
|
109
|
+
*/ export function renderTextEmailHtml(text, subject = '', preheader = '') {
|
|
110
|
+
const body = String(text != null ? text : '').trim();
|
|
111
|
+
if (!body) return '';
|
|
112
|
+
const preview = String(preheader != null ? preheader : '').trim();
|
|
113
|
+
const preheaderHtml = preview ? `<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;">` + escapeEmailHtml(preview) + `</div>` : '';
|
|
114
|
+
const paragraphs = body.split(/\n\s*\n/).map((block)=>block.trim()).filter(Boolean).map((block)=>`<p style="margin:0 0 16px;">` + linkifyEscaped(escapeEmailHtml(block)).replace(/\n/g, '<br />') + `</p>`).join('');
|
|
115
|
+
return `<!DOCTYPE html><html><head><meta charset="utf-8" />` + `<meta name="viewport" content="width=device-width, initial-scale=1" />` + `<title>${escapeEmailHtml(String(subject != null ? subject : ''))}</title></head>` + `<body style="margin:0;padding:0;background-color:#f4f4f4;">` + preheaderHtml + `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" ` + `style="background-color:#f4f4f4;"><tr><td style="padding:24px 8px;">` + `<table role="presentation" width="600" cellpadding="0" cellspacing="0" ` + `align="center" style="max-width:600px;width:100%;margin:0 auto;">` + `<tr><td style="padding:24px;background-color:#ffffff;border-radius:8px;` + `font-family:Helvetica, Arial, sans-serif;font-size:15px;line-height:1.6;` + `color:#1a1a1a;">` + paragraphs + `</td></tr></table></td></tr></table></body></html>`;
|
|
116
|
+
}
|
|
117
|
+
export default renderTextEmailHtml;
|
|
118
|
+
|
|
119
|
+
//# sourceMappingURL=text-email-html.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/text-email-html.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 { escapeEmailHtml } from './email-render'\n\n/**\n * THE HTML PART EVERY MESSAGE GETS.\n *\n * ## What was wrong\n *\n * `sendEmail` forwarded exactly what it was handed, and almost nothing hands\n * it `html`. Twelve senders have no HTML path at all; the other twenty-seven\n * write `...(designed?.html ? { html: designed.html } : {})`, which produces\n * an HTML part only when a staff-designed template is published for that key.\n * With none published, every message on the domain went out as `\"html\": \"\"` —\n * a `text/plain` part and nothing else.\n *\n * Two consequences, and the second is the one that hid the first:\n *\n * 1. A URL in a plain-text part is not a link. It renders as the characters\n * of the URL; whether it is clickable at all is the mail client's guess,\n * and a long one wraps mid-string and stops being followable.\n * 2. **Click tracking cannot work.** Resend measures clicks by rewriting\n * `<a href>` in the HTML part. No HTML part means no anchors, which means\n * no rewriting, which means the click rate is structurally 0% and would\n * have stayed there however long anyone waited on it.\n *\n * ## Why it lives here rather than at the call sites\n *\n * The same reasoning `contextTag` records one file over: threading an HTML\n * body through thirty-nine call sites asks thirty-nine places to remember,\n * which is the shape that produces the fortieth that does not. A sender that\n * builds real HTML still wins — this is a fallback, consulted only when\n * `html` is absent — so no existing caller changes and no designed template\n * is overridden.\n *\n * ## What it is not\n *\n * Not a template. It carries no logo, no brand color and no footer, because\n * it stands in for copy that was written to be read as plain text and it must\n * not imply a design decision nobody made. It is the plain-text body, in a\n * readable column, with its links actually linked.\n */\n\n/**\n * Trailing characters stripped from a matched URL.\n *\n * A sentence that ends \"…see {@link https://example.com/billing}.\" puts the\n * period inside the match, because a period is a legal URL character and the\n * regex cannot tell prose from path. Closing brackets are handled separately\n * below, since a URL may legitimately end in one.\n *\n * `;` is deliberately absent: by the time this runs the text is already\n * escaped, so a query string reads `?a=1&b=2` and trimming `;` would cut\n * an entity in half and corrupt the link.\n */\nconst URL_TRAILING_PUNCTUATION = /[.,!?'\"]+$/\n\n/**\n * Bare absolute URLs in already-escaped text.\n *\n * Matched AFTER escaping, not before, so the href and the visible label are\n * the same string and neither can reintroduce markup: `&` inside a query\n * string is `&` by then, which is what an href attribute is supposed to\n * carry and parses back to `&` in the client.\n *\n * `http`/`https` only. Every URL our system copy emits is absolute — the\n * senders share one `consoleOrigin()` precisely because a mail client has no\n * page to resolve a relative path against — and matching bare `www.` or\n * addresses would turn ordinary prose into links nobody wrote.\n */\nconst BARE_URL = /https?:\\/\\/[^\\s<>\"]+/g\n\n/**\n * Links the bare URLs in one escaped line.\n *\n * Balanced closing parens are kept, because a URL can genuinely end in one\n * and a wrapping \"(see https://…/a_(b))\" is the rarer case. Anything the\n * paren count says is unbalanced belongs to the prose.\n */\nfunction linkifyEscaped(escaped: string): string {\n return escaped.replace(BARE_URL, (match) => {\n let url = match.replace(URL_TRAILING_PUNCTUATION, '')\n while (\n url.endsWith(')') &&\n url.split(')').length > url.split('(').length\n ) {\n url = url.slice(0, -1)\n }\n if (!url) return match\n const trailer = match.slice(url.length)\n return (\n `<a href=\"${url}\" target=\"_blank\" ` +\n `style=\"color:#1a73e8;text-decoration:underline;word-break:break-word;\">` +\n `${url}</a>${trailer}`\n )\n })\n}\n\n/**\n * Renders a plain-text body as the HTML part of the same message.\n *\n * Blank-line-separated blocks become paragraphs and single newlines become\n * `<br />`, which is what the senders' template literals already mean by\n * them. Returns an empty string for empty input so a caller can treat \"no\n * text\" and \"no html\" the same way.\n *\n * @param text The plain-text body being sent alongside this.\n * @param subject Used only for the document `<title>`.\n * @param preheader The hidden line inboxes show after the subject. Same\n * markup `renderEmailHtml` emits for a designed email, so a campaign\n * gets the same treatment whichever body it carries.\n */\nexport function renderTextEmailHtml(\n text: string,\n subject = '',\n preheader = '',\n): string {\n const body = String(text ?? '').trim()\n if (!body) return ''\n\n const preview = String(preheader ?? '').trim()\n const preheaderHtml = preview\n ? `<div style=\"display:none;max-height:0;overflow:hidden;mso-hide:all;\">` +\n escapeEmailHtml(preview) +\n `</div>`\n : ''\n\n const paragraphs = body\n .split(/\\n\\s*\\n/)\n .map((block) => block.trim())\n .filter(Boolean)\n .map(\n (block) =>\n `<p style=\"margin:0 0 16px;\">` +\n linkifyEscaped(escapeEmailHtml(block)).replace(/\\n/g, '<br />') +\n `</p>`,\n )\n .join('')\n\n return (\n `<!DOCTYPE html><html><head><meta charset=\"utf-8\" />` +\n `<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />` +\n `<title>${escapeEmailHtml(String(subject ?? ''))}</title></head>` +\n `<body style=\"margin:0;padding:0;background-color:#f4f4f4;\">` +\n preheaderHtml +\n `<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" ` +\n `style=\"background-color:#f4f4f4;\"><tr><td style=\"padding:24px 8px;\">` +\n `<table role=\"presentation\" width=\"600\" cellpadding=\"0\" cellspacing=\"0\" ` +\n `align=\"center\" style=\"max-width:600px;width:100%;margin:0 auto;\">` +\n `<tr><td style=\"padding:24px;background-color:#ffffff;border-radius:8px;` +\n `font-family:Helvetica, Arial, sans-serif;font-size:15px;line-height:1.6;` +\n `color:#1a1a1a;\">` +\n paragraphs +\n `</td></tr></table></td></tr></table></body></html>`\n )\n}\n\nexport default renderTextEmailHtml\n"],"names":["escapeEmailHtml","URL_TRAILING_PUNCTUATION","BARE_URL","linkifyEscaped","escaped","replace","match","url","endsWith","split","length","slice","trailer","renderTextEmailHtml","text","subject","preheader","body","String","trim","preview","preheaderHtml","paragraphs","map","block","filter","Boolean","join"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,eAAe,QAAQ,oBAAgB;AAEhD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAqCC,GAED;;;;;;;;;;;CAWC,GACD,MAAMC,2BAA2B;AAEjC;;;;;;;;;;;;CAYC,GACD,MAAMC,WAAW;AAEjB;;;;;;CAMC,GACD,SAASC,eAAeC,OAAe;IACrC,OAAOA,QAAQC,OAAO,CAACH,UAAU,CAACI;QAChC,IAAIC,MAAMD,MAAMD,OAAO,CAACJ,0BAA0B;QAClD,MACEM,IAAIC,QAAQ,CAAC,QACbD,IAAIE,KAAK,CAAC,KAAKC,MAAM,GAAGH,IAAIE,KAAK,CAAC,KAAKC,MAAM,CAC7C;YACAH,MAAMA,IAAII,KAAK,CAAC,GAAG,CAAC;QACtB;QACA,IAAI,CAACJ,KAAK,OAAOD;QACjB,MAAMM,UAAUN,MAAMK,KAAK,CAACJ,IAAIG,MAAM;QACtC,OACE,CAAC,SAAS,EAAEH,IAAI,kBAAkB,CAAC,GACnC,CAAC,uEAAuE,CAAC,GACzE,GAAGA,IAAI,IAAI,EAAEK,SAAS;IAE1B;AACF;AAEA;;;;;;;;;;;;;CAaC,GACD,OAAO,SAASC,oBACdC,IAAY,EACZC,UAAU,EAAE,EACZC,YAAY,EAAE;IAEd,MAAMC,OAAOC,OAAOJ,eAAAA,OAAQ,IAAIK,IAAI;IACpC,IAAI,CAACF,MAAM,OAAO;IAElB,MAAMG,UAAUF,OAAOF,oBAAAA,YAAa,IAAIG,IAAI;IAC5C,MAAME,gBAAgBD,UAClB,CAAC,qEAAqE,CAAC,GACvEpB,gBAAgBoB,WAChB,CAAC,MAAM,CAAC,GACR;IAEJ,MAAME,aAAaL,KAChBR,KAAK,CAAC,WACNc,GAAG,CAAC,CAACC,QAAUA,MAAML,IAAI,IACzBM,MAAM,CAACC,SACPH,GAAG,CACF,CAACC,QACC,CAAC,4BAA4B,CAAC,GAC9BrB,eAAeH,gBAAgBwB,QAAQnB,OAAO,CAAC,OAAO,YACtD,CAAC,IAAI,CAAC,EAETsB,IAAI,CAAC;IAER,OACE,CAAC,mDAAmD,CAAC,GACrD,CAAC,sEAAsE,CAAC,GACxE,CAAC,OAAO,EAAE3B,gBAAgBkB,OAAOH,kBAAAA,UAAW,KAAK,eAAe,CAAC,GACjE,CAAC,2DAA2D,CAAC,GAC7DM,gBACA,CAAC,wEAAwE,CAAC,GAC1E,CAAC,oEAAoE,CAAC,GACtE,CAAC,uEAAuE,CAAC,GACzE,CAAC,iEAAiE,CAAC,GACnE,CAAC,uEAAuE,CAAC,GACzE,CAAC,wEAAwE,CAAC,GAC1E,CAAC,gBAAgB,CAAC,GAClBC,aACA,CAAC,kDAAkD,CAAC;AAExD;AAEA,eAAeT,oBAAmB"}
|