@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/context.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react';
|
|
2
|
+
import { DEFAULT_THEME, MailPolicyError } from './policy.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What one render of one email is: whether this pass draws the HTML part or
|
|
6
|
+
* the plain-text one, the link policy PHP sent with the message, and the
|
|
7
|
+
* document the layout fills in -- its language, title and background, which
|
|
8
|
+
* renderEmail() writes into the <html> and <head> it puts around the body.
|
|
9
|
+
*
|
|
10
|
+
* A context, made afresh by each renderEmail() call, never module state: one
|
|
11
|
+
* renderer process draws every tenant's mail, and nothing of one message --
|
|
12
|
+
* its origin, its branding, its reader's name -- may be left where the next
|
|
13
|
+
* message's render would find it.
|
|
14
|
+
*
|
|
15
|
+
* The layout's dark palette goes there too, when it has one (ADR 0116):
|
|
16
|
+
* renderEmail() writes its `prefers-color-scheme` rule into the head, and
|
|
17
|
+
* every kit component marks what it colours with a class that rule names.
|
|
18
|
+
*
|
|
19
|
+
* @typedef {{ lang: string, title: string, background: string, dark: typeof DEFAULT_THEME | null }} MailDocument
|
|
20
|
+
* @typedef {{ text: boolean, policy: import('./policy.js').MailPolicy, document: MailDocument }} MailRender
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** @type {import('react').Context<MailRender | null>} */
|
|
24
|
+
export const RenderContext = createContext(/** @type {MailRender | null} */ (null));
|
|
25
|
+
|
|
26
|
+
/** @type {import('react').Context<typeof DEFAULT_THEME>} */
|
|
27
|
+
export const ThemeContext = createContext(DEFAULT_THEME);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The render in hand. A kit component drawn outside renderEmail() has no
|
|
31
|
+
* policy to hold its links to, so it refuses rather than draw them unchecked.
|
|
32
|
+
*
|
|
33
|
+
* @returns {MailRender}
|
|
34
|
+
*/
|
|
35
|
+
export function useMailRender() {
|
|
36
|
+
const render = useContext(RenderContext);
|
|
37
|
+
if (render === null) {
|
|
38
|
+
throw new MailPolicyError(
|
|
39
|
+
'@tinvar/mail components render only inside renderEmail(), which holds their links.',
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return render;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function useTheme() {
|
|
46
|
+
return useContext(ThemeContext);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The class names a component marks an element with, so the dark palette's
|
|
51
|
+
* rule recolours it -- or none, when the layout has no dark palette and the
|
|
52
|
+
* markup should carry nothing it does not use.
|
|
53
|
+
*
|
|
54
|
+
* @param {MailRender} render
|
|
55
|
+
* @param {...string} tones e.g. 'text', 'surface', 'border'
|
|
56
|
+
* @returns {string | undefined}
|
|
57
|
+
*/
|
|
58
|
+
export function tones(render, ...tones) {
|
|
59
|
+
return render.document.dark === null ? undefined : tones.map((tone) => `tm-${tone}`).join(' ');
|
|
60
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { ReactElement, ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
/** Why a value was refused: a link, an image, a theme value or a prop the kit cannot draw safely. */
|
|
4
|
+
export declare class MailPolicyError extends Error {}
|
|
5
|
+
|
|
6
|
+
/** The colours and fonts a layout draws with: hex colours, plain font stacks. */
|
|
7
|
+
export interface MailTheme {
|
|
8
|
+
background: string;
|
|
9
|
+
surface: string;
|
|
10
|
+
border: string;
|
|
11
|
+
text: string;
|
|
12
|
+
secondary: string;
|
|
13
|
+
muted: string;
|
|
14
|
+
accent: string;
|
|
15
|
+
button: string;
|
|
16
|
+
buttonText: string;
|
|
17
|
+
code: string;
|
|
18
|
+
codeText: string;
|
|
19
|
+
font: string;
|
|
20
|
+
mono: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Meridian's light palette, which a layout uses unless given a theme. */
|
|
24
|
+
export declare const DEFAULT_THEME: Readonly<MailTheme>;
|
|
25
|
+
|
|
26
|
+
/** Meridian's dark palette, which a layout given `dark` draws where the client prefers dark. */
|
|
27
|
+
export declare const DEFAULT_DARK_THEME: Readonly<MailTheme>;
|
|
28
|
+
|
|
29
|
+
/** A file the message carries, as PHP told the render: never its bytes. */
|
|
30
|
+
export interface MailAttachment {
|
|
31
|
+
name: string;
|
|
32
|
+
type: string;
|
|
33
|
+
bytes: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** What a render may link to and load images from: PHP's LinkPolicy, as the mail frame carries it. */
|
|
37
|
+
export interface MailLinks {
|
|
38
|
+
origin: string | null;
|
|
39
|
+
images: readonly string[];
|
|
40
|
+
/** Origins a tracking image may load from, its query and all. */
|
|
41
|
+
tracking?: readonly string[];
|
|
42
|
+
/** The content ids of the images the message carries inline. */
|
|
43
|
+
inline?: readonly string[];
|
|
44
|
+
/** The files the message carries to be opened. */
|
|
45
|
+
attachments?: readonly MailAttachment[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `href`, if a reader may follow it: https, on the message's origin, or mailto:. Throws otherwise. */
|
|
49
|
+
export declare function safeHref(href: unknown, policy: Pick<MailLinks, 'origin'>): string;
|
|
50
|
+
|
|
51
|
+
/** `src`, if a mail client may load it: on the message's origin or an allowed one, with no query, or `cid:` an inline image. Throws otherwise. */
|
|
52
|
+
export declare function safeImageSrc(src: unknown, policy: MailLinks): string;
|
|
53
|
+
|
|
54
|
+
/** `src`, if it is on an origin named for tracking images. Throws otherwise. */
|
|
55
|
+
export declare function safeTrackerSrc(src: unknown, policy: Pick<MailLinks, 'tracking'>): string;
|
|
56
|
+
|
|
57
|
+
export interface LayoutProps {
|
|
58
|
+
children?: ReactNode;
|
|
59
|
+
/** The line an inbox shows after the subject; hidden in the message. */
|
|
60
|
+
preview?: string;
|
|
61
|
+
/** Who the message is from, in its header. */
|
|
62
|
+
brand: string;
|
|
63
|
+
/** A hosted PNG, on the message's origin (MER-36.3). */
|
|
64
|
+
logoUrl?: string | null;
|
|
65
|
+
/** Why the reader is getting it: "Why am I getting this?" (MER-36.3). */
|
|
66
|
+
reason?: string | null;
|
|
67
|
+
/** Where the reader asks to stop these emails (MER-36.3). */
|
|
68
|
+
unsubscribeUrl?: string | null;
|
|
69
|
+
/** The words of that link; "Unsubscribe from these emails" unless given. */
|
|
70
|
+
unsubscribeLabel?: string;
|
|
71
|
+
/** The sender's postal address (MER-36.3). */
|
|
72
|
+
address?: string | null;
|
|
73
|
+
/** This message's branding, over the default: never shared with another message's render. */
|
|
74
|
+
theme?: Partial<MailTheme> | null;
|
|
75
|
+
/** What a client that prefers dark draws: true for the default dark palette, or colours over it. */
|
|
76
|
+
dark?: boolean | Partial<MailTheme> | null;
|
|
77
|
+
/** The message's language tag; `en` unless given. */
|
|
78
|
+
lang?: string;
|
|
79
|
+
/** The document's <title>; the brand unless given. */
|
|
80
|
+
title?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export declare function Layout(props: LayoutProps): ReactElement;
|
|
84
|
+
|
|
85
|
+
export declare function Heading(props: { children?: ReactNode; level?: 1 | 2 | 3 }): ReactElement;
|
|
86
|
+
|
|
87
|
+
export declare function Text(props: { children?: ReactNode; muted?: boolean }): ReactElement;
|
|
88
|
+
|
|
89
|
+
export declare function Link(props: { href: string; children?: ReactNode }): ReactElement;
|
|
90
|
+
|
|
91
|
+
/** A button, and its URL written out beneath it unless `fallback` is false. */
|
|
92
|
+
export declare function Button(props: {
|
|
93
|
+
href: string;
|
|
94
|
+
children?: ReactNode;
|
|
95
|
+
fallback?: boolean;
|
|
96
|
+
}): ReactElement;
|
|
97
|
+
|
|
98
|
+
export declare function Rows(props: { rows: ReadonlyArray<readonly [ReactNode, ReactNode]> }): ReactElement;
|
|
99
|
+
|
|
100
|
+
export declare function Table(props: {
|
|
101
|
+
head?: ReadonlyArray<ReactNode>;
|
|
102
|
+
rows: ReadonlyArray<ReadonlyArray<ReactNode>>;
|
|
103
|
+
align?: ReadonlyArray<'left' | 'right' | 'center'>;
|
|
104
|
+
}): ReactElement;
|
|
105
|
+
|
|
106
|
+
export declare function Code(props: { children?: ReactNode }): ReactElement;
|
|
107
|
+
|
|
108
|
+
/** A rule between parts of the message; in the text part, `---` and a blank line, as a string. */
|
|
109
|
+
export declare function Divider(): ReactElement | string;
|
|
110
|
+
|
|
111
|
+
export declare function List(props: { items: ReadonlyArray<ReactNode> }): ReactElement;
|
|
112
|
+
|
|
113
|
+
/** One pixel from an origin the Email names for tracking, its query and all; nothing in the text part. */
|
|
114
|
+
export declare function Tracker(props: { src: string }): ReactElement | null;
|
|
115
|
+
|
|
116
|
+
/** What the message carries to be opened, by name and size, as PHP told the render. */
|
|
117
|
+
export declare function Attachments(props: { label?: string }): ReactElement | null;
|
|
118
|
+
|
|
119
|
+
/** An image from the message's origin or an allowed one, or `cid:` one it carries inline, 8 to 1200 pixels each way; nothing in the text part when its alt is empty. */
|
|
120
|
+
export declare function Image(props: {
|
|
121
|
+
src: string;
|
|
122
|
+
alt: string;
|
|
123
|
+
width: number;
|
|
124
|
+
height: number;
|
|
125
|
+
}): ReactElement | null;
|
|
126
|
+
|
|
127
|
+
export declare function Section(props: { children?: ReactNode }): ReactElement;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// @tinvar/mail: the framework's email kit (ADR 0086).
|
|
2
|
+
//
|
|
3
|
+
// Components a template is built from, drawn by the renderer into an HTML
|
|
4
|
+
// part mail clients read and a plain-text part with every link spelled out;
|
|
5
|
+
// renderEmail() and createMailRenderer() from '@tinvar/mail/render' draw
|
|
6
|
+
// them, in the ssr environment.
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
Attachments,
|
|
10
|
+
Button,
|
|
11
|
+
Code,
|
|
12
|
+
Divider,
|
|
13
|
+
Heading,
|
|
14
|
+
Image,
|
|
15
|
+
Layout,
|
|
16
|
+
Link,
|
|
17
|
+
List,
|
|
18
|
+
Rows,
|
|
19
|
+
Section,
|
|
20
|
+
Table,
|
|
21
|
+
Text,
|
|
22
|
+
Tracker,
|
|
23
|
+
} from './components.js';
|
|
24
|
+
export {
|
|
25
|
+
DEFAULT_DARK_THEME,
|
|
26
|
+
DEFAULT_THEME,
|
|
27
|
+
MailPolicyError,
|
|
28
|
+
safeHref,
|
|
29
|
+
safeImageSrc,
|
|
30
|
+
safeTrackerSrc,
|
|
31
|
+
} from './policy.js';
|
package/src/policy.js
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// What an email may link to, load, and be styled with.
|
|
2
|
+
//
|
|
3
|
+
// An email is opened by a mail client that runs no script but follows
|
|
4
|
+
// links, loads images and honours CSS, often before the reader has looked:
|
|
5
|
+
// a `javascript:` or `data:` link, an image from an address that learns who
|
|
6
|
+
// opened the message, or a style that fetches one, are each a way out of
|
|
7
|
+
// the message the application meant to send. So the kit's components hold
|
|
8
|
+
// every href, src and style value they write to these rules as they
|
|
9
|
+
// render, and PHP holds the HTML to the same rules again before it sends
|
|
10
|
+
// it (Tinvar\Mail\LinkPolicy, Tinvar\Mail\SafeMailHtml) -- the renderer is
|
|
11
|
+
// not trusted to have done so.
|
|
12
|
+
|
|
13
|
+
/** Why a value was refused: what was given, and the rule it broke. */
|
|
14
|
+
export class MailPolicyError extends Error {
|
|
15
|
+
/** @param {string} message */
|
|
16
|
+
constructor(message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = 'MailPolicyError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Whitespace and control characters, which no URL the kit writes holds: a browser drops some inside a scheme. */
|
|
23
|
+
const UNSAFE_CHARACTERS = /[\s\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* An http origin on this machine -- localhost and below it, or a loopback
|
|
27
|
+
* address: the one plain-HTTP origin allowed, for an app served by `tinvar serve`.
|
|
28
|
+
*/
|
|
29
|
+
const LOOPBACK_ORIGIN =
|
|
30
|
+
/^http:\/\/(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::[1-9]\d{0,4})?$/;
|
|
31
|
+
|
|
32
|
+
/** An https origin: a host and perhaps a port, no user, path, query or fragment. */
|
|
33
|
+
const HTTPS_ORIGIN = /^https:\/\/(?:[^/:?#@[\]\s]+|\[[0-9a-f:.]+\])(?::[1-9]\d{0,4})?$/i;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The policy a render was given: the message's canonical origin, from PHP's
|
|
37
|
+
* CanonicalOrigins (or an app's own on loopback), any further origins
|
|
38
|
+
* images may load from, the origins a tracking image may load from query
|
|
39
|
+
* and all, the content ids of the images the message carries inline, and
|
|
40
|
+
* the names, types and sizes of the files it carries (ADR 0116). Checked
|
|
41
|
+
* as it is read, so a malformed one refuses the render rather than letting
|
|
42
|
+
* everything, or nothing, through by accident.
|
|
43
|
+
*
|
|
44
|
+
* @typedef {{ name: string, type: string, bytes: number }} MailAttachment
|
|
45
|
+
* @typedef {{ origin: string | null, images: readonly string[], tracking: readonly string[], inline: readonly string[], attachments: readonly MailAttachment[] }} MailPolicy
|
|
46
|
+
*
|
|
47
|
+
* @param {unknown} links the frame's `links`, as PHP's LinkPolicy::toFrame() wrote it
|
|
48
|
+
* @returns {MailPolicy}
|
|
49
|
+
*/
|
|
50
|
+
export function policyOf(links) {
|
|
51
|
+
if (links === undefined || links === null) {
|
|
52
|
+
return Object.freeze({
|
|
53
|
+
origin: null,
|
|
54
|
+
images: Object.freeze([]),
|
|
55
|
+
tracking: Object.freeze([]),
|
|
56
|
+
inline: Object.freeze([]),
|
|
57
|
+
attachments: Object.freeze([]),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
if (typeof links !== 'object' || Array.isArray(links)) {
|
|
61
|
+
throw new MailPolicyError("An email's link policy must be an object: { origin, images }.");
|
|
62
|
+
}
|
|
63
|
+
const {
|
|
64
|
+
origin = null,
|
|
65
|
+
images = [],
|
|
66
|
+
tracking = [],
|
|
67
|
+
inline = [],
|
|
68
|
+
attachments = [],
|
|
69
|
+
} = /** @type {{ origin?: unknown, images?: unknown, tracking?: unknown, inline?: unknown, attachments?: unknown }} */ (
|
|
70
|
+
links
|
|
71
|
+
);
|
|
72
|
+
if (origin !== null && (typeof origin !== 'string' || !isOrigin(origin))) {
|
|
73
|
+
throw new MailPolicyError(
|
|
74
|
+
`'${String(origin)}' is not an origin: https://host[:port], or http on loopback.`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
if (
|
|
78
|
+
!Array.isArray(images) ||
|
|
79
|
+
images.some((image) => typeof image !== 'string' || !HTTPS_ORIGIN.test(image))
|
|
80
|
+
) {
|
|
81
|
+
throw new MailPolicyError('Images may only be allowed from https origins: https://host[:port].');
|
|
82
|
+
}
|
|
83
|
+
if (
|
|
84
|
+
!Array.isArray(tracking) ||
|
|
85
|
+
tracking.some((tracker) => typeof tracker !== 'string' || !HTTPS_ORIGIN.test(tracker))
|
|
86
|
+
) {
|
|
87
|
+
throw new MailPolicyError(
|
|
88
|
+
'A tracking image may only be allowed from https origins: https://host[:port].',
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
if (!Array.isArray(inline) || inline.some((id) => typeof id !== 'string' || !CONTENT_ID.test(id))) {
|
|
92
|
+
throw new MailPolicyError("An inline image's content id is letters, digits, '.', '_' or '-'.");
|
|
93
|
+
}
|
|
94
|
+
if (
|
|
95
|
+
!Array.isArray(attachments) ||
|
|
96
|
+
attachments.some(
|
|
97
|
+
(file) =>
|
|
98
|
+
typeof file !== 'object' ||
|
|
99
|
+
file === null ||
|
|
100
|
+
typeof file.name !== 'string' ||
|
|
101
|
+
typeof file.type !== 'string' ||
|
|
102
|
+
!Number.isInteger(file.bytes) ||
|
|
103
|
+
file.bytes < 0,
|
|
104
|
+
)
|
|
105
|
+
) {
|
|
106
|
+
throw new MailPolicyError("An email's attachments are described as { name, type, bytes }.");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return Object.freeze({
|
|
110
|
+
origin,
|
|
111
|
+
images: Object.freeze([...images]),
|
|
112
|
+
tracking: Object.freeze([...tracking]),
|
|
113
|
+
inline: Object.freeze([...inline]),
|
|
114
|
+
attachments: Object.freeze(
|
|
115
|
+
attachments.map((file) => Object.freeze({ name: file.name, type: file.type, bytes: file.bytes })),
|
|
116
|
+
),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** A content id, as a `cid:` URL names it unescaped. */
|
|
121
|
+
const CONTENT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
122
|
+
|
|
123
|
+
/** @param {string} origin */
|
|
124
|
+
function isOrigin(origin) {
|
|
125
|
+
return HTTPS_ORIGIN.test(origin) || LOOPBACK_ORIGIN.test(origin);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Whether `url` is on `origin`: the origin itself, or a path, query or
|
|
130
|
+
* fragment after it -- never `https://shop.test.evil.example`, which merely
|
|
131
|
+
* starts with it.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} url
|
|
134
|
+
* @param {string} origin
|
|
135
|
+
*/
|
|
136
|
+
function isOn(url, origin) {
|
|
137
|
+
const lower = url.toLowerCase();
|
|
138
|
+
const base = origin.toLowerCase();
|
|
139
|
+
if (!lower.startsWith(base)) return false;
|
|
140
|
+
const next = lower.charAt(base.length);
|
|
141
|
+
return next === '' || next === '/' || next === '?' || next === '#';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* A link a reader may follow: any https URL, a URL on the message's own
|
|
146
|
+
* origin (http on loopback in dev), or a `mailto:` an address. Never
|
|
147
|
+
* `javascript:`, `data:`, `vbscript:`, a relative path -- which a mail
|
|
148
|
+
* client resolves against nothing the app controls -- or plain http
|
|
149
|
+
* elsewhere, which hands whatever the link carries to the network.
|
|
150
|
+
*
|
|
151
|
+
* @param {unknown} href
|
|
152
|
+
* @param {{ origin: string | null }} policy
|
|
153
|
+
* @returns {string} the href, unchanged
|
|
154
|
+
*/
|
|
155
|
+
export function safeHref(href, policy) {
|
|
156
|
+
if (typeof href !== 'string' || href === '' || UNSAFE_CHARACTERS.test(href)) {
|
|
157
|
+
throw new MailPolicyError(
|
|
158
|
+
`An email link must be a URL with no spaces or control characters; got '${String(href)}'.`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
if (/^mailto:/i.test(href)) {
|
|
162
|
+
if (/^mailto:[^@\s<>"(),;:\\[\]]+@[^@\s<>"(),;:\\[\]?]+(?:\?[^\s<>"]*)?$/i.test(href)) return href;
|
|
163
|
+
throw new MailPolicyError(`'${href}' is not a mailto: link to one address.`);
|
|
164
|
+
}
|
|
165
|
+
if (/^https:\/\/[^/?#\s]/i.test(href) && parses(href, 'https:')) return href;
|
|
166
|
+
if (policy.origin !== null && isOn(href, policy.origin) && parses(href, null)) return href;
|
|
167
|
+
|
|
168
|
+
throw new MailPolicyError(
|
|
169
|
+
`'${href}' cannot be linked to from an email: links are https, on the message's own origin, or mailto:.`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* An image a mail client may load: from the message's own origin or one the
|
|
175
|
+
* application allowed, with no query or fragment; or one the message
|
|
176
|
+
* carries inline, by a content id it declared (`cid:logo`).
|
|
177
|
+
*
|
|
178
|
+
* An image is fetched by the client when the message is opened, so an
|
|
179
|
+
* address that learns per recipient who opened what is a tracking pixel
|
|
180
|
+
* whatever its size; a static asset on the app's own origin is not. So an
|
|
181
|
+
* image is never from anywhere else unless the app says so, and never
|
|
182
|
+
* carries a query -- where a per-recipient id would go. A tracking image is
|
|
183
|
+
* Tracker's, from an origin named for it alone (safeTrackerSrc()).
|
|
184
|
+
*
|
|
185
|
+
* @param {unknown} src
|
|
186
|
+
* @param {{ origin: string | null, images: readonly string[], inline?: readonly string[] }} policy
|
|
187
|
+
* @returns {string}
|
|
188
|
+
*/
|
|
189
|
+
export function safeImageSrc(src, policy) {
|
|
190
|
+
if (typeof src === 'string' && /^cid:/i.test(src)) {
|
|
191
|
+
if ((policy.inline ?? []).includes(src.slice(4))) return src;
|
|
192
|
+
throw new MailPolicyError(`'${src}' names no image this message carries inline.`);
|
|
193
|
+
}
|
|
194
|
+
if (typeof src !== 'string' || src === '' || UNSAFE_CHARACTERS.test(src) || /[?#]/.test(src)) {
|
|
195
|
+
throw new MailPolicyError(
|
|
196
|
+
`An email image must be a URL with no spaces, query or fragment; got '${String(src)}'.`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
const allowed = [...(policy.origin === null ? [] : [policy.origin]), ...policy.images];
|
|
200
|
+
if (allowed.some((origin) => isOn(src, origin) && src.length > origin.length) && parses(src, null))
|
|
201
|
+
return src;
|
|
202
|
+
|
|
203
|
+
throw new MailPolicyError(
|
|
204
|
+
`'${src}' cannot be loaded by an email: images come from the message's own origin, or one the application allows.`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* A tracking image's URL: on an origin the application named for tracking
|
|
210
|
+
* (ADR 0116), where -- and only where -- an image may carry a query. Never
|
|
211
|
+
* a fragment, which no image needs.
|
|
212
|
+
*
|
|
213
|
+
* @param {unknown} src
|
|
214
|
+
* @param {{ tracking?: readonly string[] }} policy
|
|
215
|
+
* @returns {string}
|
|
216
|
+
*/
|
|
217
|
+
export function safeTrackerSrc(src, policy) {
|
|
218
|
+
if (typeof src !== 'string' || src === '' || UNSAFE_CHARACTERS.test(src) || src.includes('#')) {
|
|
219
|
+
throw new MailPolicyError(`A tracking image is a URL with no spaces or fragment; got '${String(src)}'.`);
|
|
220
|
+
}
|
|
221
|
+
const allowed = policy.tracking ?? [];
|
|
222
|
+
if (allowed.some((origin) => isOn(src, origin) && src.length > origin.length) && parses(src, 'https:'))
|
|
223
|
+
return src;
|
|
224
|
+
|
|
225
|
+
throw new MailPolicyError(
|
|
226
|
+
`'${src}' cannot be loaded as a tracking image: name its origin in the Email's tracking origins.`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** @param {string} url @param {string | null} protocol */
|
|
231
|
+
function parses(url, protocol) {
|
|
232
|
+
try {
|
|
233
|
+
const parsed = new URL(url);
|
|
234
|
+
return (
|
|
235
|
+
(protocol === null || parsed.protocol === protocol) && parsed.username === '' && parsed.password === ''
|
|
236
|
+
);
|
|
237
|
+
} catch {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** A colour the theme may set: hex, nothing CSS could read as more than one value. */
|
|
243
|
+
const COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
244
|
+
|
|
245
|
+
/** A font stack: names, quotes, spaces, commas and hyphens -- no `;`, `(` or `:`. */
|
|
246
|
+
const FONT = /^[A-Za-z0-9 ,'"-]{1,200}$/;
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The colours and fonts a layout draws with, Meridian's light palette unless
|
|
250
|
+
* the app gives its own. Inline hex values: mail clients ignore classes and
|
|
251
|
+
* CSS variables (MER-36.1).
|
|
252
|
+
*/
|
|
253
|
+
export const DEFAULT_THEME = Object.freeze({
|
|
254
|
+
background: '#F7F7F5',
|
|
255
|
+
surface: '#FFFFFF',
|
|
256
|
+
border: '#E6E5E1',
|
|
257
|
+
text: '#111110',
|
|
258
|
+
secondary: '#5B5A56',
|
|
259
|
+
muted: '#72716C',
|
|
260
|
+
accent: '#2E63F0',
|
|
261
|
+
button: '#111110',
|
|
262
|
+
buttonText: '#FFFFFF',
|
|
263
|
+
code: '#111110',
|
|
264
|
+
codeText: '#D9D8D3',
|
|
265
|
+
font: "Inter, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif",
|
|
266
|
+
mono: "'Geist Mono', ui-monospace, Menlo, Consolas, monospace",
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Meridian's dark palette, which a layout given `dark` switches to where the
|
|
271
|
+
* reader's client asks for dark (ADR 0116): by a `prefers-color-scheme`
|
|
272
|
+
* rule in the head, over the inline light colours, which a client that
|
|
273
|
+
* reads no <style> keeps.
|
|
274
|
+
*/
|
|
275
|
+
export const DEFAULT_DARK_THEME = Object.freeze({
|
|
276
|
+
...DEFAULT_THEME,
|
|
277
|
+
background: '#111110',
|
|
278
|
+
surface: '#1A1A18',
|
|
279
|
+
border: '#2E2D2A',
|
|
280
|
+
text: '#EDEDEA',
|
|
281
|
+
secondary: '#B5B4AE',
|
|
282
|
+
muted: '#8F8E88',
|
|
283
|
+
accent: '#7FA2FF',
|
|
284
|
+
button: '#EDEDEA',
|
|
285
|
+
buttonText: '#111110',
|
|
286
|
+
code: '#0B0B0A',
|
|
287
|
+
codeText: '#D9D8D3',
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The theme a layout was given, over the default, each value checked: a
|
|
292
|
+
* tenant's branding arrives as props, and a colour of `red;background:
|
|
293
|
+
* url(https://tracker.example/x)` would be a style that loads something.
|
|
294
|
+
*
|
|
295
|
+
* @param {unknown} theme
|
|
296
|
+
* @param {typeof DEFAULT_THEME} [base] what it is over: the light default unless given
|
|
297
|
+
* @returns {typeof DEFAULT_THEME}
|
|
298
|
+
*/
|
|
299
|
+
export function themeOf(theme, base = DEFAULT_THEME) {
|
|
300
|
+
if (theme === undefined || theme === null) return base;
|
|
301
|
+
if (typeof theme !== 'object' || Array.isArray(theme)) {
|
|
302
|
+
throw new MailPolicyError('An email theme must be an object of colours and fonts.');
|
|
303
|
+
}
|
|
304
|
+
/** @type {Record<string, string>} */
|
|
305
|
+
const merged = { ...base };
|
|
306
|
+
for (const [key, value] of Object.entries(theme)) {
|
|
307
|
+
if (value === undefined) continue;
|
|
308
|
+
if (!Object.hasOwn(DEFAULT_THEME, key)) {
|
|
309
|
+
throw new MailPolicyError(`'${key}' is not something an email theme sets.`);
|
|
310
|
+
}
|
|
311
|
+
const ok = typeof value === 'string' && (key === 'font' || key === 'mono' ? FONT : COLOR).test(value);
|
|
312
|
+
if (!ok) throw new MailPolicyError(`The email theme's ${key} cannot be '${String(value)}'.`);
|
|
313
|
+
merged[key] = value;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return /** @type {typeof DEFAULT_THEME} */ (Object.freeze(merged));
|
|
317
|
+
}
|
package/src/preview.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Plugin } from 'vite';
|
|
2
|
+
|
|
3
|
+
export interface MailPreviewOptions {
|
|
4
|
+
/** The module whose `renderMail(frame)` draws the app's emails, as createMailRenderer() makes it. */
|
|
5
|
+
templates: string;
|
|
6
|
+
/** A module whose default export takes the preview's origin and answers sample props by template name. */
|
|
7
|
+
previews: string;
|
|
8
|
+
/** The Vite environment both are loaded in; `ssr` unless given. */
|
|
9
|
+
environment?: string;
|
|
10
|
+
/** Where under the dev server's base the preview is served; `__mail` unless given. */
|
|
11
|
+
path?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Sample props for each template, given the origin the preview is served on. */
|
|
15
|
+
export type MailPreviews = (origin: string) => Record<string, object>;
|
|
16
|
+
|
|
17
|
+
/** Throws where NODE_ENV, TINVAR_ENV or APP_ENV says production. */
|
|
18
|
+
export declare function assertDevelopment(env?: Record<string, string | undefined>): void;
|
|
19
|
+
|
|
20
|
+
/** The dev server's email preview; no part of a build, and refused in production. */
|
|
21
|
+
export declare function mailPreview(options: MailPreviewOptions): Plugin;
|