@ultimat3/mail 1.0.0

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 (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +120 -0
  3. package/package.json +39 -0
  4. package/src/base64.ts +9 -0
  5. package/src/blocks.d.ts +47 -0
  6. package/src/blocks.d.ts.map +1 -0
  7. package/src/blocks.js +23 -0
  8. package/src/blocks.js.map +1 -0
  9. package/src/blocks.ts +56 -0
  10. package/src/catalog.d.ts +6 -0
  11. package/src/catalog.d.ts.map +1 -0
  12. package/src/catalog.js +78 -0
  13. package/src/catalog.js.map +1 -0
  14. package/src/catalog.ts +88 -0
  15. package/src/driver-env.ts +105 -0
  16. package/src/driver-resend.ts +184 -0
  17. package/src/driver-smtp.ts +209 -0
  18. package/src/driver.d.ts +79 -0
  19. package/src/driver.d.ts.map +1 -0
  20. package/src/driver.js +115 -0
  21. package/src/driver.js.map +1 -0
  22. package/src/driver.ts +174 -0
  23. package/src/errors.d.ts +23 -0
  24. package/src/errors.d.ts.map +1 -0
  25. package/src/errors.js +82 -0
  26. package/src/errors.js.map +1 -0
  27. package/src/errors.ts +177 -0
  28. package/src/html.d.ts +10 -0
  29. package/src/html.d.ts.map +1 -0
  30. package/src/html.js +34 -0
  31. package/src/html.js.map +1 -0
  32. package/src/html.ts +37 -0
  33. package/src/idempotency.ts +61 -0
  34. package/src/index.d.ts +17 -0
  35. package/src/index.d.ts.map +1 -0
  36. package/src/index.js +13 -0
  37. package/src/index.js.map +1 -0
  38. package/src/index.ts +110 -0
  39. package/src/job.d.ts +17 -0
  40. package/src/job.d.ts.map +1 -0
  41. package/src/job.js +79 -0
  42. package/src/job.js.map +1 -0
  43. package/src/job.ts +36 -0
  44. package/src/layout.d.ts +41 -0
  45. package/src/layout.d.ts.map +1 -0
  46. package/src/layout.js +160 -0
  47. package/src/layout.js.map +1 -0
  48. package/src/layout.ts +221 -0
  49. package/src/mail.d.ts +67 -0
  50. package/src/mail.d.ts.map +1 -0
  51. package/src/mail.js +119 -0
  52. package/src/mail.js.map +1 -0
  53. package/src/mail.ts +195 -0
  54. package/src/mime.ts +233 -0
  55. package/src/render.d.ts +26 -0
  56. package/src/render.d.ts.map +1 -0
  57. package/src/render.js +149 -0
  58. package/src/render.js.map +1 -0
  59. package/src/render.ts +190 -0
  60. package/src/smtp-client.ts +246 -0
  61. package/src/smtp-protocol.ts +184 -0
  62. package/src/smtp-socket.ts +266 -0
  63. package/src/templates/index.d.ts +9 -0
  64. package/src/templates/index.d.ts.map +1 -0
  65. package/src/templates/index.js +23 -0
  66. package/src/templates/index.js.map +1 -0
  67. package/src/templates/index.ts +39 -0
  68. package/src/templates/invite.d.ts +15 -0
  69. package/src/templates/invite.d.ts.map +1 -0
  70. package/src/templates/invite.js +25 -0
  71. package/src/templates/invite.js.map +1 -0
  72. package/src/templates/invite.ts +29 -0
  73. package/src/templates/mfa-enrolled.d.ts +14 -0
  74. package/src/templates/mfa-enrolled.d.ts.map +1 -0
  75. package/src/templates/mfa-enrolled.js +26 -0
  76. package/src/templates/mfa-enrolled.js.map +1 -0
  77. package/src/templates/mfa-enrolled.ts +37 -0
  78. package/src/templates/reset-password.d.ts +13 -0
  79. package/src/templates/reset-password.d.ts.map +1 -0
  80. package/src/templates/reset-password.js +23 -0
  81. package/src/templates/reset-password.js.map +1 -0
  82. package/src/templates/reset-password.ts +27 -0
  83. package/src/templates/security-alert.d.ts +16 -0
  84. package/src/templates/security-alert.d.ts.map +1 -0
  85. package/src/templates/security-alert.js +29 -0
  86. package/src/templates/security-alert.js.map +1 -0
  87. package/src/templates/security-alert.ts +36 -0
  88. package/src/templates/verify-email.d.ts +13 -0
  89. package/src/templates/verify-email.d.ts.map +1 -0
  90. package/src/templates/verify-email.js +23 -0
  91. package/src/templates/verify-email.js.map +1 -0
  92. package/src/templates/verify-email.ts +27 -0
  93. package/src/templates/welcome.d.ts +13 -0
  94. package/src/templates/welcome.d.ts.map +1 -0
  95. package/src/templates/welcome.js +20 -0
  96. package/src/templates/welcome.js.map +1 -0
  97. package/src/templates/welcome.ts +24 -0
package/src/mime.ts ADDED
@@ -0,0 +1,233 @@
1
+ // Single responsibility: serialise a `MailMessage` into the raw RFC 5322 / MIME text the SMTP
2
+ // transport writes to `DATA` — header order, RFC 2047 subject encoding, header folding and
3
+ // quoted-printable body encoding. Pure: no I/O, no clock read beyond the `Date` it is given.
4
+
5
+ import { instant, toZoned, UTC } from '@ultimat3/time';
6
+ import { base64Utf8 } from './base64';
7
+ import { type MailMessage, messageHeaders } from './driver';
8
+ import { headerInvalid } from './errors';
9
+
10
+ export interface MimeOptions {
11
+ /** `Postly <no-reply@postly.test>` or a bare `no-reply@postly.test`. */
12
+ readonly from: string;
13
+ /** The full `Message-ID` header value, angle brackets included: `<abc@postly.test>`. */
14
+ readonly messageId: string;
15
+ readonly date: Date;
16
+ /** multipart boundary token; the driver generates it. */
17
+ readonly boundary: string;
18
+ }
19
+
20
+ const CRLF = '\r\n';
21
+ /** RFC 5322's soft line-fold target for headers. */
22
+ const MAX_FOLD_LINE = 78;
23
+ /** RFC 2045's hard line-length cap for quoted-printable, the trailing `=` included. */
24
+ const MAX_QP_LINE = 76;
25
+ /** RFC 2047: an encoded-word (prefix + payload + suffix) may not exceed this. */
26
+ const MAX_ENCODED_WORD = 75;
27
+ const ENCODED_WORD_PREFIX = '=?UTF-8?B?';
28
+ const ENCODED_WORD_SUFFIX = '?=';
29
+ // The largest byte chunk whose base64 form still fits the encoded-word budget above.
30
+ const MAX_CHUNK_BYTES =
31
+ Math.floor((MAX_ENCODED_WORD - ENCODED_WORD_PREFIX.length - ENCODED_WORD_SUFFIX.length) / 4) * 3;
32
+
33
+ const WEEKDAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as const;
34
+ const MONTH_NAMES = [
35
+ 'Jan',
36
+ 'Feb',
37
+ 'Mar',
38
+ 'Apr',
39
+ 'May',
40
+ 'Jun',
41
+ 'Jul',
42
+ 'Aug',
43
+ 'Sep',
44
+ 'Oct',
45
+ 'Nov',
46
+ 'Dec',
47
+ ] as const;
48
+
49
+ /** The complete RFC 5322 message: headers, blank line, multipart/alternative body. CRLF throughout. */
50
+ export function buildMimeMessage(message: MailMessage, options: MimeOptions): string {
51
+ const headerLines: string[] = [];
52
+ // Every header goes through one gate, so the CR/LF injection check cannot be forgotten by
53
+ // whichever header is added next. The check runs on the RAW value: encoding first would let a
54
+ // non-ASCII subject hide a line break inside an encoded word instead of refusing it, so the
55
+ // same input would be accepted or rejected depending on whether it happened to be ASCII.
56
+ const header = (name: string, value: string, encode = false): void => {
57
+ if (value.includes('\r') || value.includes('\n')) throw headerInvalid(name, message.mailId);
58
+ headerLines.push(foldHeaderLine(name, encode ? encodeHeaderValue(value) : value));
59
+ };
60
+
61
+ header('From', options.from);
62
+ header('To', message.to.join(', '));
63
+ if (message.cc !== undefined && message.cc.length > 0) header('Cc', message.cc.join(', '));
64
+ header('Subject', message.subject, true);
65
+ header('Date', rfc5322Date(options.date));
66
+ header('Message-ID', options.messageId);
67
+ header('MIME-Version', '1.0');
68
+ // RFC 8058 one-click unsubscribe and friends: computed once in driver.ts, emitted verbatim
69
+ // here so there is exactly one place that decides which of these headers a message gets.
70
+ for (const [name, value] of Object.entries(messageHeaders(message))) header(name, value);
71
+ header('Content-Type', `multipart/alternative; boundary="${options.boundary}"`);
72
+
73
+ return `${headerLines.join(CRLF)}${CRLF}${CRLF}${buildBody(message, options.boundary)}`;
74
+ }
75
+
76
+ /** multipart/alternative: text first, html second — clients render the last part they understand. */
77
+ function buildBody(message: MailMessage, boundary: string): string {
78
+ return (
79
+ `--${boundary}${CRLF}` +
80
+ bodyPart('text/plain', message.text) +
81
+ `--${boundary}${CRLF}` +
82
+ bodyPart('text/html', message.html) +
83
+ `--${boundary}--${CRLF}`
84
+ );
85
+ }
86
+
87
+ function bodyPart(contentType: string, text: string): string {
88
+ return (
89
+ `Content-Type: ${contentType}; charset=utf-8${CRLF}` +
90
+ `Content-Transfer-Encoding: quoted-printable${CRLF}` +
91
+ CRLF +
92
+ `${quotedPrintable(text)}${CRLF}`
93
+ );
94
+ }
95
+
96
+ /** `Postly <no-reply@postly.test>` -> `no-reply@postly.test`. A bare address is returned as-is. */
97
+ export function addressSpec(address: string): string {
98
+ const match = /<([^<>]+)>\s*$/.exec(address);
99
+ return match?.[1] ?? address;
100
+ }
101
+
102
+ /** The domain half of an address spec, for building a `Message-ID`. */
103
+ export function addressDomain(address: string): string {
104
+ const spec = addressSpec(address);
105
+ const at = spec.lastIndexOf('@');
106
+ return at === -1 ? spec : spec.slice(at + 1);
107
+ }
108
+
109
+ /** RFC 2047 `=?UTF-8?B?…?=` when the value is not pure ASCII, otherwise the value unchanged. */
110
+ export function encodeHeaderValue(value: string): string {
111
+ if (isPureAscii(value)) return value;
112
+ // Encoded-words are joined by a plain space, not a hard fold: RFC 2047 says any linear
113
+ // whitespace between adjacent encoded-words is discarded on decode, so `foldHeaderLine`
114
+ // is free to turn that space into a CRLF continuation later without changing the meaning.
115
+ return utf8Chunks(value)
116
+ .map((chunk) => `${ENCODED_WORD_PREFIX}${base64Utf8(chunk)}${ENCODED_WORD_SUFFIX}`)
117
+ .join(' ');
118
+ }
119
+
120
+ /** Splits `text` on whole code points so no chunk's UTF-8 form exceeds `MAX_CHUNK_BYTES`. */
121
+ function utf8Chunks(text: string): string[] {
122
+ const encoder = new TextEncoder();
123
+ const chunks: string[] = [];
124
+ let chunk = '';
125
+ let chunkBytes = 0;
126
+ for (const char of text) {
127
+ const charBytes = encoder.encode(char).length;
128
+ if (chunk !== '' && chunkBytes + charBytes > MAX_CHUNK_BYTES) {
129
+ chunks.push(chunk);
130
+ chunk = '';
131
+ chunkBytes = 0;
132
+ }
133
+ chunk += char;
134
+ chunkBytes += charBytes;
135
+ }
136
+ if (chunk !== '') chunks.push(chunk);
137
+ return chunks;
138
+ }
139
+
140
+ // A `\xNN` regex range trips Biome's control-character lint, so ASCII-ness is a byte scan.
141
+ function isPureAscii(value: string): boolean {
142
+ for (let index = 0; index < value.length; index += 1) {
143
+ if (value.charCodeAt(index) > 0x7f) return false;
144
+ }
145
+ return true;
146
+ }
147
+
148
+ /**
149
+ * `Name: value` folded to <= 78 chars per line with CRLF + single space continuations. The value
150
+ * is copied through, never re-spaced: unfolding restores exactly one space per fold point, so a
151
+ * subject that legitimately holds a run of spaces survives the round trip. A value with no fold
152
+ * point (one very long URL) stays over-long rather than being broken mid-token.
153
+ */
154
+ export function foldHeaderLine(name: string, value: string): string {
155
+ const lines: string[] = [];
156
+ let remainder = `${name}: ${value}`;
157
+ // The space after the colon is not a fold point, and neither is a continuation's own leading
158
+ // space: folding there would emit a line holding nothing but the field name.
159
+ let earliest = name.length + 1;
160
+ while (remainder.length > MAX_FOLD_LINE) {
161
+ const at = remainder.lastIndexOf(' ', MAX_FOLD_LINE);
162
+ if (at <= earliest) break;
163
+ lines.push(remainder.slice(0, at));
164
+ remainder = ` ${remainder.slice(at + 1)}`;
165
+ earliest = 0;
166
+ }
167
+ lines.push(remainder);
168
+ return lines.join(CRLF);
169
+ }
170
+
171
+ /** RFC 2045 quoted-printable, CRLF line endings, soft breaks at 76 chars. */
172
+ export function quotedPrintable(text: string): string {
173
+ // Collapse any line-ending style down to `\n` first, then re-expand to `\r\n` — the only
174
+ // way to guarantee a bare `\n` becomes `\r\n` without ever doubling an existing `\r\n`.
175
+ return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n').map(encodeQpLine).join(CRLF);
176
+ }
177
+
178
+ function encodeQpLine(line: string): string {
179
+ const tokens = [...new TextEncoder().encode(line)].map((byte) =>
180
+ isQpLiteral(byte) ? String.fromCharCode(byte) : qpEscape(byte),
181
+ );
182
+ protectTrailingWhitespace(tokens);
183
+ return wrapQpTokens(tokens);
184
+ }
185
+
186
+ function isQpLiteral(byte: number): boolean {
187
+ if (byte === 0x09 || byte === 0x20) return true; // tab, space — protected below if trailing
188
+ return byte >= 33 && byte <= 126 && byte !== 0x3d; // printable ASCII, '=' excluded
189
+ }
190
+
191
+ function qpEscape(byte: number): string {
192
+ return `=${byte.toString(16).toUpperCase().padStart(2, '0')}`;
193
+ }
194
+
195
+ /** A trailing run of literal space/tab risks silent loss in transit; escape all of it. */
196
+ function protectTrailingWhitespace(tokens: string[]): void {
197
+ for (let index = tokens.length - 1; index >= 0; index -= 1) {
198
+ const token = tokens[index];
199
+ if (token !== ' ' && token !== '\t') break;
200
+ tokens[index] = qpEscape(token.charCodeAt(0));
201
+ }
202
+ }
203
+
204
+ /** Greedily packs tokens (never split) into <= 76-char lines, reserving one column for `=`. */
205
+ function wrapQpTokens(tokens: string[]): string {
206
+ const lines: string[] = [];
207
+ let line = '';
208
+ for (const token of tokens) {
209
+ if (line.length + token.length > MAX_QP_LINE - 1) {
210
+ lines.push(`${line}=`);
211
+ line = '';
212
+ }
213
+ line += token;
214
+ }
215
+ lines.push(line);
216
+ return lines.join(CRLF);
217
+ }
218
+
219
+ /** `Tue, 09 Aug 2026 12:34:56 +0000` — always UTC, and the zone is stated, never ambient. */
220
+ export function rfc5322Date(at: Date): string {
221
+ const zoned = toZoned(instant(at), UTC);
222
+ // weekday is ISO 1-7 and month is 1-12 by ZonedDateTime's own contract, so these never miss;
223
+ // the fallback exists only to satisfy noUncheckedIndexedAccess.
224
+ const weekday = WEEKDAY_NAMES[zoned.weekday - 1] ?? 'Mon';
225
+ const month = MONTH_NAMES[zoned.month - 1] ?? 'Jan';
226
+ const date = `${pad2(zoned.day)} ${month} ${zoned.year}`;
227
+ const time = `${pad2(zoned.hour)}:${pad2(zoned.minute)}:${pad2(zoned.second)}`;
228
+ return `${weekday}, ${date} ${time} +0000`;
229
+ }
230
+
231
+ function pad2(value: number): string {
232
+ return String(value).padStart(2, '0');
233
+ }
@@ -0,0 +1,26 @@
1
+ import { type Translator } from '@ultimat3/i18n';
2
+ import type { MailBlock, MailTemplate } from './blocks';
3
+ /** What the renderer needs from a mail. `MailDefinition` adds the input schema on top. */
4
+ export interface RenderableMail<I> {
5
+ readonly id: string;
6
+ readonly subject: string;
7
+ readonly template: MailTemplate<I>;
8
+ readonly layout: string;
9
+ }
10
+ export interface RenderOptions {
11
+ readonly locale: string;
12
+ readonly tz: string;
13
+ readonly unsubscribeUrl?: string | undefined;
14
+ }
15
+ export interface RenderedMail {
16
+ readonly subject: string;
17
+ readonly preheader: string;
18
+ readonly html: string;
19
+ readonly text: string;
20
+ }
21
+ export declare const FOOTER_KEYS: readonly ['mail.footer.legal', 'mail.footer.help'];
22
+ export declare const UNSUBSCRIBE_KEY = "mail.footer.unsubscribe";
23
+ export declare function renderMail<I>(mail: RenderableMail<I>, data: I, options: RenderOptions): RenderedMail;
24
+ /** The text part, block by block. Never derived from the HTML. */
25
+ export declare function textOf(list: readonly MailBlock[], t: Translator): string;
26
+ //# sourceMappingURL=render.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render.d.ts","sourceRoot":"","sources":["render.ts"],"names":[],"mappings":"AAKA,OAAO,EAAmC,KAAK,UAAU,EAAiB,MAAM,gBAAgB,CAAC;AACjG,OAAO,KAAK,EAAe,SAAS,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAKrE,0FAA0F;AAC1F,MAAM,WAAW,cAAc,CAAC,CAAC;IAC/B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAC,CAAC,CAAC,CAAC;IACnC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC9C;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED,eAAO,MAAM,WAAW,YAAI,mBAAmB,EAAE,kBAAkB,CAAU,CAAC;AAC9E,eAAO,MAAM,eAAe,4BAA4B,CAAC;AAEzD,wBAAgB,UAAU,CAAC,CAAC,EAC1B,IAAI,EAAE,cAAc,CAAC,CAAC,CAAC,EACvB,IAAI,EAAE,CAAC,EACP,OAAO,EAAE,aAAa,GACrB,YAAY,CAmCd;AAED,kEAAkE;AAClE,wBAAgB,MAAM,CAAC,IAAI,EAAE,SAAS,SAAS,EAAE,EAAE,CAAC,EAAE,UAAU,GAAG,MAAM,CAoBxE"}
package/src/render.js ADDED
@@ -0,0 +1,149 @@
1
+ // Single responsibility: turn a template's block list into BOTH an HTML part and a plain-text
2
+ // part. One source of truth per mail — the text is derived from the blocks, never scraped back
3
+ // out of the HTML, so it never rots. Styling is inlined from the layout's tokens because most
4
+ // clients drop <style>; the dark-mode block is the one exception, for clients that honour it.
5
+ import { directionOf, translatorFor } from '@ultimat3/i18n';
6
+ import { layoutUnknown, textMissing } from './errors';
7
+ import { escapeHtml, safeUrl, styleAttr } from './html';
8
+ import { layoutFor, registeredLayouts, token } from './layout';
9
+ export const FOOTER_KEYS = ['mail.footer.legal', 'mail.footer.help'];
10
+ export const UNSUBSCRIBE_KEY = 'mail.footer.unsubscribe';
11
+ export function renderMail(mail, data, options) {
12
+ const layout = layoutFor(mail.layout);
13
+ if (layout === undefined)
14
+ throw layoutUnknown(mail.id, mail.layout, registeredLayouts());
15
+ const t = translatorFor(options.locale);
16
+ const vars = toVars(data);
17
+ const subject = t(mail.subject, vars);
18
+ const preheaderKey = `mail.${mail.id}.preheader`;
19
+ const preheader = t.has(preheaderKey) ? t(preheaderKey, vars) : subject;
20
+ const list = mail.template({ data, t, locale: options.locale, tz: options.tz });
21
+ const bodyText = textOf(list, t);
22
+ if (bodyText.trim() === '')
23
+ throw textMissing(mail.id);
24
+ const footer = FOOTER_KEYS.filter((key) => t.has(key)).map((key) => t(key, vars));
25
+ const unsubscribe = options.unsubscribeUrl === undefined
26
+ ? undefined
27
+ : { label: t(UNSUBSCRIBE_KEY), url: options.unsubscribeUrl };
28
+ const html = layout({
29
+ subject,
30
+ preheader,
31
+ content: list.map((block) => htmlOf(block, t)).join(''),
32
+ footer,
33
+ unsubscribe,
34
+ locale: options.locale,
35
+ direction: directionOf(options.locale),
36
+ });
37
+ const tail = [...footer];
38
+ if (unsubscribe !== undefined)
39
+ tail.push(`${unsubscribe.label}: ${safeUrl(unsubscribe.url)}`);
40
+ const text = tail.length === 0 ? bodyText : `${bodyText}\n\n--\n${tail.join('\n')}`;
41
+ return { subject, preheader, html, text };
42
+ }
43
+ /** The text part, block by block. Never derived from the HTML. */
44
+ export function textOf(list, t) {
45
+ const lines = [];
46
+ for (const block of list) {
47
+ switch (block.kind) {
48
+ case 'heading':
49
+ case 'paragraph':
50
+ case 'callout':
51
+ lines.push(t(block.key, block.vars));
52
+ break;
53
+ case 'button':
54
+ lines.push(`${t(block.key, block.vars)}: ${safeUrl(block.href)}`);
55
+ break;
56
+ case 'detail':
57
+ lines.push(`${t(block.key)}: ${block.value}`);
58
+ break;
59
+ case 'divider':
60
+ break;
61
+ }
62
+ }
63
+ return lines.join('\n\n');
64
+ }
65
+ const HEADING_STYLE = styleAttr([
66
+ 'margin:0 0 16px 0',
67
+ 'font-size:24px',
68
+ 'line-height:32px',
69
+ 'font-weight:600',
70
+ `color:${token('textPrimary')}`,
71
+ ]);
72
+ const PARAGRAPH_STYLE = styleAttr([
73
+ 'margin:0 0 16px 0',
74
+ 'font-size:16px',
75
+ 'line-height:24px',
76
+ `color:${token('textPrimary')}`,
77
+ ]);
78
+ const DETAIL_LABEL_STYLE = styleAttr([`color:${token('textMuted')}`, 'font-size:14px']);
79
+ const BUTTON_LINK_STYLE = styleAttr([
80
+ 'display:inline-block',
81
+ 'padding:12px 22px',
82
+ 'font-size:16px',
83
+ 'font-weight:600',
84
+ 'text-decoration:none',
85
+ `color:${token('accentText')}`,
86
+ `background-color:${token('accentBg')}`,
87
+ 'border-radius:8px',
88
+ ]);
89
+ const DIVIDER_STYLE = styleAttr([
90
+ `border-top:1px solid ${token('borderSubtle')}`,
91
+ 'font-size:0',
92
+ 'line-height:0',
93
+ 'height:1px',
94
+ 'padding:8px 0 0 0',
95
+ ]);
96
+ const TABLE_ATTRS = 'role="presentation" cellpadding="0" cellspacing="0" border="0"';
97
+ function calloutStyle(tone) {
98
+ const bg = tone === 'danger' ? token('calloutDangerBg') : token('calloutInfoBg');
99
+ const fg = tone === 'danger' ? token('calloutDangerText') : token('calloutInfoText');
100
+ return styleAttr([
101
+ `background-color:${bg}`,
102
+ `color:${fg}`,
103
+ 'padding:16px',
104
+ 'border-radius:8px',
105
+ 'font-size:15px',
106
+ 'line-height:22px',
107
+ ]);
108
+ }
109
+ function htmlOf(block, t) {
110
+ switch (block.kind) {
111
+ case 'heading':
112
+ return `<h1 data-x="h" ${HEADING_STYLE}>${escapeHtml(t(block.key, block.vars))}</h1>`;
113
+ case 'paragraph':
114
+ return `<p data-x="p" ${PARAGRAPH_STYLE}>${escapeHtml(t(block.key, block.vars))}</p>`;
115
+ case 'button': {
116
+ const href = escapeHtml(safeUrl(block.href));
117
+ const label = escapeHtml(t(block.key, block.vars));
118
+ const anchor = `<a data-x="btn" href="${href}" ${BUTTON_LINK_STYLE}>${label}</a>`;
119
+ return `<table ${TABLE_ATTRS}><tr><td>${anchor}</td></tr></table>`;
120
+ }
121
+ case 'callout': {
122
+ const role = block.tone === 'danger' ? 'cd' : 'ci';
123
+ const body = escapeHtml(t(block.key, block.vars));
124
+ const cell = `<td data-x="${role}" ${calloutStyle(block.tone)}>${body}</td>`;
125
+ return `<table ${TABLE_ATTRS} width="100%"><tr>${cell}</tr></table>`;
126
+ }
127
+ case 'detail': {
128
+ const label = `<span data-x="mut" ${DETAIL_LABEL_STYLE}>${escapeHtml(t(block.key))}</span>`;
129
+ return `<p data-x="p" ${PARAGRAPH_STYLE}>${label} ${escapeHtml(block.value)}</p>`;
130
+ }
131
+ case 'divider': {
132
+ const cell = `<td data-x="hr" ${DIVIDER_STYLE}></td>`;
133
+ return `<table ${TABLE_ATTRS} width="100%"><tr>${cell}</tr></table>`;
134
+ }
135
+ }
136
+ }
137
+ /** Scalar fields of the payload become interpolation vars; Dates and objects never do. */
138
+ function toVars(data) {
139
+ const vars = {};
140
+ if (typeof data !== 'object' || data === null)
141
+ return vars;
142
+ for (const [key, value] of Object.entries(data)) {
143
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
144
+ vars[key] = value;
145
+ }
146
+ }
147
+ return vars;
148
+ }
149
+ //# sourceMappingURL=render.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render.js","sourceRoot":"","sources":["render.ts"],"names":[],"mappings":"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"}
package/src/render.ts ADDED
@@ -0,0 +1,190 @@
1
+ // Single responsibility: turn a template's block list into BOTH an HTML part and a plain-text
2
+ // part. One source of truth per mail — the text is derived from the blocks, never scraped back
3
+ // out of the HTML, so it never rots. Styling is inlined from the layout's tokens because most
4
+ // clients drop <style>; the dark-mode block is the one exception, for clients that honour it.
5
+
6
+ import { directionOf, type TranslateVars, type Translator, translatorFor } from '@ultimat3/i18n';
7
+ import type { CalloutTone, MailBlock, MailTemplate } from './blocks';
8
+ import { layoutUnknown, textMissing } from './errors';
9
+ import { escapeHtml, safeUrl, styleAttr } from './html';
10
+ import { layoutFor, registeredLayouts, token } from './layout';
11
+
12
+ /** What the renderer needs from a mail. `MailDefinition` adds the input schema on top. */
13
+ export interface RenderableMail<I> {
14
+ readonly id: string;
15
+ readonly subject: string;
16
+ readonly template: MailTemplate<I>;
17
+ readonly layout: string;
18
+ }
19
+
20
+ export interface RenderOptions {
21
+ readonly locale: string;
22
+ readonly tz: string;
23
+ readonly unsubscribeUrl?: string | undefined;
24
+ }
25
+
26
+ export interface RenderedMail {
27
+ readonly subject: string;
28
+ readonly preheader: string;
29
+ readonly html: string;
30
+ readonly text: string;
31
+ }
32
+
33
+ export const FOOTER_KEYS = ['mail.footer.legal', 'mail.footer.help'] as const;
34
+ export const UNSUBSCRIBE_KEY = 'mail.footer.unsubscribe';
35
+
36
+ export function renderMail<I>(
37
+ mail: RenderableMail<I>,
38
+ data: I,
39
+ options: RenderOptions,
40
+ ): RenderedMail {
41
+ const layout = layoutFor(mail.layout);
42
+ if (layout === undefined) throw layoutUnknown(mail.id, mail.layout, registeredLayouts());
43
+
44
+ const t = translatorFor(options.locale);
45
+ const vars = toVars(data);
46
+ const subject = t(mail.subject, vars);
47
+ const preheaderKey = `mail.${mail.id}.preheader`;
48
+ const preheader = t.has(preheaderKey) ? t(preheaderKey, vars) : subject;
49
+
50
+ const list = mail.template({ data, t, locale: options.locale, tz: options.tz });
51
+ const bodyText = textOf(list, t);
52
+ if (bodyText.trim() === '') throw textMissing(mail.id);
53
+
54
+ const footer = FOOTER_KEYS.filter((key) => t.has(key)).map((key) => t(key, vars));
55
+ const unsubscribe =
56
+ options.unsubscribeUrl === undefined
57
+ ? undefined
58
+ : { label: t(UNSUBSCRIBE_KEY), url: options.unsubscribeUrl };
59
+
60
+ const html = layout({
61
+ subject,
62
+ preheader,
63
+ content: list.map((block) => htmlOf(block, t)).join(''),
64
+ footer,
65
+ unsubscribe,
66
+ locale: options.locale,
67
+ direction: directionOf(options.locale),
68
+ });
69
+
70
+ const tail = [...footer];
71
+ if (unsubscribe !== undefined) tail.push(`${unsubscribe.label}: ${safeUrl(unsubscribe.url)}`);
72
+ const text = tail.length === 0 ? bodyText : `${bodyText}\n\n--\n${tail.join('\n')}`;
73
+
74
+ return { subject, preheader, html, text };
75
+ }
76
+
77
+ /** The text part, block by block. Never derived from the HTML. */
78
+ export function textOf(list: readonly MailBlock[], t: Translator): string {
79
+ const lines: string[] = [];
80
+ for (const block of list) {
81
+ switch (block.kind) {
82
+ case 'heading':
83
+ case 'paragraph':
84
+ case 'callout':
85
+ lines.push(t(block.key, block.vars));
86
+ break;
87
+ case 'button':
88
+ lines.push(`${t(block.key, block.vars)}: ${safeUrl(block.href)}`);
89
+ break;
90
+ case 'detail':
91
+ lines.push(`${t(block.key)}: ${block.value}`);
92
+ break;
93
+ case 'divider':
94
+ break;
95
+ }
96
+ }
97
+ return lines.join('\n\n');
98
+ }
99
+
100
+ const HEADING_STYLE = styleAttr([
101
+ 'margin:0 0 16px 0',
102
+ 'font-size:24px',
103
+ 'line-height:32px',
104
+ 'font-weight:600',
105
+ `color:${token('textPrimary')}`,
106
+ ]);
107
+
108
+ const PARAGRAPH_STYLE = styleAttr([
109
+ 'margin:0 0 16px 0',
110
+ 'font-size:16px',
111
+ 'line-height:24px',
112
+ `color:${token('textPrimary')}`,
113
+ ]);
114
+
115
+ const DETAIL_LABEL_STYLE = styleAttr([`color:${token('textMuted')}`, 'font-size:14px']);
116
+
117
+ const BUTTON_LINK_STYLE = styleAttr([
118
+ 'display:inline-block',
119
+ 'padding:12px 22px',
120
+ 'font-size:16px',
121
+ 'font-weight:600',
122
+ 'text-decoration:none',
123
+ `color:${token('accentText')}`,
124
+ `background-color:${token('accentBg')}`,
125
+ 'border-radius:8px',
126
+ ]);
127
+
128
+ const DIVIDER_STYLE = styleAttr([
129
+ `border-top:1px solid ${token('borderSubtle')}`,
130
+ 'font-size:0',
131
+ 'line-height:0',
132
+ 'height:1px',
133
+ 'padding:8px 0 0 0',
134
+ ]);
135
+
136
+ const TABLE_ATTRS = 'role="presentation" cellpadding="0" cellspacing="0" border="0"';
137
+
138
+ function calloutStyle(tone: CalloutTone): string {
139
+ const bg = tone === 'danger' ? token('calloutDangerBg') : token('calloutInfoBg');
140
+ const fg = tone === 'danger' ? token('calloutDangerText') : token('calloutInfoText');
141
+ return styleAttr([
142
+ `background-color:${bg}`,
143
+ `color:${fg}`,
144
+ 'padding:16px',
145
+ 'border-radius:8px',
146
+ 'font-size:15px',
147
+ 'line-height:22px',
148
+ ]);
149
+ }
150
+
151
+ function htmlOf(block: MailBlock, t: Translator): string {
152
+ switch (block.kind) {
153
+ case 'heading':
154
+ return `<h1 data-x="h" ${HEADING_STYLE}>${escapeHtml(t(block.key, block.vars))}</h1>`;
155
+ case 'paragraph':
156
+ return `<p data-x="p" ${PARAGRAPH_STYLE}>${escapeHtml(t(block.key, block.vars))}</p>`;
157
+ case 'button': {
158
+ const href = escapeHtml(safeUrl(block.href));
159
+ const label = escapeHtml(t(block.key, block.vars));
160
+ const anchor = `<a data-x="btn" href="${href}" ${BUTTON_LINK_STYLE}>${label}</a>`;
161
+ return `<table ${TABLE_ATTRS}><tr><td>${anchor}</td></tr></table>`;
162
+ }
163
+ case 'callout': {
164
+ const role = block.tone === 'danger' ? 'cd' : 'ci';
165
+ const body = escapeHtml(t(block.key, block.vars));
166
+ const cell = `<td data-x="${role}" ${calloutStyle(block.tone)}>${body}</td>`;
167
+ return `<table ${TABLE_ATTRS} width="100%"><tr>${cell}</tr></table>`;
168
+ }
169
+ case 'detail': {
170
+ const label = `<span data-x="mut" ${DETAIL_LABEL_STYLE}>${escapeHtml(t(block.key))}</span>`;
171
+ return `<p data-x="p" ${PARAGRAPH_STYLE}>${label} ${escapeHtml(block.value)}</p>`;
172
+ }
173
+ case 'divider': {
174
+ const cell = `<td data-x="hr" ${DIVIDER_STYLE}></td>`;
175
+ return `<table ${TABLE_ATTRS} width="100%"><tr>${cell}</tr></table>`;
176
+ }
177
+ }
178
+ }
179
+
180
+ /** Scalar fields of the payload become interpolation vars; Dates and objects never do. */
181
+ function toVars(data: unknown): TranslateVars {
182
+ const vars: TranslateVars = {};
183
+ if (typeof data !== 'object' || data === null) return vars;
184
+ for (const [key, value] of Object.entries(data)) {
185
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
186
+ vars[key] = value;
187
+ }
188
+ }
189
+ return vars;
190
+ }