@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/src/preview.js ADDED
@@ -0,0 +1,171 @@
1
+ // A preview of an app's emails, in dev and in dev alone (ADR 0086).
2
+ //
3
+ // A Vite plugin: its pages are served by Vite's dev server -- the one
4
+ // `tinvar serve --dev` starts, behind PHP's server under /_vite/ -- and by
5
+ // nothing else. It has no part in a build (`apply: 'serve'`), refuses to
6
+ // start anywhere that says production, as the dev server itself does, and
7
+ // answers only a request naming this machine. What it draws is each
8
+ // template from the sample props the app writes for it: never a database's
9
+ // rows, a reader's address or a real link's token.
10
+
11
+ /** The variables any of which, saying `production`, rule the preview out. Mirrors Tinvar\Support\DevMode. */
12
+ const PRODUCTION_MARKERS = ['NODE_ENV', 'TINVAR_ENV', 'APP_ENV'];
13
+
14
+ /** Hosts a request may name: this machine's. */
15
+ const LOOPBACK_HOST = /^(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::[1-9]\d{0,4})?$/i;
16
+
17
+ /** A template's name, as PHP's Email allows one. */
18
+ const TEMPLATE = /^[a-z0-9][a-z0-9-]{0,63}$/;
19
+
20
+ /** Refuses, loudly, anywhere that says production. */
21
+ export function assertDevelopment(env = process.env) {
22
+ for (const name of PRODUCTION_MARKERS) {
23
+ if (
24
+ String(env[name] ?? '')
25
+ .trim()
26
+ .toLowerCase() === 'production'
27
+ ) {
28
+ throw new Error(
29
+ `The email preview is refused: ${name}=production. It draws templates over HTTP for whoever asks, ` +
30
+ 'so it runs under the dev server alone.',
31
+ );
32
+ }
33
+ }
34
+ }
35
+
36
+ /** @param {string} value */
37
+ function escapeHtml(value) {
38
+ return value.replace(
39
+ /[&<>"']/g,
40
+ (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#x27;' })[c],
41
+ );
42
+ }
43
+
44
+ /**
45
+ * The plugin. `templates` is the module whose `renderMail(frame)` draws the
46
+ * app's emails (createMailRenderer()), `previews` one whose default export
47
+ * is a function of the origin the preview is served on, answering sample
48
+ * props for each template by name.
49
+ *
50
+ * @param {{ templates: string, previews: string, environment?: string, path?: string }} options
51
+ * @returns {import('vite').Plugin}
52
+ */
53
+ export function mailPreview({ templates, previews, environment = 'ssr', path = '__mail' }) {
54
+ return {
55
+ name: 'tinvar:mail-preview',
56
+ apply: 'serve',
57
+ configureServer(server) {
58
+ assertDevelopment();
59
+ const prefix = `${server.config.base.replace(/\/?$/, '/')}${path}`;
60
+
61
+ server.middlewares.use(async (req, res, next) => {
62
+ const url = new URL(req.url ?? '/', 'http://preview.invalid');
63
+ if (url.pathname !== prefix && !url.pathname.startsWith(`${prefix}/`)) return next();
64
+
65
+ const send = (status, type, body, policy) => {
66
+ res.statusCode = status;
67
+ res.setHeader('content-type', `${type}; charset=utf-8`);
68
+ res.setHeader('cache-control', 'no-store');
69
+ res.setHeader('x-content-type-options', 'nosniff');
70
+ res.setHeader('referrer-policy', 'no-referrer');
71
+ res.setHeader('content-security-policy', policy);
72
+ res.end(body);
73
+ };
74
+ const page =
75
+ "default-src 'none'; style-src 'unsafe-inline'; frame-src 'self'; frame-ancestors 'self'";
76
+
77
+ // This machine, asked by this machine: a dev server reached through
78
+ // a name that rebinds to it is not asked by its developer.
79
+ const host = req.headers.host ?? '';
80
+ const peer = req.socket.remoteAddress ?? '';
81
+ if (!LOOPBACK_HOST.test(host) || !/^(?:127\.|::1$|::ffff:127\.)/.test(peer)) {
82
+ return send(404, 'text/plain', 'Not found', "default-src 'none'");
83
+ }
84
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
85
+ return send(405, 'text/plain', 'Method not allowed', "default-src 'none'");
86
+ }
87
+
88
+ try {
89
+ const env = server.environments[environment];
90
+ if (!env?.runner) throw new Error(`The dev server has no runnable '${environment}' environment.`);
91
+ const load = async (source) => {
92
+ const resolved = await env.pluginContainer.resolveId(source, undefined);
93
+ if (!resolved) throw new Error(`${source} does not resolve in the '${environment}' environment.`);
94
+ return env.runner.import(resolved.id);
95
+ };
96
+ const [mail, samples] = await Promise.all([load(templates), load(previews)]);
97
+ const origin = `http://${host}`;
98
+ const props = typeof samples.default === 'function' ? samples.default(origin) : null;
99
+ if (props === null || typeof props !== 'object') {
100
+ throw new Error(
101
+ `${previews} must export a default function of the origin, answering props by template.`,
102
+ );
103
+ }
104
+ const names = Object.keys(props)
105
+ .filter((name) => TEMPLATE.test(name))
106
+ .sort();
107
+ const rest = url.pathname.slice(prefix.length).replace(/^\//, '');
108
+
109
+ if (rest === '') {
110
+ const items = names
111
+ .map((name) => `<li><a href="${escapeHtml(`${prefix}/${name}`)}">${escapeHtml(name)}</a></li>`)
112
+ .join('');
113
+ return send(200, 'text/html', shell('Emails', `<h1>Emails</h1><ul>${items}</ul>`), page);
114
+ }
115
+
116
+ const [name, part] = rest.split('/');
117
+ if (!name || !names.includes(name) || (part !== undefined && part !== 'html')) {
118
+ return send(404, 'text/plain', 'No such email', "default-src 'none'");
119
+ }
120
+ const rendered = await mail.renderMail({
121
+ template: name,
122
+ props: props[name],
123
+ links: { origin, images: [] },
124
+ });
125
+ if (part === 'html') {
126
+ // Drawn in a sandbox of its own: no script, no form, an origin
127
+ // of nobody's, and nothing loaded but its images from here.
128
+ return send(
129
+ 200,
130
+ 'text/html',
131
+ rendered.html,
132
+ `sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src ${origin}; frame-ancestors 'self'`,
133
+ );
134
+ }
135
+
136
+ const frame = escapeHtml(`${prefix}/${name}/html`);
137
+ return send(
138
+ 200,
139
+ 'text/html',
140
+ shell(
141
+ name,
142
+ `<p><a href="${escapeHtml(prefix)}">Emails</a></p><h1>${escapeHtml(name)}</h1>` +
143
+ `<iframe src="${frame}" sandbox title="${escapeHtml(name)} as HTML"></iframe>` +
144
+ `<h2>Plain text</h2><pre>${escapeHtml(rendered.text)}</pre>`,
145
+ ),
146
+ page,
147
+ );
148
+ } catch (error) {
149
+ const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
150
+ return send(
151
+ 500,
152
+ 'text/html',
153
+ shell('Email preview failed', `<h1>Failed</h1><pre>${escapeHtml(message)}</pre>`),
154
+ page,
155
+ );
156
+ }
157
+ });
158
+ },
159
+ };
160
+ }
161
+
162
+ /** @param {string} title @param {string} body */
163
+ function shell(title, body) {
164
+ return (
165
+ `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>${escapeHtml(title)} · email preview</title>` +
166
+ '<style>body{font:14px/1.5 system-ui,sans-serif;margin:24px;color:#111110;background:#F7F7F5}' +
167
+ 'iframe{width:100%;height:640px;border:1px solid #E6E5E1;background:#fff}' +
168
+ 'pre{white-space:pre-wrap;background:#fff;border:1px solid #E6E5E1;padding:12px}</style>' +
169
+ `</head><body>${body}</body></html>`
170
+ );
171
+ }
@@ -0,0 +1,21 @@
1
+ import type { ComponentType, ReactElement } from 'react';
2
+
3
+ export interface RenderedEmail {
4
+ html: string;
5
+ text: string;
6
+ }
7
+
8
+ /** The `mail` frame's fields a renderMail() entry reads. */
9
+ export interface MailFrame {
10
+ template: string;
11
+ props: Record<string, unknown>;
12
+ links?: unknown;
13
+ }
14
+
15
+ /** Draws `element` into an HTML part and a plain-text part, its links held to `links`. */
16
+ export declare function renderEmail(element: ReactElement, links?: unknown): RenderedEmail;
17
+
18
+ /** The app's renderMail(), over a registry of the templates PHP may name. */
19
+ export declare function createMailRenderer(
20
+ templates: Record<string, ComponentType<any>>,
21
+ ): (frame: MailFrame) => Promise<RenderedEmail>;
package/src/render.js ADDED
@@ -0,0 +1,163 @@
1
+ // Draws an email: the HTML part and the plain-text part, from one tree.
2
+ //
3
+ // In the renderer's ssr environment -- the react-server condition has no
4
+ // react-dom/server -- where the app's renderMail() entry is called with the
5
+ // `mail` frame PHP sent (ADR 0032, ADR 0086).
6
+
7
+ import { createElement as h } from 'react';
8
+ import { renderToStaticMarkup } from 'react-dom/server';
9
+ import { RenderContext } from './context.js';
10
+ import { DEFAULT_THEME, policyOf } from './policy.js';
11
+
12
+ /**
13
+ * The dark palette's rule: each class a kit component marks an element with,
14
+ * recoloured where the client prefers dark -- and in Outlook.com, which says
15
+ * so by `[data-ogsc]` rather than the media query. `!important`, since the
16
+ * light colours are inline. Every value is a hex colour themeOf() checked.
17
+ *
18
+ * @param {typeof DEFAULT_THEME} dark
19
+ */
20
+ function darkStyle(dark) {
21
+ const rules = [
22
+ ['bg', `background-color:${dark.background}`],
23
+ ['surface', `background-color:${dark.surface};border-color:${dark.border}`],
24
+ ['border', `border-color:${dark.border}`],
25
+ ['text', `color:${dark.text}`],
26
+ ['secondary', `color:${dark.secondary}`],
27
+ ['muted', `color:${dark.muted}`],
28
+ ['accent', `color:${dark.accent}`],
29
+ ['button', `background-color:${dark.button}`],
30
+ ['button-text', `color:${dark.buttonText}`],
31
+ ['code', `background-color:${dark.code};color:${dark.codeText}`],
32
+ ];
33
+ const body = (prefix) =>
34
+ rules
35
+ .map(
36
+ ([tone, declarations]) =>
37
+ `${prefix}.tm-${tone}{${declarations
38
+ .split(';')
39
+ .map((declaration) => `${declaration} !important`)
40
+ .join(';')}}`,
41
+ )
42
+ .join('');
43
+
44
+ return (
45
+ ':root{color-scheme:light dark;supported-color-schemes:light dark}' +
46
+ `@media (prefers-color-scheme: dark){${body('')}}` +
47
+ body('[data-ogsc] ')
48
+ );
49
+ }
50
+
51
+ /** The characters React escapes in text, and what each stands for. */
52
+ const ENTITIES = { '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#x27;': "'", '&amp;': '&' };
53
+
54
+ /** The closing tag of a block element: in the text part, the end of a line. */
55
+ const BLOCK_END =
56
+ /<\/(?:address|article|aside|blockquote|center|dd|div|dl|dt|figcaption|figure|footer|h[1-6]|header|li|main|nav|ol|p|pre|section|table|tbody|tfoot|thead|tr|ul)\s*>/gi;
57
+
58
+ /** Escapes text for the shell's <title>, as React escapes the body's. */
59
+ function escapeHtml(value) {
60
+ return value.replace(
61
+ /[&<>"']/g,
62
+ (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#x27;' })[c],
63
+ );
64
+ }
65
+
66
+ /**
67
+ * One email from `element`, under `links` -- the message's origin and the
68
+ * origins its images may come from, as PHP's LinkPolicy sent them.
69
+ *
70
+ * Two passes over the same tree, each with a context of its own: the HTML
71
+ * pass, whose body is put inside a document this function writes -- so no
72
+ * <link rel="preload"> React would put in a <head> it drew, and nothing the
73
+ * template did not ask for, and no <meta>: PHP writes the head's own, the
74
+ * charset first, and refuses any other (SafeMailHtml) -- and the text
75
+ * pass, where each kit component
76
+ * draws its plain form. Synchronous: a template is handed everything it
77
+ * shows, and has nothing to wait for.
78
+ *
79
+ * @param {import('react').ReactElement} element
80
+ * @param {unknown} [links]
81
+ * @returns {{ html: string, text: string }}
82
+ */
83
+ export function renderEmail(element, links) {
84
+ const policy = policyOf(links);
85
+
86
+ const document = { lang: 'en', title: '', background: DEFAULT_THEME.background, dark: null };
87
+ const body = renderToStaticMarkup(
88
+ h(RenderContext.Provider, { value: { text: false, policy, document } }, element),
89
+ );
90
+ const textMarkup = renderToStaticMarkup(
91
+ h(RenderContext.Provider, { value: { text: true, policy, document: { ...document } } }, element),
92
+ );
93
+
94
+ const html =
95
+ '<!DOCTYPE html>' +
96
+ `<html lang="${escapeHtml(document.lang)}" dir="ltr">` +
97
+ '<head>' +
98
+ `<title>${escapeHtml(document.title)}</title>` +
99
+ (document.dark === null ? '' : `<style>${darkStyle(document.dark)}</style>`) +
100
+ '</head>' +
101
+ `<body${document.dark === null ? '' : ' class="tm-bg"'} style="margin:0;padding:0;background-color:${document.background}">` +
102
+ body +
103
+ '</body></html>';
104
+
105
+ return { html, text: plainText(textMarkup) };
106
+ }
107
+
108
+ /**
109
+ * The text pass's markup as text: any element a template drew itself (a
110
+ * <b>, a <span>) taken out -- its words kept -- and React's escapes undone.
111
+ * Text React wrote has no `<` of its own, only `&lt;`, so every tag found is
112
+ * one an element made.
113
+ *
114
+ * A block element a template drew (a <div>, a <p>, a row) ends a line where
115
+ * it closes, or blocks ran together ("Order shippedTrack it soon"); blank
116
+ * lines past one, from blocks nested in blocks, are collapsed below. The
117
+ * components draw nothing in the text pass but text, so these are only ever
118
+ * a template's own.
119
+ *
120
+ * @param {string} markup
121
+ */
122
+ function plainText(markup) {
123
+ const text = markup
124
+ .replace(/<(?:br|hr)\s*\/?>/gi, '\n')
125
+ .replace(BLOCK_END, '\n')
126
+ .replace(/<[^>]*>/g, '')
127
+ .replace(
128
+ /&(?:lt|gt|quot|#x27|amp);/g,
129
+ (entity) => ENTITIES[/** @type {keyof typeof ENTITIES} */ (entity)],
130
+ );
131
+
132
+ return `${text
133
+ .split('\n')
134
+ .map((line) => line.replace(/[ \t\u00a0]+$/, ''))
135
+ .join('\n')
136
+ .replace(/\n{3,}/g, '\n\n')
137
+ .trim()}\n`;
138
+ }
139
+
140
+ /**
141
+ * The app's renderMail(): the templates PHP may name, by the name its
142
+ * Mailable gives, in a registry -- as the deferred components are -- so a
143
+ * name from PHP is never a module path, and never a key the registry
144
+ * inherited (`constructor`, `toString`).
145
+ *
146
+ * @template {Record<string, import('react').ComponentType<any>>} T
147
+ * @param {T} templates
148
+ * @returns {(frame: { template: string, props: Record<string, unknown>, links?: unknown }) => Promise<{ html: string, text: string }>}
149
+ */
150
+ export function createMailRenderer(templates) {
151
+ const registry = new Map(Object.entries(templates));
152
+
153
+ return async function renderMail(frame) {
154
+ const { template, props, links } = frame ?? {};
155
+ const Template = typeof template === 'string' ? registry.get(template) : undefined;
156
+ if (Template === undefined) throw new Error(`Unknown email template '${String(template)}'.`);
157
+ if (typeof props !== 'object' || props === null || Array.isArray(props)) {
158
+ throw new TypeError(`The props of '${template}' must be an object, as PHP encodes a Data object.`);
159
+ }
160
+
161
+ return renderEmail(h(Template, props), links);
162
+ };
163
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.