@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
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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
|
+
}
|
package/src/render.d.ts
ADDED
|
@@ -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 = { '<': '<', '>': '>', '"': '"', ''': "'", '&': '&' };
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 `<`, 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