@cloudgrid-io/ui 0.31.4 → 0.31.6

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 (108) hide show
  1. package/README.md +32 -0
  2. package/dist/badge/brand.d.ts +8 -0
  3. package/dist/badge/brand.d.ts.map +1 -0
  4. package/dist/badge/brand.js +22 -0
  5. package/dist/badge/brand.js.map +1 -0
  6. package/dist/badge/bubble.d.ts +11 -0
  7. package/dist/badge/bubble.d.ts.map +1 -0
  8. package/dist/badge/bubble.js +17 -0
  9. package/dist/badge/bubble.js.map +1 -0
  10. package/dist/badge/css-base.d.ts +25 -0
  11. package/dist/badge/css-base.d.ts.map +1 -0
  12. package/dist/badge/css-base.js +103 -0
  13. package/dist/badge/css-base.js.map +1 -0
  14. package/dist/badge/css-bubble.d.ts +10 -0
  15. package/dist/badge/css-bubble.d.ts.map +1 -0
  16. package/dist/badge/css-bubble.js +21 -0
  17. package/dist/badge/css-bubble.js.map +1 -0
  18. package/dist/badge/css-owner.d.ts +7 -0
  19. package/dist/badge/css-owner.d.ts.map +1 -0
  20. package/dist/badge/css-owner.js +159 -0
  21. package/dist/badge/css-owner.js.map +1 -0
  22. package/dist/badge/css-visitor.d.ts +7 -0
  23. package/dist/badge/css-visitor.d.ts.map +1 -0
  24. package/dist/badge/css-visitor.js +69 -0
  25. package/dist/badge/css-visitor.js.map +1 -0
  26. package/dist/badge/html.d.ts +26 -0
  27. package/dist/badge/html.d.ts.map +1 -0
  28. package/dist/badge/html.js +55 -0
  29. package/dist/badge/html.js.map +1 -0
  30. package/dist/badge/index.d.ts +43 -0
  31. package/dist/badge/index.d.ts.map +1 -0
  32. package/dist/badge/index.js +46 -0
  33. package/dist/badge/index.js.map +1 -0
  34. package/dist/badge/marks.d.ts +47 -0
  35. package/dist/badge/marks.d.ts.map +1 -0
  36. package/dist/badge/marks.js +131 -0
  37. package/dist/badge/marks.js.map +1 -0
  38. package/dist/badge/owner.d.ts +239 -0
  39. package/dist/badge/owner.d.ts.map +1 -0
  40. package/dist/badge/owner.js +355 -0
  41. package/dist/badge/owner.js.map +1 -0
  42. package/dist/badge/palette.d.ts +23 -0
  43. package/dist/badge/palette.d.ts.map +1 -0
  44. package/dist/badge/palette.js +86 -0
  45. package/dist/badge/palette.js.map +1 -0
  46. package/dist/badge/root.d.ts +24 -0
  47. package/dist/badge/root.d.ts.map +1 -0
  48. package/dist/badge/root.js +11 -0
  49. package/dist/badge/root.js.map +1 -0
  50. package/dist/badge/shared.d.ts +56 -0
  51. package/dist/badge/shared.d.ts.map +1 -0
  52. package/dist/badge/shared.js +53 -0
  53. package/dist/badge/shared.js.map +1 -0
  54. package/dist/badge/visitor.d.ts +96 -0
  55. package/dist/badge/visitor.d.ts.map +1 -0
  56. package/dist/badge/visitor.js +118 -0
  57. package/dist/badge/visitor.js.map +1 -0
  58. package/dist/blocks/entity-status.d.ts +44 -1
  59. package/dist/blocks/entity-status.d.ts.map +1 -1
  60. package/dist/blocks/entity-status.js +26 -1
  61. package/dist/blocks/entity-status.js.map +1 -1
  62. package/dist/blocks/expiry-notice.d.ts +86 -0
  63. package/dist/blocks/expiry-notice.d.ts.map +1 -0
  64. package/dist/blocks/expiry-notice.js +81 -0
  65. package/dist/blocks/expiry-notice.js.map +1 -0
  66. package/dist/blocks/icons.d.ts +22 -0
  67. package/dist/blocks/icons.d.ts.map +1 -1
  68. package/dist/blocks/icons.js +22 -0
  69. package/dist/blocks/icons.js.map +1 -1
  70. package/dist/blocks/notification-row.d.ts +88 -0
  71. package/dist/blocks/notification-row.d.ts.map +1 -0
  72. package/dist/blocks/notification-row.js +32 -0
  73. package/dist/blocks/notification-row.js.map +1 -0
  74. package/dist/blocks/phosphor-marks.d.ts +10 -0
  75. package/dist/blocks/phosphor-marks.d.ts.map +1 -1
  76. package/dist/blocks/phosphor-marks.js +15 -0
  77. package/dist/blocks/phosphor-marks.js.map +1 -1
  78. package/dist/brand/badge/manrope-latin-wght-normal.woff2 +0 -0
  79. package/dist/brand/badge/space-grotesk-latin-wght-normal.woff2 +0 -0
  80. package/dist/brand/email/headline-activity.png +0 -0
  81. package/dist/brand/email/headline-expired.png +0 -0
  82. package/dist/brand/email/headline-expires-tomorrow.png +0 -0
  83. package/dist/brand/email/headline-expiring-soon.png +0 -0
  84. package/dist/brand/email/headline-invited.png +0 -0
  85. package/dist/brand/email/headline-sign-in-code.png +0 -0
  86. package/dist/brand/email/mesh.png +0 -0
  87. package/dist/brand/email/wordmark.png +0 -0
  88. package/dist/email/assets.d.ts +50 -0
  89. package/dist/email/assets.d.ts.map +1 -0
  90. package/dist/email/assets.js +16 -0
  91. package/dist/email/assets.js.map +1 -0
  92. package/dist/email/index.d.ts +204 -0
  93. package/dist/email/index.d.ts.map +1 -0
  94. package/dist/email/index.js +429 -0
  95. package/dist/email/index.js.map +1 -0
  96. package/dist/email/palette.d.ts +40 -0
  97. package/dist/email/palette.d.ts.map +1 -0
  98. package/dist/email/palette.js +44 -0
  99. package/dist/email/palette.js.map +1 -0
  100. package/dist/index.d.ts +2 -0
  101. package/dist/index.d.ts.map +1 -1
  102. package/dist/index.js +2 -0
  103. package/dist/index.js.map +1 -1
  104. package/dist/ui/dropdown-menu.d.ts +8 -1
  105. package/dist/ui/dropdown-menu.d.ts.map +1 -1
  106. package/dist/ui/dropdown-menu.js +9 -2
  107. package/dist/ui/dropdown-menu.js.map +1 -1
  108. package/package.json +18 -2
@@ -0,0 +1,204 @@
1
+ /**
2
+ * THE CLOUDGRID EMAIL LAYOUT (ui#295). Content in, `{ html, text }` out.
3
+ *
4
+ * One layout for every email CloudGrid sends: the sign-in code, invites,
5
+ * reactions, activity and the expiry warnings. The platform calls it; the
6
+ * styleguide previews its real output (`/gallery/email`), so what a reviewer
7
+ * sees is the string the platform will send.
8
+ *
9
+ * ```ts
10
+ * import { renderEmail } from "@cloudgrid-io/ui/email"
11
+ * const { html, text } = renderEmail({ headline: "expiring-soon", title, points, action, note, footer })
12
+ * ```
13
+ *
14
+ * ## It speaks the library's language (founder revision, 2026-10-11)
15
+ *
16
+ * The first cut was a generic transactional email, and the founder rejected it:
17
+ * "it doesn't use our logo or our language". So every element here is taken
18
+ * from a library element and its tokens, the way the Console's sign-in and the
19
+ * `EmptyCreations` block draw them:
20
+ *
21
+ * the logo `Logo`'s own paths, as a PNG (wordmark.png), top left
22
+ * the headline Tanker, uppercase, brand purple, at the sign-in's size
23
+ * ("READY TO PLUG IN?"), as a PNG per headline, its words as
24
+ * styled alt text
25
+ * the body Space Grotesk where the client loads web fonts, else the
26
+ * system stack; the title is the first line under the
27
+ * headline, at the sign-in subline's 19px
28
+ * the button the `cta` button as `EmptyCreations` sets it: teal, ink
29
+ * label in Manrope bold, 40px tall, 16px corners, 20px sides
30
+ * an item the entity card: the identity media (field colour, the grid,
31
+ * the monogram tile), name and kind, and the expiring status
32
+ * pill exactly as `EntityExpiryStatus` draws it
33
+ * the frame lavender ground, the white container at the library's
34
+ * container radius, centred like the sign-in and the empty
35
+ * state, and the sign-in's footer
36
+ *
37
+ * ## No React, no dependencies
38
+ *
39
+ * The API runs this, not a browser. The module imports only its two generated
40
+ * siblings, `./palette.js` (colours and shape, from tokens.css at build) and
41
+ * `./assets.js` (the image manifest, from scripts/generate-email-assets.mjs).
42
+ * `test/email.test.mjs` asserts that.
43
+ *
44
+ * ## Email clients
45
+ *
46
+ * Outlook for Windows renders with Word: no flexbox, no `var()`, no rounded
47
+ * corners, no background images on a cell without VML. Gmail strips `<link>`
48
+ * and every custom property, and loads no web font. So: nested tables, inline
49
+ * styles, colours as hex and again as `bgcolor`, a fixed 560px column inside an
50
+ * Outlook-only table, and pictures for the two things a system font cannot be
51
+ * (the wordmark and the Tanker headline). Outlook draws the corners square and
52
+ * the identity field flat; everything else holds.
53
+ *
54
+ * ## Images off
55
+ *
56
+ * The wordmark and headline carry alt text styled as what they show (purple,
57
+ * bold, uppercase for a headline), so an inbox that blocks images still shows a
58
+ * purple "EXPIRING SOON" where the picture was. The identity media keeps its
59
+ * field colour without the grid; the monogram tile is not an image at all.
60
+ *
61
+ * ## Assets: the sender names the host, or there are none
62
+ *
63
+ * The library never chooses where a recipient's mail client fetches anything
64
+ * from (Gilad's review of ui#296: a third-party host sees every recipient's IP
65
+ * and open time, is blocked on some networks, and costs deliverability).
66
+ *
67
+ * - `assetBaseUrl` set: the wordmark, the headline and the grid tile load from
68
+ * there. The sender serves this package's `dist/brand/email/*` from its own
69
+ * domain and passes that base.
70
+ * - `assetBaseUrl` absent: the email is image-free for every client. The
71
+ * wordmark and headline are live text, the treatment Outlook gets anyway,
72
+ * and the identity field is plain colour. Nothing is requested from any
73
+ * host the caller did not name.
74
+ * - `webFontsUrl` likewise: the brand faces load only from a stylesheet the
75
+ * caller names; without it the system stack renders.
76
+ * - An item's `imageUrl` is the caller's own URL and is used as given.
77
+ *
78
+ * ## Escaping
79
+ *
80
+ * Every string the caller passes is text, never HTML: `&`, `<`, `>`, `"` and
81
+ * `'` are escaped wherever they land. A URL is percent-encoded where it is not
82
+ * already, then attribute-escaped. A URL with any scheme other than `http`,
83
+ * `https` or `mailto` (a `javascript:` link, say) is replaced by `#`.
84
+ */
85
+ import { EMAIL_HEADLINES, type EmailHeadline } from "./assets.js";
86
+ export type { EmailHeadline };
87
+ export { EMAIL_HEADLINES };
88
+ export type EmailTone = "page" | "app" | "agent";
89
+ export type EmailLink = {
90
+ label: string;
91
+ url: string;
92
+ };
93
+ /** The `EntityExpiryStatus` pill: amber `soon`, red `urgent`. */
94
+ export type EmailStatus = {
95
+ label: string;
96
+ tone: "soon" | "urgent";
97
+ };
98
+ export interface EmailItem {
99
+ /** "Pricing Grid" */
100
+ name: string;
101
+ /** The kind line. "Page · not opened in 25 days" */
102
+ detail: string;
103
+ /** "PG". One or two characters; anything longer is cut to two. */
104
+ monogram: string;
105
+ /** The identity colour. Absent: the app's purple. */
106
+ tone?: EmailTone;
107
+ /** The status pill, e.g. `{ label: "Expires tomorrow", tone: "urgent" }`. */
108
+ status?: EmailStatus;
109
+ /** The first cut's name for `status`. Still accepted; `status` wins when both are set. */
110
+ badge?: EmailStatus;
111
+ /** A button on the item itself, e.g. Keep it. */
112
+ action?: EmailLink;
113
+ /**
114
+ * The entity's picture, its public OG card (1200x630):
115
+ * `https://api.cloudgrid.io/api/v2/entities/<id>/og.png`. Shown full width in
116
+ * the card's media area, alt = the item's name. Absent (a never-published app
117
+ * has no screenshot), the tinted grid and the monogram tile show instead; with
118
+ * images off, the tinted grid shows behind the alt text.
119
+ */
120
+ imageUrl?: string;
121
+ }
122
+ export interface EmailContent {
123
+ /** Hidden inbox preview text. */
124
+ preheader?: string;
125
+ /** The Tanker display line, from the fixed set. A pre-made image; its words are the alt text. */
126
+ headline?: EmailHeadline;
127
+ /**
128
+ * The first body line, under the headline: "Pricing Grid expires in 5 days."
129
+ * Without a headline it is the email's heading. Also a good default subject.
130
+ */
131
+ title: string;
132
+ /**
133
+ * Short scannable facts under the title, as a compact bulleted list:
134
+ * "Not opened in 25 days", "Expires Friday, October 16". The expiry emails
135
+ * use these instead of `lines` (founder, 2026-10-11: "use short points").
136
+ */
137
+ points?: string[];
138
+ /** Paragraphs, plain text, for the emails that need prose. */
139
+ lines?: string[];
140
+ items?: EmailItem[];
141
+ /** A sign-in code, e.g. "482-913". */
142
+ code?: string;
143
+ /** The one main button. */
144
+ action?: EmailLink;
145
+ /** One small muted line directly under the main button: "Kept pages don't expire." */
146
+ note?: string;
147
+ /** "Or open it first:" and a link. */
148
+ secondary?: {
149
+ prefix: string;
150
+ link: EmailLink;
151
+ };
152
+ /** Lines of small print, then an optional link with a suffix. The copyright line follows them. */
153
+ footer: {
154
+ lines: string[];
155
+ link?: EmailLink & {
156
+ suffix?: string;
157
+ };
158
+ };
159
+ /**
160
+ * Absolute base URL where the sender serves this package's `brand/email/*`
161
+ * files (the wordmark, the headline images, the grid tile), on its own domain.
162
+ * No default: without it the email is image-free, with the wordmark and
163
+ * headline as live text.
164
+ */
165
+ assetBaseUrl?: string;
166
+ /**
167
+ * A stylesheet that loads Space Grotesk and Manrope, on a host the sender
168
+ * chooses (its own, or Google Fonts if it accepts that). No default: without
169
+ * it the system stack renders, which is what Gmail and Outlook do anyway.
170
+ */
171
+ webFontsUrl?: string;
172
+ /**
173
+ * The first cut's mark image. Accepted and ignored: the email's logo is the
174
+ * wordmark now (founder revision, 2026-10-11).
175
+ * @deprecated
176
+ */
177
+ markUrl?: string;
178
+ }
179
+ export interface RenderedEmail {
180
+ html: string;
181
+ text: string;
182
+ }
183
+ /**
184
+ * The Google Fonts stylesheet for the two faces the email names, for a sender
185
+ * that chooses to accept Google as a host. Passed as `webFontsUrl`; never used
186
+ * unless passed.
187
+ */
188
+ export declare const GOOGLE_FONTS_EMAIL_CSS = "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Manrope:wght@500;600;700&display=swap";
189
+ /** Text to HTML text, safe in an element body and in a quoted attribute. */
190
+ export declare function escapeHtml(value: string): string;
191
+ /**
192
+ * A URL, percent-encoded where it is not already, with any scheme other than
193
+ * http, https or mailto replaced by `#`. Returns the URL for a text body; pass
194
+ * the result through `escapeHtml` before putting it in an attribute.
195
+ */
196
+ export declare function safeUrl(url: string): string;
197
+ /**
198
+ * The width an item's picture is drawn at: the 560 column, less the container's
199
+ * 1px border and 40px padding a side, less the card's own 1px border.
200
+ */
201
+ export declare const EMAIL_ITEM_IMAGE_WIDTH: number;
202
+ /** Render one email. Pure: the same content always gives the same strings (within a year). */
203
+ export declare function renderEmail(content: EmailContent): RenderedEmail;
204
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/email/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmFG;AAEH,OAAO,EAAE,eAAe,EAA8B,KAAK,aAAa,EAAE,MAAM,aAAa,CAAA;AAG7F,YAAY,EAAE,aAAa,EAAE,CAAA;AAC7B,OAAO,EAAE,eAAe,EAAE,CAAA;AAE1B,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,KAAK,GAAG,OAAO,CAAA;AAEhD,MAAM,MAAM,SAAS,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAA;AAEtD,iEAAiE;AACjE,MAAM,MAAM,WAAW,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,QAAQ,CAAA;CAAE,CAAA;AAEpE,MAAM,WAAW,SAAS;IACxB,qBAAqB;IACrB,IAAI,EAAE,MAAM,CAAA;IACZ,oDAAoD;IACpD,MAAM,EAAE,MAAM,CAAA;IACd,kEAAkE;IAClE,QAAQ,EAAE,MAAM,CAAA;IAChB,qDAAqD;IACrD,IAAI,CAAC,EAAE,SAAS,CAAA;IAChB,6EAA6E;IAC7E,MAAM,CAAC,EAAE,WAAW,CAAA;IACpB,0FAA0F;IAC1F,KAAK,CAAC,EAAE,WAAW,CAAA;IACnB,iDAAiD;IACjD,MAAM,CAAC,EAAE,SAAS,CAAA;IAClB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,WAAW,YAAY;IAC3B,iCAAiC;IACjC,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,iGAAiG;IACjG,QAAQ,CAAC,EAAE,aAAa,CAAA;IACxB;;;OAGG;IACH,KAAK,EAAE,MAAM,CAAA;IACb;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAA;IACjB,8DAA8D;IAC9D,KAAK,CAAC,EAAE,MAAM,EAAE,CAAA;IAChB,KAAK,CAAC,EAAE,SAAS,EAAE,CAAA;IACnB,sCAAsC;IACtC,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,2BAA2B;IAC3B,MAAM,CAAC,EAAE,SAAS,CAAA;IAClB,sFAAsF;IACtF,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,sCAAsC;IACtC,SAAS,CAAC,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,SAAS,CAAA;KAAE,CAAA;IAC/C,kGAAkG;IAClG,MAAM,EAAE;QAAE,KAAK,EAAE,MAAM,EAAE,CAAC;QAAC,IAAI,CAAC,EAAE,SAAS,GAAG;YAAE,MAAM,CAAC,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,CAAA;IACnE;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;CACb;AAED;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,6HACyF,CAAA;AAY5H,4EAA4E;AAC5E,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEhD;AAOD;;;;GAIG;AACH,wBAAgB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAwB3C;AAuCD;;;GAGG;AACH,eAAO,MAAM,sBAAsB,QAA6B,CAAA;AA2ThE,8FAA8F;AAC9F,wBAAgB,WAAW,CAAC,OAAO,EAAE,YAAY,GAAG,aAAa,CAEhE"}
@@ -0,0 +1,429 @@
1
+ /**
2
+ * THE CLOUDGRID EMAIL LAYOUT (ui#295). Content in, `{ html, text }` out.
3
+ *
4
+ * One layout for every email CloudGrid sends: the sign-in code, invites,
5
+ * reactions, activity and the expiry warnings. The platform calls it; the
6
+ * styleguide previews its real output (`/gallery/email`), so what a reviewer
7
+ * sees is the string the platform will send.
8
+ *
9
+ * ```ts
10
+ * import { renderEmail } from "@cloudgrid-io/ui/email"
11
+ * const { html, text } = renderEmail({ headline: "expiring-soon", title, points, action, note, footer })
12
+ * ```
13
+ *
14
+ * ## It speaks the library's language (founder revision, 2026-10-11)
15
+ *
16
+ * The first cut was a generic transactional email, and the founder rejected it:
17
+ * "it doesn't use our logo or our language". So every element here is taken
18
+ * from a library element and its tokens, the way the Console's sign-in and the
19
+ * `EmptyCreations` block draw them:
20
+ *
21
+ * the logo `Logo`'s own paths, as a PNG (wordmark.png), top left
22
+ * the headline Tanker, uppercase, brand purple, at the sign-in's size
23
+ * ("READY TO PLUG IN?"), as a PNG per headline, its words as
24
+ * styled alt text
25
+ * the body Space Grotesk where the client loads web fonts, else the
26
+ * system stack; the title is the first line under the
27
+ * headline, at the sign-in subline's 19px
28
+ * the button the `cta` button as `EmptyCreations` sets it: teal, ink
29
+ * label in Manrope bold, 40px tall, 16px corners, 20px sides
30
+ * an item the entity card: the identity media (field colour, the grid,
31
+ * the monogram tile), name and kind, and the expiring status
32
+ * pill exactly as `EntityExpiryStatus` draws it
33
+ * the frame lavender ground, the white container at the library's
34
+ * container radius, centred like the sign-in and the empty
35
+ * state, and the sign-in's footer
36
+ *
37
+ * ## No React, no dependencies
38
+ *
39
+ * The API runs this, not a browser. The module imports only its two generated
40
+ * siblings, `./palette.js` (colours and shape, from tokens.css at build) and
41
+ * `./assets.js` (the image manifest, from scripts/generate-email-assets.mjs).
42
+ * `test/email.test.mjs` asserts that.
43
+ *
44
+ * ## Email clients
45
+ *
46
+ * Outlook for Windows renders with Word: no flexbox, no `var()`, no rounded
47
+ * corners, no background images on a cell without VML. Gmail strips `<link>`
48
+ * and every custom property, and loads no web font. So: nested tables, inline
49
+ * styles, colours as hex and again as `bgcolor`, a fixed 560px column inside an
50
+ * Outlook-only table, and pictures for the two things a system font cannot be
51
+ * (the wordmark and the Tanker headline). Outlook draws the corners square and
52
+ * the identity field flat; everything else holds.
53
+ *
54
+ * ## Images off
55
+ *
56
+ * The wordmark and headline carry alt text styled as what they show (purple,
57
+ * bold, uppercase for a headline), so an inbox that blocks images still shows a
58
+ * purple "EXPIRING SOON" where the picture was. The identity media keeps its
59
+ * field colour without the grid; the monogram tile is not an image at all.
60
+ *
61
+ * ## Assets: the sender names the host, or there are none
62
+ *
63
+ * The library never chooses where a recipient's mail client fetches anything
64
+ * from (Gilad's review of ui#296: a third-party host sees every recipient's IP
65
+ * and open time, is blocked on some networks, and costs deliverability).
66
+ *
67
+ * - `assetBaseUrl` set: the wordmark, the headline and the grid tile load from
68
+ * there. The sender serves this package's `dist/brand/email/*` from its own
69
+ * domain and passes that base.
70
+ * - `assetBaseUrl` absent: the email is image-free for every client. The
71
+ * wordmark and headline are live text, the treatment Outlook gets anyway,
72
+ * and the identity field is plain colour. Nothing is requested from any
73
+ * host the caller did not name.
74
+ * - `webFontsUrl` likewise: the brand faces load only from a stylesheet the
75
+ * caller names; without it the system stack renders.
76
+ * - An item's `imageUrl` is the caller's own URL and is used as given.
77
+ *
78
+ * ## Escaping
79
+ *
80
+ * Every string the caller passes is text, never HTML: `&`, `<`, `>`, `"` and
81
+ * `'` are escaped wherever they land. A URL is percent-encoded where it is not
82
+ * already, then attribute-escaped. A URL with any scheme other than `http`,
83
+ * `https` or `mailto` (a `javascript:` link, say) is replaced by `#`.
84
+ */
85
+ import { EMAIL_HEADLINES, EMAIL_MESH, EMAIL_WORDMARK } from "./assets.js";
86
+ import { EMAIL_PALETTE as C, EMAIL_SHAPE as S } from "./palette.js";
87
+ export { EMAIL_HEADLINES };
88
+ /**
89
+ * The Google Fonts stylesheet for the two faces the email names, for a sender
90
+ * that chooses to accept Google as a host. Passed as `webFontsUrl`; never used
91
+ * unless passed.
92
+ */
93
+ export const GOOGLE_FONTS_EMAIL_CSS = "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Manrope:wght@500;600;700&display=swap";
94
+ // ---- text and URL safety ----------------------------------------------------
95
+ const ESCAPES = {
96
+ "&": "&amp;",
97
+ "<": "&lt;",
98
+ ">": "&gt;",
99
+ '"': "&quot;",
100
+ "'": "&#39;",
101
+ };
102
+ /** Text to HTML text, safe in an element body and in a quoted attribute. */
103
+ export function escapeHtml(value) {
104
+ return String(value).replace(/[&<>"']/g, (c) => ESCAPES[c] ?? c);
105
+ }
106
+ /** The characters a URL may carry as they are (RFC 3986 reserved and unreserved). */
107
+ const URL_SAFE = /[A-Za-z0-9\-._~:/?#[\]@!$&'()*+,;=%]/;
108
+ const ALLOWED_SCHEMES = new Set(["http", "https", "mailto"]);
109
+ /**
110
+ * A URL, percent-encoded where it is not already, with any scheme other than
111
+ * http, https or mailto replaced by `#`. Returns the URL for a text body; pass
112
+ * the result through `escapeHtml` before putting it in an attribute.
113
+ */
114
+ export function safeUrl(url) {
115
+ const trimmed = String(url).trim();
116
+ // Browsers drop tabs, newlines and control characters before reading a
117
+ // scheme, so "java\nscript:" is javascript:. Read the scheme the same way.
118
+ const probe = trimmed.replace(/[\u0000- \u007f]/g, "");
119
+ const scheme = probe.match(/^([a-z][a-z0-9+.-]*):/i);
120
+ if (scheme && !ALLOWED_SCHEMES.has(scheme[1].toLowerCase()))
121
+ return "#";
122
+ // A `%` that does not start an escape is a literal percent sign.
123
+ const percent = trimmed.replace(/%(?![0-9A-Fa-f]{2})/g, "%25");
124
+ let out = "";
125
+ for (const ch of percent) {
126
+ if (URL_SAFE.test(ch)) {
127
+ out += ch;
128
+ continue;
129
+ }
130
+ try {
131
+ out += encodeURIComponent(ch);
132
+ }
133
+ catch {
134
+ // A lone surrogate cannot be encoded. U+FFFD, as a browser would.
135
+ out += "%EF%BF%BD";
136
+ }
137
+ }
138
+ return out;
139
+ }
140
+ const attrUrl = (url) => escapeHtml(safeUrl(url));
141
+ // ---- Outlook ------------------------------------------------------------------
142
+ //
143
+ // Outlook for Windows blocks images by default, so it never gets one: every
144
+ // picture has an Outlook twin made of live text and table cells, and the two are
145
+ // fenced with Microsoft's conditional comments. Every other client reads the
146
+ // `!mso` branch (its comment markers are plain comments to them) and never sees
147
+ // the `mso` one.
148
+ /** Only Outlook for Windows renders this. */
149
+ const mso = (html) => `<!--[if mso]>${html}<![endif]-->`;
150
+ /** Everyone except Outlook for Windows renders this. */
151
+ const notMso = (html) => `<!--[if !mso]><!-->${html}<!--<![endif]-->`;
152
+ /** A heavy system face for live-text stand-ins, where Tanker cannot reach. */
153
+ const HEAVY = "'Arial Black', 'Helvetica Neue', Arial, sans-serif";
154
+ /** The wordmark's stand-in face. */
155
+ const WORDMARK_FACE = "Arial, Helvetica, sans-serif";
156
+ /**
157
+ * A size for a headline's alt text that fits the image's own width in the heavy
158
+ * face, so a blocked headline sets as one designed line, not a clipped one.
159
+ * Arial Black's capitals average about 0.72em.
160
+ */
161
+ function altSize(text, width) {
162
+ return Math.max(18, Math.min(30, Math.floor(width / (text.length * 0.72))));
163
+ }
164
+ // ---- the shape --------------------------------------------------------------
165
+ const BODY = S.fontBody;
166
+ const UI = S.fontUi;
167
+ /** Every layout table: no spacing, no border, announced as nothing. */
168
+ const TABLE = 'role="presentation" cellpadding="0" cellspacing="0" border="0"';
169
+ /**
170
+ * The width an item's picture is drawn at: the 560 column, less the container's
171
+ * 1px border and 40px padding a side, less the card's own 1px border.
172
+ */
173
+ export const EMAIL_ITEM_IMAGE_WIDTH = 560 - 2 * (1 + 40) - 2 * 1;
174
+ /** How tall a blocked item picture's alt-text strip is. */
175
+ const ALT_STRIP = 64;
176
+ const TILE = { page: C.tilePage, app: C.tileApp, agent: C.tileAgent };
177
+ const FIELD = { page: C.fieldPage, app: C.fieldApp, agent: C.fieldAgent };
178
+ const STATUS = {
179
+ soon: { fill: C.soonFill, border: C.soonBorder, text: C.soon, dot: C.soonDot },
180
+ urgent: { fill: C.urgentFill, border: C.urgentBorder, text: C.urgent, dot: C.urgentDot },
181
+ };
182
+ /** The caller's web-font stylesheet, if it named one. Gmail drops the tag either way. */
183
+ const fontLink = (url) => (url ? `<link rel="stylesheet" href="${attrUrl(url)}">` : "");
184
+ function assetUrl(base, file) {
185
+ return attrUrl(`${base.endsWith("/") ? base : `${base}/`}${file}`);
186
+ }
187
+ /** The `cta` button, as EmptyCreations sets it. `small` is the per-item size. */
188
+ function button(link, small = false) {
189
+ const pad = small ? "6px 16px" : "10px 20px";
190
+ const size = small ? 14 : 15;
191
+ return (`<table ${TABLE} style="border-collapse:separate;"><tr>` +
192
+ `<td align="center" bgcolor="${C.button}" style="background-color:${C.button};border-radius:${S.radiusButton}px;mso-padding-alt:${pad};">` +
193
+ `<a href="${attrUrl(link.url)}" target="_blank" rel="noopener" style="display:inline-block;padding:${pad};font-family:${UI};font-size:${size}px;font-weight:700;line-height:20px;color:${C.onButton};text-decoration:none;border-radius:${S.radiusButton}px;white-space:nowrap;">` +
194
+ `${escapeHtml(link.label)}</a></td></tr></table>`);
195
+ }
196
+ /** The `EntityExpiryStatus` pill: fill, border, dot and the word in the strong tone. */
197
+ function statusPill(status) {
198
+ const t = STATUS[status.tone] ?? STATUS.soon;
199
+ return (`<table ${TABLE} style="border-collapse:separate;"><tr>` +
200
+ `<td bgcolor="${t.fill}" style="background-color:${t.fill};border:1px solid ${t.border};border-radius:999px;padding:2px 10px;font-family:${BODY};font-size:13px;line-height:19px;font-weight:500;color:${t.text};white-space:nowrap;">` +
201
+ `<span style="display:inline-block;width:6px;height:6px;border-radius:999px;background-color:${t.dot};vertical-align:middle;margin:0 6px 2px 0;font-size:0;line-height:0;">&nbsp;</span>` +
202
+ `${escapeHtml(status.label)}</td></tr></table>`);
203
+ }
204
+ function monogram(value) {
205
+ const chars = [...String(value).trim()];
206
+ return chars.length === 0 ? "?" : chars.slice(0, 2).join("");
207
+ }
208
+ /** One item, as an entity card: the identity media, then name, kind and status. */
209
+ function item(it, base) {
210
+ const tone = it.tone ?? "app";
211
+ const status = it.status ?? it.badge;
212
+ // Inside url('...') too, so an apostrophe is percent-encoded rather than escaped.
213
+ // No base, no grid: the field is plain colour and nothing is fetched.
214
+ const mesh = base ? assetUrl(base, EMAIL_MESH.file).replace(/&#39;/g, "%27") : undefined;
215
+ const r = S.radiusCard;
216
+ // The tinted grid is the media area's ground either way: behind the picture,
217
+ // so a blocked image still sits on the entity's colour, and on its own with
218
+ // the monogram tile when there is no picture.
219
+ const ground = `bgcolor="${FIELD[tone]}" ${mesh ? `background="${mesh}" ` : ""}`;
220
+ const groundStyle = `background-color:${FIELD[tone]};` +
221
+ (mesh
222
+ ? `background-image:url('${mesh}');background-size:${EMAIL_MESH.size}px ${EMAIL_MESH.size}px;background-position:-1px -1px;background-repeat:repeat;`
223
+ : "") +
224
+ `border-radius:${r - 1}px ${r - 1}px 0 0;`;
225
+ const w = EMAIL_ITEM_IMAGE_WIDTH;
226
+ const tile = `<td align="center" valign="middle" height="112" ${ground}style="height:112px;${groundStyle}">` +
227
+ `<table ${TABLE} align="center" style="border-collapse:separate;"><tr>` +
228
+ `<td width="46" height="46" align="center" valign="middle" bgcolor="${TILE[tone]}" style="width:46px;height:46px;background-color:${TILE[tone]};border-radius:${S.radiusTile}px;font-family:${BODY};font-size:20px;line-height:46px;font-weight:700;color:${C.onPrimary};">` +
229
+ `${escapeHtml(monogram(it.monogram))}</td></tr></table></td>`;
230
+ const picture = it.imageUrl
231
+ ? `<td align="center" valign="middle" ${ground}style="${groundStyle}font-size:0;line-height:0;">` +
232
+ // width:100% with no max-width: on a phone the container's padding drops
233
+ // to 20px and the card grows past 476, and the picture must grow with it.
234
+ // No height attribute, so a blocked picture is not an empty 1200:630 box:
235
+ // it collapses to its alt text, the name in the tile's colour, on a
236
+ // ${ALT_STRIP}px strip of the tinted grid.
237
+ `<img src="${attrUrl(it.imageUrl)}" width="${w}" alt="${escapeHtml(it.name)}" ` +
238
+ `style="display:block;width:100%;height:auto;border:0;outline:none;border-radius:${r - 1}px ${r - 1}px 0 0;font-family:${BODY};font-size:16px;line-height:${ALT_STRIP}px;font-weight:700;color:${TILE[tone]};text-align:center;">` +
239
+ `</td>`
240
+ : undefined;
241
+ // Outlook never gets the picture: it blocks images by default, so it gets the tile.
242
+ const media = picture ? `${notMso(`<tr>${picture}</tr>`)}${mso(`<tr>${tile}</tr>`)}` : `<tr>${tile}</tr>`;
243
+ const meta = `<table ${TABLE} width="100%" style="width:100%;"><tr>` +
244
+ `<td valign="middle" style="font-family:${BODY};">` +
245
+ `<div style="font-size:16px;line-height:1.35;font-weight:500;color:${C.text};">${escapeHtml(it.name)}</div>` +
246
+ `<div style="font-size:13px;line-height:1.45;color:${C.textSecondary};">${escapeHtml(it.detail)}</div></td>` +
247
+ (status ? `<td align="right" valign="middle" style="padding-left:12px;">${statusPill(status)}</td>` : "") +
248
+ `</tr></table>` +
249
+ (it.action ? `<table ${TABLE} style="margin-top:14px;"><tr><td>${button(it.action, true)}</td></tr></table>` : "");
250
+ return (`<table ${TABLE} width="100%" bgcolor="${C.card}" style="width:100%;background-color:${C.card};border:1px solid ${C.hairline};border-radius:${r}px;border-collapse:separate;">` +
251
+ media +
252
+ `<tr><td style="padding:14px 16px 16px;">${meta}</td></tr>` +
253
+ `</table>`);
254
+ }
255
+ /** One block of the card, spaced from the one above it. */
256
+ const row = (content, top, align = "center") => `<tr><td align="${align}" style="padding-top:${top}px;text-align:${align};">${content}</td></tr>`;
257
+ function headlineBlock(content, base) {
258
+ const h = content.headline ? EMAIL_HEADLINES[content.headline] : undefined;
259
+ if (!h) {
260
+ // No headline: the title is the heading, in the body face.
261
+ return `<h1 style="margin:0;font-family:${BODY};font-size:24px;line-height:1.25;font-weight:700;color:${C.text};">${escapeHtml(content.title)}</h1>`;
262
+ }
263
+ const words = escapeHtml(h.text);
264
+ const live = `<span style="display:block;font-family:${HEAVY};font-size:34px;line-height:${h.height}px;font-weight:900;letter-spacing:1px;text-transform:uppercase;color:${C.primary};text-align:center;">${words}</span>`;
265
+ // No base: live text for everyone, the treatment Outlook gets.
266
+ if (!base)
267
+ return `<h1 style="margin:0;font-size:0;line-height:0;">${live}</h1>`;
268
+ return (`<h1 style="margin:0;font-size:0;line-height:0;">` +
269
+ // The picture, its alt text styled as a heading for a client that blocks it.
270
+ // No height attribute: a blocked image then takes the alt text's height
271
+ // rather than holding an empty box.
272
+ notMso(`<img src="${assetUrl(base, h.file)}" width="${h.width}" alt="${words}" ` +
273
+ `style="display:block;margin:0 auto;border:0;outline:none;max-width:100%;height:auto;font-family:${HEAVY};font-size:${altSize(h.text, h.width)}px;line-height:${h.height}px;font-weight:900;letter-spacing:0.02em;text-transform:uppercase;color:${C.primary};text-align:center;">`) +
274
+ // Outlook: the same words as live text, sized like the picture.
275
+ mso(live) +
276
+ `</h1>`);
277
+ }
278
+ /**
279
+ * The points: a small table centred in the card, one row per fact, a brand
280
+ * purple bullet and the words left-aligned beside it. The bullet is a text
281
+ * character rather than a drawn dot, so Outlook (which draws no inline-block
282
+ * box) shows it too; the same markup serves every branch.
283
+ */
284
+ function pointsList(points) {
285
+ const rows = points
286
+ .map((p) => `<tr>` +
287
+ `<td valign="top" style="padding:3px 10px 3px 0;font-family:${BODY};font-size:15px;line-height:22px;font-weight:700;color:${C.primary};">•</td>` +
288
+ `<td valign="top" align="left" style="padding:3px 0;font-family:${BODY};font-size:15px;line-height:22px;color:${C.text};text-align:left;">${escapeHtml(p)}</td>` +
289
+ `</tr>`)
290
+ .join("");
291
+ return `<table ${TABLE} align="center" style="margin:0 auto;">${rows}</table>`;
292
+ }
293
+ function cardRows(content, base) {
294
+ const rows = [row(headlineBlock(content, base), 0)];
295
+ if (content.headline && EMAIL_HEADLINES[content.headline]) {
296
+ rows.push(row(`<p style="margin:0;font-family:${BODY};font-size:19px;line-height:1.46;color:${C.textSecondary};">${escapeHtml(content.title)}</p>`, 10));
297
+ }
298
+ if (content.code) {
299
+ rows.push(row(`<table ${TABLE} width="100%" style="width:100%;border-collapse:separate;"><tr>` +
300
+ `<td align="center" bgcolor="${C.raised}" style="background-color:${C.raised};border-radius:${S.radiusCard}px;padding:18px;font-family:${S.fontMono};font-size:30px;line-height:1;font-weight:600;letter-spacing:0.12em;color:${C.text};">` +
301
+ `${escapeHtml(content.code)}</td></tr></table>`, 24));
302
+ }
303
+ if (content.points?.length)
304
+ rows.push(row(pointsList(content.points), 18));
305
+ (content.lines ?? []).forEach((line, i) => {
306
+ rows.push(row(`<p style="margin:0;font-family:${BODY};font-size:15px;line-height:1.55;color:${C.textSecondary};">${escapeHtml(line)}</p>`, i === 0 ? 16 : 10));
307
+ });
308
+ (content.items ?? []).forEach((it, i) => rows.push(row(item(it, base), i === 0 ? 28 : 16, "left")));
309
+ if (content.action)
310
+ rows.push(row(`<table ${TABLE} align="center"><tr><td>${button(content.action)}</td></tr></table>`, 28));
311
+ if (content.note) {
312
+ rows.push(row(`<p style="margin:0;font-family:${BODY};font-size:13px;line-height:1.46;color:${C.textMuted};">${escapeHtml(content.note)}</p>`, content.action ? 12 : 20));
313
+ }
314
+ if (content.secondary) {
315
+ const { prefix, link } = content.secondary;
316
+ rows.push(row(`<p style="margin:0;font-family:${BODY};font-size:13px;line-height:1.5;color:${C.textSecondary};">${escapeHtml(prefix)} ` +
317
+ `<a href="${attrUrl(link.url)}" target="_blank" rel="noopener" style="color:${C.text};text-decoration:underline;">${escapeHtml(link.label)}</a></p>`, 16));
318
+ }
319
+ return rows.join("");
320
+ }
321
+ /** The sign-in footer's copyright line. */
322
+ function copyright() {
323
+ return `© ${new Date().getUTCFullYear()}. CloudGrid. All Rights Reserved.`;
324
+ }
325
+ function footer(content) {
326
+ const { lines, link } = content.footer;
327
+ const p = (inner, last = false) => `<p style="margin:0 0 ${last ? 0 : 6}px;font-family:${BODY};font-size:13px;line-height:1.46;color:${C.textMuted};text-align:center;">${inner}</p>`;
328
+ const parts = lines.map((l) => p(escapeHtml(l)));
329
+ if (link) {
330
+ parts.push(p(`<a href="${attrUrl(link.url)}" target="_blank" rel="noopener" style="color:${C.text};text-decoration:underline;">${escapeHtml(link.label)}</a>${escapeHtml(link.suffix ?? "")}`));
331
+ }
332
+ parts.push(p(escapeHtml(copyright()), true));
333
+ return parts.join("");
334
+ }
335
+ /**
336
+ * The hidden preview line. The run of zero-width joiners after it stops an inbox
337
+ * filling the rest of the preview with the start of the body.
338
+ */
339
+ function preheader(value) {
340
+ if (!value)
341
+ return "";
342
+ return (`<div style="display:none;max-height:0;max-width:0;overflow:hidden;mso-hide:all;font-size:1px;line-height:1px;color:${C.page};opacity:0;">` +
343
+ `${escapeHtml(value)}${"&zwnj;&nbsp;".repeat(60)}</div>`);
344
+ }
345
+ function renderHtml(content) {
346
+ const base = content.assetBaseUrl || undefined;
347
+ const w = EMAIL_WORDMARK;
348
+ const liveMark = `<span style="font-family:${WORDMARK_FACE};font-size:26px;line-height:${w.height}px;font-weight:700;letter-spacing:-0.5px;color:${C.primary};">cloudgrid</span>`;
349
+ return (`<!doctype html>` +
350
+ `<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:o="urn:schemas-microsoft-com:office:office">` +
351
+ `<head>` +
352
+ `<meta charset="utf-8">` +
353
+ `<meta name="viewport" content="width=device-width, initial-scale=1">` +
354
+ `<meta name="x-apple-disable-message-reformatting">` +
355
+ `<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">` +
356
+ `<meta name="color-scheme" content="light">` +
357
+ `<meta name="supported-color-schemes" content="light">` +
358
+ `<title>${escapeHtml(content.title)}</title>` +
359
+ fontLink(content.webFontsUrl) +
360
+ `<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->` +
361
+ `<style>body{margin:0;padding:0;width:100%!important;-webkit-text-size-adjust:100%;}table{border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;}img{border:0;outline:none;text-decoration:none;-ms-interpolation-mode:bicubic;}@media (max-width:600px){.cg-card{padding:32px 20px!important;}}</style>` +
362
+ `</head>` +
363
+ `<body bgcolor="${C.page}" style="margin:0;padding:0;background-color:${C.page};">` +
364
+ preheader(content.preheader) +
365
+ `<table ${TABLE} width="100%" bgcolor="${C.page}" style="width:100%;background-color:${C.page};"><tr>` +
366
+ `<td align="center" style="padding:32px 16px 40px;">` +
367
+ `<!--[if mso]><table ${TABLE} width="560" align="center"><tr><td><![endif]-->` +
368
+ // separate, not the reset's collapse: a collapsed table draws a rounded
369
+ // cell's border square, which showed as a faint edge behind the corners.
370
+ `<table ${TABLE} width="100%" style="width:100%;max-width:560px;border-collapse:separate;">` +
371
+ // the wordmark, top left
372
+ `<tr><td align="left" style="padding:0 4px 24px;">` +
373
+ // The alt says the name; blocked, it is set lowercase and purple, as the mark reads.
374
+ // No base: the live wordmark for everyone.
375
+ (base
376
+ ? notMso(`<img src="${assetUrl(base, w.file)}" width="${w.width}" alt="${escapeHtml(w.alt)}" ` +
377
+ `style="display:block;border:0;outline:none;width:${w.width}px;height:auto;font-family:${WORDMARK_FACE};font-size:24px;line-height:${w.height}px;font-weight:700;letter-spacing:-0.02em;text-transform:lowercase;color:${C.primary};">`) + mso(liveMark)
378
+ : liveMark) +
379
+ `</td></tr>` +
380
+ // the container
381
+ `<tr><td class="cg-card" bgcolor="${C.card}" style="background-color:${C.card};border:1px solid ${C.hairline};border-radius:${S.radiusContainer}px;padding:44px 40px 40px;">` +
382
+ `<table ${TABLE} width="100%" style="width:100%;">${cardRows(content, base)}</table>` +
383
+ `</td></tr>` +
384
+ // the footer
385
+ `<tr><td align="center" style="padding:24px 16px 0;">${footer(content)}</td></tr>` +
386
+ `</table>` +
387
+ `<!--[if mso]></td></tr></table><![endif]-->` +
388
+ `</td></tr></table>` +
389
+ `</body></html>`);
390
+ }
391
+ /**
392
+ * The plain-text part. Everything the HTML says, in the same order, with every
393
+ * link written out as its URL, so a text-only client loses nothing it can act on.
394
+ */
395
+ function renderText(content) {
396
+ const h = content.headline ? EMAIL_HEADLINES[content.headline] : undefined;
397
+ const out = h ? [h.text, content.title] : [content.title];
398
+ if (content.code)
399
+ out.push(content.code);
400
+ if (content.points?.length)
401
+ out.push(content.points.map((p) => `- ${p}`).join("\n"));
402
+ out.push(...(content.lines ?? []));
403
+ for (const it of content.items ?? []) {
404
+ const status = it.status ?? it.badge;
405
+ const lines = [status ? `${it.name} (${status.label})` : it.name, it.detail];
406
+ if (it.action)
407
+ lines.push(`${it.action.label}: ${safeUrl(it.action.url)}`);
408
+ out.push(lines.join("\n"));
409
+ }
410
+ if (content.action)
411
+ out.push(`${content.action.label}: ${safeUrl(content.action.url)}`);
412
+ if (content.note)
413
+ out.push(content.note);
414
+ if (content.secondary) {
415
+ out.push(`${content.secondary.prefix} ${safeUrl(content.secondary.link.url)}`);
416
+ }
417
+ const foot = [...content.footer.lines];
418
+ const link = content.footer.link;
419
+ if (link)
420
+ foot.push(`${link.label}${link.suffix ?? ""}\n${safeUrl(link.url)}`);
421
+ foot.push(copyright());
422
+ out.push(`--\n${foot.join("\n")}`);
423
+ return `${out.join("\n\n")}\n`;
424
+ }
425
+ /** Render one email. Pure: the same content always gives the same strings (within a year). */
426
+ export function renderEmail(content) {
427
+ return { html: renderHtml(content), text: renderText(content) };
428
+ }
429
+ //# sourceMappingURL=index.js.map