@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.
@@ -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
+ }