@tinvar/mail 0.0.0-stage → 0.4.1
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 +21 -0
- package/package.json +30 -4
- package/src/components.js +803 -0
- package/src/context.js +60 -0
- package/src/index.d.ts +127 -0
- package/src/index.js +31 -0
- package/src/policy.js +317 -0
- package/src/preview.d.ts +21 -0
- package/src/preview.js +171 -0
- package/src/render.d.ts +21 -0
- package/src/render.js +163 -0
- package/README.md +0 -3
|
@@ -0,0 +1,803 @@
|
|
|
1
|
+
// The email kit: components a template is built from, each drawn twice --
|
|
2
|
+
// once as the HTML a mail client lays out, once as the plain-text part --
|
|
3
|
+
// by renderEmail().
|
|
4
|
+
//
|
|
5
|
+
// The HTML is what mail clients read reliably (MER-36.1): inline styles
|
|
6
|
+
// only, layout in presentation tables, no script, no stylesheet, no web
|
|
7
|
+
// font fetched, no image but those the policy allows. The text part is each
|
|
8
|
+
// component's own plain form -- a heading and a blank line, a link as its
|
|
9
|
+
// words and its URL -- so every link the HTML has is written out in the text
|
|
10
|
+
// too (MER-36.2), which PHP checks before it sends (RenderedMail).
|
|
11
|
+
//
|
|
12
|
+
// Every string reaches the markup through React, which escapes it; every
|
|
13
|
+
// href and src through the policy (policy.js). Nothing here holds state
|
|
14
|
+
// between renders.
|
|
15
|
+
|
|
16
|
+
import { createElement as h, Fragment } from 'react';
|
|
17
|
+
import { ThemeContext, tones, useMailRender, useTheme } from './context.js';
|
|
18
|
+
import {
|
|
19
|
+
DEFAULT_DARK_THEME,
|
|
20
|
+
MailPolicyError,
|
|
21
|
+
safeHref,
|
|
22
|
+
safeImageSrc,
|
|
23
|
+
safeTrackerSrc,
|
|
24
|
+
themeOf,
|
|
25
|
+
} from './policy.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Padding after the preview text, so a client's inbox line shows the preview
|
|
29
|
+
* and nothing of the body after it: zero-width non-joiners and non-breaking
|
|
30
|
+
* spaces, which clients do not collapse.
|
|
31
|
+
*/
|
|
32
|
+
const PREVIEW_PADDING = '\u200c\u00a0'.repeat(90);
|
|
33
|
+
|
|
34
|
+
/** A table that is only layout: no borders, no spacing, read as nothing by screen readers. */
|
|
35
|
+
const presentation = { role: 'presentation', width: '100%', cellPadding: 0, cellSpacing: 0, border: 0 };
|
|
36
|
+
|
|
37
|
+
/** Plain text for this node, when it is plain text -- a string or number -- and null otherwise. */
|
|
38
|
+
function plain(node) {
|
|
39
|
+
if (typeof node === 'string') return node;
|
|
40
|
+
if (typeof node === 'number' || typeof node === 'bigint') return String(node);
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A URL as the text part writes it: a mailto: link as its address alone. */
|
|
45
|
+
function spelled(url) {
|
|
46
|
+
return url.replace(/^mailto:/i, '');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* An <img>. Marked low priority, which mail clients ignore, because React
|
|
51
|
+
* otherwise writes a <link rel="preload"> for it into the markup -- an
|
|
52
|
+
* element no email should hold, and which PHP refuses (SafeMailHtml).
|
|
53
|
+
*/
|
|
54
|
+
function img(props) {
|
|
55
|
+
return h('img', { ...props, fetchPriority: 'low' });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A block of the text part: its content, then a blank line. */
|
|
59
|
+
function block(...children) {
|
|
60
|
+
return h(Fragment, null, ...children, '\n\n');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The whole message: the hidden preview line, a header with the brand, the
|
|
65
|
+
* body on a card, and a footer saying why the reader got it, how to stop,
|
|
66
|
+
* and the sender's postal address (MER-36.3).
|
|
67
|
+
*
|
|
68
|
+
* `theme` is the tenant's branding, given as props for this message alone;
|
|
69
|
+
* every component under the layout reads it from the render's context.
|
|
70
|
+
* `dark` -- true for Meridian's dark palette, or colours over it -- is what
|
|
71
|
+
* a client that prefers dark draws instead (ADR 0116); without it the
|
|
72
|
+
* message is light only, as the head's color-scheme then says.
|
|
73
|
+
*
|
|
74
|
+
* `unsubscribeLabel` is the words of the footer's link: a transactional
|
|
75
|
+
* message, which no reader unsubscribes from, links to their preferences
|
|
76
|
+
* instead, and says so.
|
|
77
|
+
*/
|
|
78
|
+
export function Layout({
|
|
79
|
+
children,
|
|
80
|
+
preview,
|
|
81
|
+
brand,
|
|
82
|
+
logoUrl,
|
|
83
|
+
reason,
|
|
84
|
+
unsubscribeUrl,
|
|
85
|
+
unsubscribeLabel = 'Unsubscribe from these emails',
|
|
86
|
+
address,
|
|
87
|
+
theme,
|
|
88
|
+
dark,
|
|
89
|
+
lang = 'en',
|
|
90
|
+
title,
|
|
91
|
+
}) {
|
|
92
|
+
const render = useMailRender();
|
|
93
|
+
const colors = themeOf(theme);
|
|
94
|
+
if (dark !== undefined && dark !== null && dark !== false) {
|
|
95
|
+
render.document.dark = themeOf(dark === true ? undefined : dark, DEFAULT_DARK_THEME);
|
|
96
|
+
}
|
|
97
|
+
if (typeof unsubscribeLabel !== 'string' || unsubscribeLabel.trim() === '') {
|
|
98
|
+
throw new MailPolicyError("The footer's unsubscribe link needs words.");
|
|
99
|
+
}
|
|
100
|
+
if (typeof brand !== 'string' || brand.trim() === '') {
|
|
101
|
+
throw new MailPolicyError('An email layout needs the brand it is from.');
|
|
102
|
+
}
|
|
103
|
+
if (typeof lang !== 'string' || !/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(lang)) {
|
|
104
|
+
throw new MailPolicyError(`'${String(lang)}' is not a language tag.`);
|
|
105
|
+
}
|
|
106
|
+
render.document.lang = lang;
|
|
107
|
+
render.document.title = typeof title === 'string' ? title : brand;
|
|
108
|
+
render.document.background = colors.background;
|
|
109
|
+
|
|
110
|
+
const unsubscribe = unsubscribeUrl === undefined || unsubscribeUrl === null ? null : unsubscribeUrl;
|
|
111
|
+
|
|
112
|
+
if (render.text) {
|
|
113
|
+
return h(
|
|
114
|
+
ThemeContext.Provider,
|
|
115
|
+
{ value: colors },
|
|
116
|
+
block(brand),
|
|
117
|
+
children,
|
|
118
|
+
'\n---\n\n',
|
|
119
|
+
reason ? block('Why am I getting this? ', reason) : null,
|
|
120
|
+
unsubscribe === null
|
|
121
|
+
? null
|
|
122
|
+
: block(unsubscribeLabel, ': ', spelled(safeHref(unsubscribe, render.policy))),
|
|
123
|
+
address ? block(address) : null,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const footerText = { color: colors.muted, fontSize: '12px', lineHeight: '18px', margin: '0 0 8px' };
|
|
128
|
+
|
|
129
|
+
return h(
|
|
130
|
+
ThemeContext.Provider,
|
|
131
|
+
{ value: colors },
|
|
132
|
+
preview
|
|
133
|
+
? h(
|
|
134
|
+
'div',
|
|
135
|
+
{
|
|
136
|
+
style: {
|
|
137
|
+
display: 'none',
|
|
138
|
+
overflow: 'hidden',
|
|
139
|
+
lineHeight: '1px',
|
|
140
|
+
opacity: 0,
|
|
141
|
+
maxHeight: 0,
|
|
142
|
+
maxWidth: 0,
|
|
143
|
+
msoHide: 'all',
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
preview,
|
|
147
|
+
PREVIEW_PADDING,
|
|
148
|
+
)
|
|
149
|
+
: null,
|
|
150
|
+
h(
|
|
151
|
+
'table',
|
|
152
|
+
{
|
|
153
|
+
...presentation,
|
|
154
|
+
className: tones(render, 'bg'),
|
|
155
|
+
style: { backgroundColor: colors.background, fontFamily: colors.font },
|
|
156
|
+
},
|
|
157
|
+
h(
|
|
158
|
+
'tbody',
|
|
159
|
+
null,
|
|
160
|
+
h(
|
|
161
|
+
'tr',
|
|
162
|
+
null,
|
|
163
|
+
h(
|
|
164
|
+
'td',
|
|
165
|
+
{ align: 'center', style: { padding: '32px 16px' } },
|
|
166
|
+
h(
|
|
167
|
+
'table',
|
|
168
|
+
{ ...presentation, style: { maxWidth: '560px', margin: '0 auto' } },
|
|
169
|
+
h(
|
|
170
|
+
'tbody',
|
|
171
|
+
null,
|
|
172
|
+
h(
|
|
173
|
+
'tr',
|
|
174
|
+
null,
|
|
175
|
+
h(
|
|
176
|
+
'td',
|
|
177
|
+
{
|
|
178
|
+
className: tones(render, 'text'),
|
|
179
|
+
style: {
|
|
180
|
+
padding: '0 4px 16px',
|
|
181
|
+
color: colors.text,
|
|
182
|
+
fontSize: '15px',
|
|
183
|
+
fontWeight: 600,
|
|
184
|
+
letterSpacing: '-0.02em',
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
logoUrl
|
|
188
|
+
? img({
|
|
189
|
+
src: safeImageSrc(logoUrl, render.policy),
|
|
190
|
+
width: 22,
|
|
191
|
+
height: 22,
|
|
192
|
+
alt: '',
|
|
193
|
+
style: {
|
|
194
|
+
border: 0,
|
|
195
|
+
borderRadius: '6px',
|
|
196
|
+
display: 'inline-block',
|
|
197
|
+
marginRight: '6px',
|
|
198
|
+
verticalAlign: 'middle',
|
|
199
|
+
},
|
|
200
|
+
})
|
|
201
|
+
: null,
|
|
202
|
+
brand,
|
|
203
|
+
),
|
|
204
|
+
),
|
|
205
|
+
h(
|
|
206
|
+
'tr',
|
|
207
|
+
null,
|
|
208
|
+
h(
|
|
209
|
+
'td',
|
|
210
|
+
{
|
|
211
|
+
className: tones(render, 'surface'),
|
|
212
|
+
style: {
|
|
213
|
+
backgroundColor: colors.surface,
|
|
214
|
+
border: `1px solid ${colors.border}`,
|
|
215
|
+
borderRadius: '12px',
|
|
216
|
+
padding: '32px',
|
|
217
|
+
fontFamily: colors.font,
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
children,
|
|
221
|
+
),
|
|
222
|
+
),
|
|
223
|
+
h(
|
|
224
|
+
'tr',
|
|
225
|
+
null,
|
|
226
|
+
h(
|
|
227
|
+
'td',
|
|
228
|
+
{ style: { padding: '20px 4px 0', fontFamily: colors.font } },
|
|
229
|
+
reason
|
|
230
|
+
? h(
|
|
231
|
+
'p',
|
|
232
|
+
{ className: tones(render, 'muted'), style: footerText },
|
|
233
|
+
h('b', null, 'Why am I getting this?'),
|
|
234
|
+
' ',
|
|
235
|
+
reason,
|
|
236
|
+
)
|
|
237
|
+
: null,
|
|
238
|
+
unsubscribe === null
|
|
239
|
+
? null
|
|
240
|
+
: h(
|
|
241
|
+
'p',
|
|
242
|
+
{ className: tones(render, 'muted'), style: footerText },
|
|
243
|
+
h(
|
|
244
|
+
'a',
|
|
245
|
+
{
|
|
246
|
+
href: safeHref(unsubscribe, render.policy),
|
|
247
|
+
className: tones(render, 'muted'),
|
|
248
|
+
style: { color: colors.muted, textDecoration: 'underline' },
|
|
249
|
+
},
|
|
250
|
+
unsubscribeLabel,
|
|
251
|
+
),
|
|
252
|
+
),
|
|
253
|
+
address
|
|
254
|
+
? h('p', { className: tones(render, 'muted'), style: footerText }, address)
|
|
255
|
+
: null,
|
|
256
|
+
),
|
|
257
|
+
),
|
|
258
|
+
),
|
|
259
|
+
),
|
|
260
|
+
),
|
|
261
|
+
),
|
|
262
|
+
),
|
|
263
|
+
),
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const HEADING_SIZES = { 1: ['22px', '28px'], 2: ['18px', '24px'], 3: ['15px', '22px'] };
|
|
268
|
+
|
|
269
|
+
/** A heading, h1 unless `level` says 2 or 3. The first line says what happened (MER-36.4). */
|
|
270
|
+
export function Heading({ children, level = 1 }) {
|
|
271
|
+
const render = useMailRender();
|
|
272
|
+
const theme = useTheme();
|
|
273
|
+
if (level !== 1 && level !== 2 && level !== 3) {
|
|
274
|
+
throw new MailPolicyError(`An email heading is level 1, 2 or 3; got ${String(level)}.`);
|
|
275
|
+
}
|
|
276
|
+
if (render.text) return block(children);
|
|
277
|
+
|
|
278
|
+
const [fontSize, lineHeight] = HEADING_SIZES[level];
|
|
279
|
+
return h(
|
|
280
|
+
`h${level}`,
|
|
281
|
+
{
|
|
282
|
+
className: tones(render, 'text'),
|
|
283
|
+
style: {
|
|
284
|
+
color: theme.text,
|
|
285
|
+
fontFamily: theme.font,
|
|
286
|
+
fontSize,
|
|
287
|
+
fontWeight: 600,
|
|
288
|
+
letterSpacing: '-0.02em',
|
|
289
|
+
lineHeight,
|
|
290
|
+
margin: '0 0 12px',
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
children,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** A paragraph; `muted` for the quieter kind, a note under the action. */
|
|
298
|
+
export function Text({ children, muted = false }) {
|
|
299
|
+
const render = useMailRender();
|
|
300
|
+
const theme = useTheme();
|
|
301
|
+
if (render.text) return block(children);
|
|
302
|
+
|
|
303
|
+
return h(
|
|
304
|
+
'p',
|
|
305
|
+
{
|
|
306
|
+
className: tones(render, muted ? 'muted' : 'secondary'),
|
|
307
|
+
style: muted
|
|
308
|
+
? {
|
|
309
|
+
color: theme.muted,
|
|
310
|
+
fontFamily: theme.font,
|
|
311
|
+
fontSize: '13px',
|
|
312
|
+
lineHeight: '20px',
|
|
313
|
+
margin: '0 0 12px',
|
|
314
|
+
}
|
|
315
|
+
: {
|
|
316
|
+
color: theme.secondary,
|
|
317
|
+
fontFamily: theme.font,
|
|
318
|
+
fontSize: '14px',
|
|
319
|
+
lineHeight: '22px',
|
|
320
|
+
margin: '0 0 12px',
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
children,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* A link in running text. In the text part, its words and then its URL --
|
|
329
|
+
* or the URL alone when the words are the URL.
|
|
330
|
+
*/
|
|
331
|
+
export function Link({ href, children }) {
|
|
332
|
+
const render = useMailRender();
|
|
333
|
+
const theme = useTheme();
|
|
334
|
+
const url = safeHref(href, render.policy);
|
|
335
|
+
const label = children === undefined || children === null ? url : children;
|
|
336
|
+
if (render.text) {
|
|
337
|
+
const words = plain(label);
|
|
338
|
+
return words === url || words === spelled(url)
|
|
339
|
+
? spelled(url)
|
|
340
|
+
: h(Fragment, null, label, ' (', spelled(url), ')');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return h(
|
|
344
|
+
'a',
|
|
345
|
+
{
|
|
346
|
+
href: url,
|
|
347
|
+
className: tones(render, 'accent'),
|
|
348
|
+
style: { color: theme.accent, textDecoration: 'underline' },
|
|
349
|
+
},
|
|
350
|
+
label,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* The message's one action: a button, drawn as a table cell so Outlook
|
|
356
|
+
* draws it too, and -- unless `fallback` is false -- its URL written out
|
|
357
|
+
* beneath it, so the email works where buttons are blocked and in plain
|
|
358
|
+
* text.
|
|
359
|
+
*/
|
|
360
|
+
export function Button({ href, children, fallback = true }) {
|
|
361
|
+
const render = useMailRender();
|
|
362
|
+
const theme = useTheme();
|
|
363
|
+
const url = safeHref(href, render.policy);
|
|
364
|
+
if (render.text) return block(children, ': ', spelled(url));
|
|
365
|
+
|
|
366
|
+
return h(
|
|
367
|
+
'table',
|
|
368
|
+
{ ...presentation, width: undefined, style: { margin: '24px 0 8px' } },
|
|
369
|
+
h(
|
|
370
|
+
'tbody',
|
|
371
|
+
null,
|
|
372
|
+
h(
|
|
373
|
+
'tr',
|
|
374
|
+
null,
|
|
375
|
+
h(
|
|
376
|
+
'td',
|
|
377
|
+
null,
|
|
378
|
+
h(
|
|
379
|
+
'table',
|
|
380
|
+
{ role: 'presentation', cellPadding: 0, cellSpacing: 0, border: 0 },
|
|
381
|
+
h(
|
|
382
|
+
'tbody',
|
|
383
|
+
null,
|
|
384
|
+
h(
|
|
385
|
+
'tr',
|
|
386
|
+
null,
|
|
387
|
+
h(
|
|
388
|
+
'td',
|
|
389
|
+
{
|
|
390
|
+
bgcolor: theme.button,
|
|
391
|
+
className: tones(render, 'button'),
|
|
392
|
+
style: { backgroundColor: theme.button, borderRadius: '8px' },
|
|
393
|
+
},
|
|
394
|
+
h(
|
|
395
|
+
'a',
|
|
396
|
+
{
|
|
397
|
+
href: url,
|
|
398
|
+
className: tones(render, 'button-text'),
|
|
399
|
+
style: {
|
|
400
|
+
color: theme.buttonText,
|
|
401
|
+
display: 'inline-block',
|
|
402
|
+
fontFamily: theme.font,
|
|
403
|
+
fontSize: '14px',
|
|
404
|
+
fontWeight: 600,
|
|
405
|
+
padding: '11px 18px',
|
|
406
|
+
textDecoration: 'none',
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
children,
|
|
410
|
+
),
|
|
411
|
+
),
|
|
412
|
+
),
|
|
413
|
+
),
|
|
414
|
+
),
|
|
415
|
+
fallback
|
|
416
|
+
? h(
|
|
417
|
+
'p',
|
|
418
|
+
{
|
|
419
|
+
className: tones(render, 'muted'),
|
|
420
|
+
style: {
|
|
421
|
+
color: theme.muted,
|
|
422
|
+
fontFamily: theme.font,
|
|
423
|
+
fontSize: '12px',
|
|
424
|
+
lineHeight: '18px',
|
|
425
|
+
margin: '12px 0 0',
|
|
426
|
+
wordBreak: 'break-all',
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
'Or open: ',
|
|
430
|
+
h(
|
|
431
|
+
'a',
|
|
432
|
+
{
|
|
433
|
+
href: url,
|
|
434
|
+
className: tones(render, 'accent'),
|
|
435
|
+
style: { color: theme.accent, textDecoration: 'none' },
|
|
436
|
+
},
|
|
437
|
+
url,
|
|
438
|
+
),
|
|
439
|
+
)
|
|
440
|
+
: null,
|
|
441
|
+
),
|
|
442
|
+
),
|
|
443
|
+
),
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Label and value pairs, an order's lines or an invoice's totals: a table,
|
|
449
|
+
* which Outlook lays out where it would not lay out anything else. In text,
|
|
450
|
+
* one `label: value` a line.
|
|
451
|
+
*
|
|
452
|
+
* @param {{ rows: ReadonlyArray<readonly [import('react').ReactNode, import('react').ReactNode]> }} props
|
|
453
|
+
*/
|
|
454
|
+
export function Rows({ rows }) {
|
|
455
|
+
const render = useMailRender();
|
|
456
|
+
const theme = useTheme();
|
|
457
|
+
if (!Array.isArray(rows)) throw new MailPolicyError('Rows takes rows: a list of [label, value] pairs.');
|
|
458
|
+
if (render.text) {
|
|
459
|
+
return h(
|
|
460
|
+
Fragment,
|
|
461
|
+
null,
|
|
462
|
+
...rows.map(([label, value], index) => h(Fragment, { key: index }, label, ': ', value, '\n')),
|
|
463
|
+
'\n',
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const cell = {
|
|
468
|
+
borderBottom: `1px solid ${theme.border}`,
|
|
469
|
+
fontFamily: theme.font,
|
|
470
|
+
fontSize: '13px',
|
|
471
|
+
verticalAlign: 'top',
|
|
472
|
+
};
|
|
473
|
+
return h(
|
|
474
|
+
'table',
|
|
475
|
+
{ ...presentation, style: { borderCollapse: 'collapse', margin: '16px 0' } },
|
|
476
|
+
h(
|
|
477
|
+
'tbody',
|
|
478
|
+
null,
|
|
479
|
+
...rows.map(([label, value], index) =>
|
|
480
|
+
h(
|
|
481
|
+
'tr',
|
|
482
|
+
{ key: index },
|
|
483
|
+
h(
|
|
484
|
+
'td',
|
|
485
|
+
{
|
|
486
|
+
className: tones(render, 'border', 'muted'),
|
|
487
|
+
style: { ...cell, color: theme.muted, padding: '9px 12px 9px 0', width: '38%' },
|
|
488
|
+
},
|
|
489
|
+
label,
|
|
490
|
+
),
|
|
491
|
+
h(
|
|
492
|
+
'td',
|
|
493
|
+
{
|
|
494
|
+
className: tones(render, 'border', 'text'),
|
|
495
|
+
style: { ...cell, color: theme.text, padding: '9px 0' },
|
|
496
|
+
},
|
|
497
|
+
value,
|
|
498
|
+
),
|
|
499
|
+
),
|
|
500
|
+
),
|
|
501
|
+
),
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* A table of columns: a header row, if given, then the rows, each cell
|
|
507
|
+
* aligned as `align` says for its column. In text, each row's cells joined
|
|
508
|
+
* by ` | `, the header first.
|
|
509
|
+
*
|
|
510
|
+
* @param {{
|
|
511
|
+
* head?: ReadonlyArray<import('react').ReactNode>,
|
|
512
|
+
* rows: ReadonlyArray<ReadonlyArray<import('react').ReactNode>>,
|
|
513
|
+
* align?: ReadonlyArray<'left' | 'right' | 'center'>,
|
|
514
|
+
* }} props
|
|
515
|
+
*/
|
|
516
|
+
export function Table({ head, rows, align = [] }) {
|
|
517
|
+
const render = useMailRender();
|
|
518
|
+
const theme = useTheme();
|
|
519
|
+
if (!Array.isArray(rows)) throw new MailPolicyError('Table takes rows: a list of lists of cells.');
|
|
520
|
+
const line = (cells, key) =>
|
|
521
|
+
h(Fragment, { key }, ...cells.flatMap((cell, index) => (index === 0 ? [cell] : [' | ', cell])), '\n');
|
|
522
|
+
if (render.text) {
|
|
523
|
+
return h(
|
|
524
|
+
Fragment,
|
|
525
|
+
null,
|
|
526
|
+
head ? line(head, 'head') : null,
|
|
527
|
+
...rows.map((row, index) => line(row, index)),
|
|
528
|
+
'\n',
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const alignOf = (index) => {
|
|
533
|
+
const value = align[index] ?? 'left';
|
|
534
|
+
if (value !== 'left' && value !== 'right' && value !== 'center') {
|
|
535
|
+
throw new MailPolicyError(`A column aligns left, right or center; got '${String(value)}'.`);
|
|
536
|
+
}
|
|
537
|
+
return value;
|
|
538
|
+
};
|
|
539
|
+
const cell = (tag, color, weight) => (content, index) =>
|
|
540
|
+
h(
|
|
541
|
+
tag,
|
|
542
|
+
{
|
|
543
|
+
key: index,
|
|
544
|
+
align: alignOf(index),
|
|
545
|
+
className: tones(render, 'border', color === theme.muted ? 'muted' : 'text'),
|
|
546
|
+
style: {
|
|
547
|
+
borderBottom: `1px solid ${theme.border}`,
|
|
548
|
+
color,
|
|
549
|
+
fontFamily: theme.font,
|
|
550
|
+
fontSize: '13px',
|
|
551
|
+
fontWeight: weight,
|
|
552
|
+
padding: '9px 8px',
|
|
553
|
+
textAlign: alignOf(index),
|
|
554
|
+
verticalAlign: 'top',
|
|
555
|
+
},
|
|
556
|
+
},
|
|
557
|
+
content,
|
|
558
|
+
);
|
|
559
|
+
|
|
560
|
+
return h(
|
|
561
|
+
'table',
|
|
562
|
+
{ ...presentation, style: { borderCollapse: 'collapse', margin: '16px 0' } },
|
|
563
|
+
head ? h('thead', null, h('tr', null, ...head.map(cell('th', theme.muted, 600)))) : null,
|
|
564
|
+
h(
|
|
565
|
+
'tbody',
|
|
566
|
+
null,
|
|
567
|
+
...rows.map((row, index) => h('tr', { key: index }, ...row.map(cell('td', theme.text, 400)))),
|
|
568
|
+
),
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Preformatted text -- a code, a command, a log line -- on a dark box. In
|
|
574
|
+
* text, indented four spaces a line.
|
|
575
|
+
*/
|
|
576
|
+
export function Code({ children }) {
|
|
577
|
+
const render = useMailRender();
|
|
578
|
+
const theme = useTheme();
|
|
579
|
+
if (render.text) {
|
|
580
|
+
const text = plain(children);
|
|
581
|
+
return text === null
|
|
582
|
+
? block(children)
|
|
583
|
+
: block(
|
|
584
|
+
text
|
|
585
|
+
.split('\n')
|
|
586
|
+
.map((row) => ` ${row}`)
|
|
587
|
+
.join('\n'),
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
return h(
|
|
592
|
+
'pre',
|
|
593
|
+
{
|
|
594
|
+
className: tones(render, 'code'),
|
|
595
|
+
style: {
|
|
596
|
+
backgroundColor: theme.code,
|
|
597
|
+
borderRadius: '8px',
|
|
598
|
+
color: theme.codeText,
|
|
599
|
+
fontFamily: theme.mono,
|
|
600
|
+
fontSize: '12px',
|
|
601
|
+
lineHeight: '20px',
|
|
602
|
+
margin: '16px 0',
|
|
603
|
+
padding: '14px 16px',
|
|
604
|
+
whiteSpace: 'pre-wrap',
|
|
605
|
+
wordBreak: 'break-word',
|
|
606
|
+
},
|
|
607
|
+
},
|
|
608
|
+
children,
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** A rule between parts of the message, as a table cell's border: Outlook draws an <hr> its own way. */
|
|
613
|
+
export function Divider() {
|
|
614
|
+
const render = useMailRender();
|
|
615
|
+
const theme = useTheme();
|
|
616
|
+
if (render.text) return '---\n\n';
|
|
617
|
+
|
|
618
|
+
return h(
|
|
619
|
+
'table',
|
|
620
|
+
{ ...presentation, style: { margin: '24px 0' } },
|
|
621
|
+
h(
|
|
622
|
+
'tbody',
|
|
623
|
+
null,
|
|
624
|
+
h(
|
|
625
|
+
'tr',
|
|
626
|
+
null,
|
|
627
|
+
h(
|
|
628
|
+
'td',
|
|
629
|
+
{
|
|
630
|
+
className: tones(render, 'border'),
|
|
631
|
+
style: { borderTop: `1px solid ${theme.border}`, fontSize: '1px', lineHeight: '1px' },
|
|
632
|
+
},
|
|
633
|
+
'\u00a0',
|
|
634
|
+
),
|
|
635
|
+
),
|
|
636
|
+
),
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** A list of points, bulleted; in text, one `- ` a line. */
|
|
641
|
+
export function List({ items }) {
|
|
642
|
+
const render = useMailRender();
|
|
643
|
+
const theme = useTheme();
|
|
644
|
+
if (!Array.isArray(items)) throw new MailPolicyError('List takes items: a list of what each point says.');
|
|
645
|
+
if (render.text) {
|
|
646
|
+
return h(
|
|
647
|
+
Fragment,
|
|
648
|
+
null,
|
|
649
|
+
...items.map((item, index) => h(Fragment, { key: index }, '- ', item, '\n')),
|
|
650
|
+
'\n',
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
return h(
|
|
655
|
+
'ul',
|
|
656
|
+
{
|
|
657
|
+
className: tones(render, 'secondary'),
|
|
658
|
+
style: {
|
|
659
|
+
color: theme.secondary,
|
|
660
|
+
fontFamily: theme.font,
|
|
661
|
+
fontSize: '14px',
|
|
662
|
+
lineHeight: '22px',
|
|
663
|
+
margin: '0 0 12px',
|
|
664
|
+
paddingLeft: '20px',
|
|
665
|
+
},
|
|
666
|
+
},
|
|
667
|
+
...items.map((item, index) => h('li', { key: index }, item)),
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* An image from the message's own origin or one the application allowed
|
|
673
|
+
* (policy.js), or one the message carries inline by its content id --
|
|
674
|
+
* `cid:logo` -- with its size and its words for whoever cannot see it. At
|
|
675
|
+
* least 8px each way: a smaller image shows nothing a reader needs, and is
|
|
676
|
+
* the shape of a tracking pixel, which is Tracker's.
|
|
677
|
+
*/
|
|
678
|
+
export function Image({ src, alt, width, height }) {
|
|
679
|
+
const render = useMailRender();
|
|
680
|
+
if (typeof alt !== 'string')
|
|
681
|
+
throw new MailPolicyError('An email image needs alt text, empty if it is decoration.');
|
|
682
|
+
for (const [name, value] of [
|
|
683
|
+
['width', width],
|
|
684
|
+
['height', height],
|
|
685
|
+
]) {
|
|
686
|
+
if (!Number.isInteger(value) || value < 8 || value > 1200) {
|
|
687
|
+
throw new MailPolicyError(`An email image's ${name} is a whole number of pixels from 8 to 1200.`);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
const url = safeImageSrc(src, render.policy);
|
|
691
|
+
if (render.text) return alt === '' ? null : block(`[${alt}]`);
|
|
692
|
+
|
|
693
|
+
return img({
|
|
694
|
+
src: url,
|
|
695
|
+
alt,
|
|
696
|
+
width,
|
|
697
|
+
height,
|
|
698
|
+
style: { border: 0, display: 'block', height: 'auto', maxWidth: '100%', outline: 'none' },
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* A tracking image (ADR 0116): one pixel, from an origin the Email names in
|
|
704
|
+
* its tracking origins and nowhere else, its query saying who opened the
|
|
705
|
+
* message. Not drawn in the text part, which loads nothing. Allowing one
|
|
706
|
+
* is the application's choice, made where its Email is written; the kit
|
|
707
|
+
* draws one nowhere on its own.
|
|
708
|
+
*/
|
|
709
|
+
export function Tracker({ src }) {
|
|
710
|
+
const render = useMailRender();
|
|
711
|
+
const url = safeTrackerSrc(src, render.policy);
|
|
712
|
+
if (render.text) return null;
|
|
713
|
+
|
|
714
|
+
return img({
|
|
715
|
+
src: url,
|
|
716
|
+
alt: '',
|
|
717
|
+
width: 1,
|
|
718
|
+
height: 1,
|
|
719
|
+
style: { border: 0, display: 'block', height: '1px', width: '1px' },
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** A size as a reader reads it: 840 bytes, 12 KB, 3.4 MB. */
|
|
724
|
+
function size(bytes) {
|
|
725
|
+
if (bytes < 1024) return `${bytes} bytes`;
|
|
726
|
+
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
|
727
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* What the message carries to be opened, by name and size: what PHP told
|
|
732
|
+
* the render it attached (ADR 0116), never what a template says it did.
|
|
733
|
+
* Nothing when it carries none.
|
|
734
|
+
*/
|
|
735
|
+
export function Attachments({ label = 'Attached' }) {
|
|
736
|
+
const render = useMailRender();
|
|
737
|
+
const theme = useTheme();
|
|
738
|
+
const files = render.policy.attachments;
|
|
739
|
+
if (files.length === 0) return null;
|
|
740
|
+
if (render.text) {
|
|
741
|
+
return h(
|
|
742
|
+
Fragment,
|
|
743
|
+
null,
|
|
744
|
+
`${label}:\n`,
|
|
745
|
+
...files.map((file, index) =>
|
|
746
|
+
h(Fragment, { key: index }, '- ', file.name, ' (', size(file.bytes), ')\n'),
|
|
747
|
+
),
|
|
748
|
+
'\n',
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const line = {
|
|
753
|
+
color: theme.muted,
|
|
754
|
+
fontFamily: theme.font,
|
|
755
|
+
fontSize: '13px',
|
|
756
|
+
lineHeight: '20px',
|
|
757
|
+
margin: 0,
|
|
758
|
+
};
|
|
759
|
+
return h(
|
|
760
|
+
'table',
|
|
761
|
+
{ ...presentation, style: { margin: '16px 0' } },
|
|
762
|
+
h(
|
|
763
|
+
'tbody',
|
|
764
|
+
null,
|
|
765
|
+
h(
|
|
766
|
+
'tr',
|
|
767
|
+
null,
|
|
768
|
+
h(
|
|
769
|
+
'td',
|
|
770
|
+
{
|
|
771
|
+
className: tones(render, 'border'),
|
|
772
|
+
style: { borderTop: `1px solid ${theme.border}`, paddingTop: '12px' },
|
|
773
|
+
},
|
|
774
|
+
h('p', { className: tones(render, 'muted'), style: { ...line, fontWeight: 600 } }, label),
|
|
775
|
+
...files.map((file, index) =>
|
|
776
|
+
h(
|
|
777
|
+
'p',
|
|
778
|
+
{ key: index, className: tones(render, 'text'), style: { ...line, color: theme.text } },
|
|
779
|
+
file.name,
|
|
780
|
+
h(
|
|
781
|
+
'span',
|
|
782
|
+
{ className: tones(render, 'muted'), style: { color: theme.muted } },
|
|
783
|
+
` · ${size(file.bytes)}`,
|
|
784
|
+
),
|
|
785
|
+
),
|
|
786
|
+
),
|
|
787
|
+
),
|
|
788
|
+
),
|
|
789
|
+
),
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/** Spacing around a group of components, as a padded cell. */
|
|
794
|
+
export function Section({ children }) {
|
|
795
|
+
const render = useMailRender();
|
|
796
|
+
if (render.text) return h(Fragment, null, children);
|
|
797
|
+
|
|
798
|
+
return h(
|
|
799
|
+
'table',
|
|
800
|
+
{ ...presentation, style: { margin: '16px 0' } },
|
|
801
|
+
h('tbody', null, h('tr', null, h('td', null, children))),
|
|
802
|
+
);
|
|
803
|
+
}
|