@sdxc/mail 0.0.0-pre.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.md +21 -0
- package/README.md +544 -0
- package/dist/cloudflare.d.ts +82 -0
- package/dist/cloudflare.js +71 -0
- package/dist/components.d.ts +346 -0
- package/dist/components.js +293 -0
- package/dist/errors.d.ts +22 -0
- package/dist/errors.js +24 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +22 -0
- package/dist/lib/address.d.ts +37 -0
- package/dist/lib/address.js +56 -0
- package/dist/lib/html-to-text.d.ts +19 -0
- package/dist/lib/html-to-text.js +149 -0
- package/dist/mailer.d.ts +82 -0
- package/dist/mailer.js +188 -0
- package/dist/markdown.d.ts +46 -0
- package/dist/markdown.js +182 -0
- package/dist/memory.d.ts +81 -0
- package/dist/memory.js +84 -0
- package/dist/middleware.d.ts +53 -0
- package/dist/middleware.js +50 -0
- package/dist/mime.d.ts +21 -0
- package/dist/mime.js +374 -0
- package/dist/render.d.ts +27 -0
- package/dist/render.js +33 -0
- package/dist/types.d.ts +116 -0
- package/dist/types.js +8 -0
- package/package.json +30 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "remix/ui/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Font stack that resolves to a native UI face in every major mail client. No family
|
|
4
|
+
* needs quoting, which keeps the inline style free of escaped character references.
|
|
5
|
+
*/
|
|
6
|
+
const FONT_FAMILY = "-apple-system, BlinkMacSystemFont, system-ui, Roboto, Helvetica, Arial, sans-serif";
|
|
7
|
+
/** Page color behind the content card. */
|
|
8
|
+
const PAGE_COLOR = "#f4f4f5";
|
|
9
|
+
/** Content card color. */
|
|
10
|
+
const SURFACE_COLOR = "#ffffff";
|
|
11
|
+
/** Body copy color. */
|
|
12
|
+
const TEXT_COLOR = "#18181b";
|
|
13
|
+
/** Color for de-emphasized copy such as footers. */
|
|
14
|
+
const MUTED_COLOR = "#71717a";
|
|
15
|
+
/** Fill color of a call-to-action button. */
|
|
16
|
+
const ACTION_COLOR = "#18181b";
|
|
17
|
+
/** Label color of a call-to-action button. */
|
|
18
|
+
const ACTION_LABEL_COLOR = "#ffffff";
|
|
19
|
+
/** Hairline color separating the footer from the body. */
|
|
20
|
+
const BORDER_COLOR = "#e4e4e7";
|
|
21
|
+
/** Content width in pixels, the widest that renders without scrolling on mobile. */
|
|
22
|
+
const CONTENT_WIDTH = 600;
|
|
23
|
+
/** Font size in pixels per heading level. */
|
|
24
|
+
const HEADING_SIZES = { 1: 24, 2: 20, 3: 16 };
|
|
25
|
+
/**
|
|
26
|
+
* The dark counterpart of every color above. A declared color scheme with no
|
|
27
|
+
* dark rules leaves Apple Mail's promise half kept, so these `!important` classes
|
|
28
|
+
* override every inline light value for colors the caller left to the kit.
|
|
29
|
+
*/
|
|
30
|
+
const DARK_RULES = [
|
|
31
|
+
".mail-page{background-color:#09090b !important;}",
|
|
32
|
+
".mail-surface{background-color:#18181b !important;}",
|
|
33
|
+
".mail-text{color:#fafafa !important;}",
|
|
34
|
+
".mail-muted{color:#a1a1aa !important;}",
|
|
35
|
+
".mail-rule{border-color:#3f3f46 !important;}",
|
|
36
|
+
".mail-action{background-color:#fafafa !important;}",
|
|
37
|
+
".mail-action-label{color:#18181b !important;}",
|
|
38
|
+
".mail-code{background-color:#27272a !important;color:#fafafa !important;}",
|
|
39
|
+
".mail-tok-comment{color:#8b949e !important;}",
|
|
40
|
+
".mail-tok-keyword{color:#ff7b72 !important;}",
|
|
41
|
+
".mail-tok-string{color:#a5d6ff !important;}",
|
|
42
|
+
".mail-tok-number{color:#79c0ff !important;}",
|
|
43
|
+
".mail-tok-function{color:#d2a8ff !important;}",
|
|
44
|
+
".mail-tok-punctuation{color:#c9d1d9 !important;}",
|
|
45
|
+
".mail-tok-inserted{color:#7ee787 !important;}",
|
|
46
|
+
".mail-tok-deleted{color:#ffa198 !important;}",
|
|
47
|
+
].join("");
|
|
48
|
+
/**
|
|
49
|
+
* Builds the document stylesheet: the kit's dark mode, then whatever the caller
|
|
50
|
+
* adds. The result is a text node, so the renderer escapes it, ruling out `>` and
|
|
51
|
+
* `&` and any child combinator; descendant and class selectors cover everything.
|
|
52
|
+
*/
|
|
53
|
+
function stylesheet(fonts, extra) {
|
|
54
|
+
let faces = fonts.map(fontFace).join("");
|
|
55
|
+
let dark = `@media (prefers-color-scheme:dark){${DARK_RULES}${extra ?? ""}}`;
|
|
56
|
+
return `:root{color-scheme:light dark;}${faces}${dark}`;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* One `@font-face` rule. `mso-font-alt` is Outlook's own fallback declaration:
|
|
60
|
+
* Word ignores the `@font-face` rule but reads that property, keeping an
|
|
61
|
+
* unavailable family on the stack's chosen fallback instead of Times New Roman.
|
|
62
|
+
*/
|
|
63
|
+
function fontFace(font) {
|
|
64
|
+
let { family, fallback, src, weight = 400, style = "normal" } = font;
|
|
65
|
+
let source = src ? `src:url(${src.url}) format('${src.format}');` : "";
|
|
66
|
+
let alt = fallback.split(",")[0]?.trim() ?? "sans-serif";
|
|
67
|
+
return `@font-face{font-family:'${family}';font-style:${style};font-weight:${weight};mso-font-alt:'${alt}';${source}}`;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The font stack a set of web fonts asks for: the first family, then its
|
|
71
|
+
* fallbacks, left unquoted because a `style` attribute escapes a quote to
|
|
72
|
+
* `'`, naming a family no font has — CSS has allowed bare names since 2.1.
|
|
73
|
+
*/
|
|
74
|
+
function fontStack(fonts) {
|
|
75
|
+
let first = fonts[0];
|
|
76
|
+
if (!first)
|
|
77
|
+
return undefined;
|
|
78
|
+
return `${first.family}, ${first.fallback}`;
|
|
79
|
+
}
|
|
80
|
+
/** How much of an inbox snippet a client will show before it stops reading. */
|
|
81
|
+
const PREVIEW_LENGTH = 200;
|
|
82
|
+
/**
|
|
83
|
+
* Characters that occupy a snippet without printing: no-break space, the zero-width
|
|
84
|
+
* non-joiner, space, joiner, the two directional marks, and the byte-order mark. Seven
|
|
85
|
+
* of them, cycled, so no client's duplicate-run collapsing shortens the padding.
|
|
86
|
+
*/
|
|
87
|
+
const BLANKS = " ";
|
|
88
|
+
/**
|
|
89
|
+
* Pads a preheader to the length a client reads, with characters that render as
|
|
90
|
+
* nothing, so the inbox snippet stops at the block instead of spilling into the
|
|
91
|
+
* body — an unpadded six-word preheader reads `... RECOVERED Monitor Book Landing`.
|
|
92
|
+
*/
|
|
93
|
+
function previewPadding(preview) {
|
|
94
|
+
let missing = PREVIEW_LENGTH - preview.length;
|
|
95
|
+
if (missing <= 0)
|
|
96
|
+
return "";
|
|
97
|
+
return BLANKS.repeat(Math.ceil(missing / BLANKS.length)).slice(0, missing);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Wraps an email body in a centered, fixed-width card of inline-styled tables,
|
|
101
|
+
* with colors as props so the kit stays unbranded, and renders the document's
|
|
102
|
+
* only stylesheet — the dark counterpart of its colors; see {@link DARK_RULES}.
|
|
103
|
+
*
|
|
104
|
+
* @example <Email.Layout preview="Your invite is ready"><Email.Text>Hi</Email.Text></Email.Layout>
|
|
105
|
+
*/
|
|
106
|
+
export function Layout(handle) {
|
|
107
|
+
return () => {
|
|
108
|
+
let { children, preview, logo, title, lang = "en", background, surface, color, fontFamily, fonts = [], width = CONTENT_WIDTH, darkStyles, } = handle.props;
|
|
109
|
+
let family = fontFamily ?? fontStack(fonts) ?? FONT_FAMILY;
|
|
110
|
+
let page = background ?? PAGE_COLOR;
|
|
111
|
+
let card = surface ?? SURFACE_COLOR;
|
|
112
|
+
let ink = color ?? TEXT_COLOR;
|
|
113
|
+
let pageClass = background === undefined ? "mail-page" : undefined;
|
|
114
|
+
let cardClass = surface === undefined ? "mail-surface" : undefined;
|
|
115
|
+
let inkClass = color === undefined ? "mail-text" : undefined;
|
|
116
|
+
return (_jsxs("html", { lang: lang, children: [_jsxs("head", { children: [_jsx("meta", { charset: "utf-8" }), _jsx("meta", { name: "viewport", content: "width=device-width, initial-scale=1" }), _jsx("meta", { name: "color-scheme", content: "light dark" }), _jsx("meta", { name: "supported-color-schemes", content: "light dark" }), _jsx("meta", { name: "x-apple-disable-message-reformatting" }), title ? _jsx("title", { children: title }) : null, _jsx("style", { children: stylesheet(fonts, darkStyles) })] }), _jsxs("body", { class: pageClass, style: `margin:0;padding:0;width:100%;background-color:${page};color:${ink};font-family:${family};`, children: [preview ? (_jsxs("div", { "data-skip-in-text": true, style: "display:none;max-height:0;max-width:0;overflow:hidden;opacity:0;font-size:1px;line-height:1px;", children: [preview, previewPadding(preview)] })) : null, _jsx("table", { role: "presentation", width: "100%", cellPadding: "0", cellSpacing: "0", class: pageClass, style: `width:100%;background-color:${page};`, children: _jsx("tbody", { children: _jsx("tr", { children: _jsx("td", { align: "center", class: inkClass, style: `padding:24px 12px;color:${ink};font-family:${family};`, children: _jsx("table", { role: "presentation", width: width, cellPadding: "0", cellSpacing: "0", class: cardClass, style: `width:100%;max-width:${width}px;background-color:${card};border-radius:8px;`, children: _jsxs("tbody", { children: [logo ? (_jsx("tr", { children: _jsx("td", { style: "padding:24px 24px 0;", children: _jsx("img", { src: logo.src, alt: logo.alt, width: logo.width ?? 32, style: "display:block;border:0;" }) }) })) : null, _jsx("tr", { children: _jsx("td", { class: inkClass, style: `padding:24px;font-family:${family};color:${ink};font-size:16px;line-height:1.6;`, children: children }) })] }) }) }) }) }) })] })] }));
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Renders a heading with the margin collapsed to zero on top, because mail clients
|
|
121
|
+
* disagree on default heading margins and an inline reset is the only fix that holds.
|
|
122
|
+
*
|
|
123
|
+
* @example <Email.Heading level={2}>You have been invited</Email.Heading>
|
|
124
|
+
*/
|
|
125
|
+
export function Heading(handle) {
|
|
126
|
+
return () => {
|
|
127
|
+
let { children, level = 1, color, align = "left" } = handle.props;
|
|
128
|
+
let className = color === undefined ? "mail-text" : undefined;
|
|
129
|
+
let style = `margin:0 0 16px;padding:0;font-family:inherit;font-size:${HEADING_SIZES[level]}px;line-height:1.3;font-weight:600;color:${color ?? TEXT_COLOR};text-align:${align};`;
|
|
130
|
+
if (level === 3)
|
|
131
|
+
return (_jsx("h3", { class: className, style: style, children: children }));
|
|
132
|
+
if (level === 2)
|
|
133
|
+
return (_jsx("h2", { class: className, style: style, children: children }));
|
|
134
|
+
return (_jsx("h1", { class: className, style: style, children: children }));
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Renders a paragraph of body copy with an explicit line height, since clients
|
|
139
|
+
* that strip the document styles fall back to a cramped default otherwise.
|
|
140
|
+
*
|
|
141
|
+
* @example <Email.Text muted>This link expires in 24 hours.</Email.Text>
|
|
142
|
+
*/
|
|
143
|
+
export function Text(handle) {
|
|
144
|
+
return () => {
|
|
145
|
+
let { children, muted = false, color, size = 16, align = "left" } = handle.props;
|
|
146
|
+
let resolved = color ?? (muted ? MUTED_COLOR : TEXT_COLOR);
|
|
147
|
+
let className = color === undefined ? (muted ? "mail-muted" : "mail-text") : undefined;
|
|
148
|
+
return (_jsx("p", { class: className, style: `margin:0 0 16px;padding:0;font-family:inherit;font-size:${size}px;line-height:1.6;color:${resolved};text-align:${align};`, children: children }));
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Renders a call-to-action as a link inside a single-cell table so the fill
|
|
153
|
+
* survives in clients that drop CSS backgrounds on anchors, with the padding
|
|
154
|
+
* on the cell since Word supports neither inline-block nor padding on an anchor.
|
|
155
|
+
*
|
|
156
|
+
* @example <Email.Button href={url}>Accept invite</Email.Button>
|
|
157
|
+
*/
|
|
158
|
+
export function Button(handle) {
|
|
159
|
+
return () => {
|
|
160
|
+
let { href, children, background, color, radius = 6 } = handle.props;
|
|
161
|
+
let fillClass = background === undefined ? "mail-action" : undefined;
|
|
162
|
+
let labelClass = color === undefined ? "mail-action-label" : undefined;
|
|
163
|
+
return (_jsx("table", { role: "presentation", cellPadding: "0", cellSpacing: "0", style: "margin:0 0 16px;", children: _jsx("tbody", { children: _jsx("tr", { children: _jsx("td", { align: "center", class: fillClass, style: `padding:12px 20px;background-color:${background ?? ACTION_COLOR};border-radius:${radius}px;`, children: _jsx("a", { href: href, class: labelClass, style: `display:inline-block;font-family:inherit;font-size:16px;line-height:1.2;font-weight:600;color:${color ?? ACTION_LABEL_COLOR};text-decoration:none;`, children: children }) }) }) }) }));
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* A set of facts as a two-column table: label on the left, value against the
|
|
168
|
+
* right, with hairlines between rows — a scannable column beats one paragraph
|
|
169
|
+
* per fact, and stays a layout every mail client renders alike.
|
|
170
|
+
*
|
|
171
|
+
* @example <Email.Table rows={[{ label: "Status", value: "Up" }]} />
|
|
172
|
+
*/
|
|
173
|
+
export function Table(handle) {
|
|
174
|
+
return () => {
|
|
175
|
+
let { rows, borderColor } = handle.props;
|
|
176
|
+
if (rows.length === 0)
|
|
177
|
+
return null;
|
|
178
|
+
let rule = borderColor === undefined ? " mail-rule" : "";
|
|
179
|
+
return (_jsx("table", { role: "presentation", width: "100%", cellPadding: "0", cellSpacing: "0", style: "width:100%;margin:0 0 16px;border-collapse:collapse;", children: _jsx("tbody", { children: rows.map((row, index) => {
|
|
180
|
+
let border = index === 0 ? "none" : `1px solid ${borderColor ?? BORDER_COLOR}`;
|
|
181
|
+
return (_jsxs("tr", { children: [_jsx("td", { class: `mail-muted${rule}`, style: `padding:10px 12px 10px 0;border-top:${border};font-family:inherit;font-size:14px;line-height:1.4;color:${MUTED_COLOR};white-space:nowrap;vertical-align:top;`, children: row.label }), _jsx("td", { align: "right", class: `mail-text${rule}`, style: `padding:10px 0;border-top:${border};font-family:inherit;font-size:14px;line-height:1.4;font-weight:600;color:${TEXT_COLOR};text-align:right;word-break:break-word;vertical-align:top;`, children: row.value })] }, row.label));
|
|
182
|
+
}) }) }));
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Closes an email with de-emphasized copy under a hairline. Content is entirely a
|
|
187
|
+
* prop, so the kit never ships a company name, address, or unsubscribe wording.
|
|
188
|
+
*
|
|
189
|
+
* @example <Email.Footer>Sent because you were invited to a team.</Email.Footer>
|
|
190
|
+
*/
|
|
191
|
+
export function Footer(handle) {
|
|
192
|
+
return () => {
|
|
193
|
+
let { children, color, borderColor } = handle.props;
|
|
194
|
+
return (_jsx("table", { role: "presentation", width: "100%", cellPadding: "0", cellSpacing: "0", style: "width:100%;", children: _jsx("tbody", { children: _jsx("tr", { children: _jsx("td", { class: borderColor === undefined ? "mail-rule" : undefined, style: `padding:16px 0 0;border-top:1px solid ${borderColor ?? BORDER_COLOR};`, children: _jsx("div", { class: color === undefined ? "mail-muted" : undefined, style: `font-family:inherit;font-size:12px;line-height:1.5;color:${color ?? MUTED_COLOR};`, children: children }) }) }) }) }));
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* A full-width band of content, as a single-cell table. Padding sits on the
|
|
199
|
+
* cell and the fill on the table — the split Outlook needs, since it drops
|
|
200
|
+
* padding declared directly on a `<table>`, so the fill paints the whole band.
|
|
201
|
+
*
|
|
202
|
+
* @example <Email.Section padding="24px 0"><Email.Text>Grouped copy</Email.Text></Email.Section>
|
|
203
|
+
*/
|
|
204
|
+
export function Section(handle) {
|
|
205
|
+
return () => {
|
|
206
|
+
let { children, padding, background, align = "left" } = handle.props;
|
|
207
|
+
let fill = background ? `background-color:${background};` : "";
|
|
208
|
+
return (_jsx("table", { role: "presentation", width: "100%", cellPadding: "0", cellSpacing: "0", style: `width:100%;${fill}`, children: _jsx("tbody", { children: _jsx("tr", { children: _jsx("td", { align: align, style: `${padding ? `padding:${padding};` : ""}font-family:inherit;text-align:${align};`, children: children }) }) }) }));
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Puts its columns side by side as one table row, the one horizontal
|
|
213
|
+
* arrangement no client gets wrong, since Outlook supports neither flex nor
|
|
214
|
+
* grid; width stays fixed, so keep the column count low for phones.
|
|
215
|
+
*
|
|
216
|
+
* @example <Email.Row><Email.Column width="50%">Left</Email.Column><Email.Column>Right</Email.Column></Email.Row>
|
|
217
|
+
*/
|
|
218
|
+
export function Row(handle) {
|
|
219
|
+
return () => {
|
|
220
|
+
let { children, gap = 16 } = handle.props;
|
|
221
|
+
return (_jsx("table", { role: "presentation", width: "100%", cellPadding: "0", cellSpacing: "0", style: `width:100%;margin:0 0 ${gap}px;border-collapse:collapse;`, children: _jsx("tbody", { children: _jsx("tr", { children: children }) }) }));
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* One cell of a {@link Row}.
|
|
226
|
+
*
|
|
227
|
+
* The width is set as both the `width` attribute and a style, because Outlook reads
|
|
228
|
+
* the attribute and ignores the style while everything newer does the reverse.
|
|
229
|
+
*
|
|
230
|
+
* @example <Email.Column width="120" valign="top"><Email.Img src={logo} alt="Acme" /></Email.Column>
|
|
231
|
+
*/
|
|
232
|
+
export function Column(handle) {
|
|
233
|
+
return () => {
|
|
234
|
+
let { children, width, align = "left", valign = "top", padding } = handle.props;
|
|
235
|
+
let length = typeof width === "number" ? `${width}px` : width;
|
|
236
|
+
return (_jsx("td", { align: align, valign: valign, width: width, style: `${length ? `width:${length};` : ""}${padding ? `padding:${padding};` : ""}font-family:inherit;text-align:${align};vertical-align:${valign};`, children: children }));
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* An inline link, opening in a new tab, whose color is inherited so it always
|
|
241
|
+
* matches the copy around it and never needs its own entry in the dark rules;
|
|
242
|
+
* the underline alone marks it as a link, as a mail reader expects.
|
|
243
|
+
*
|
|
244
|
+
* @example <Email.Link href={url}>the settings page</Email.Link>
|
|
245
|
+
*/
|
|
246
|
+
export function Link(handle) {
|
|
247
|
+
return () => {
|
|
248
|
+
let { href, children, color, underline = true } = handle.props;
|
|
249
|
+
let decoration = underline ? "underline" : "none";
|
|
250
|
+
return (_jsx("a", { href: href, target: "_blank", rel: "noopener noreferrer", style: `color:${color ?? "inherit"};text-decoration:${decoration};font-family:inherit;`, children: children }));
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* An image with the four resets an inbox needs: `display:block` so no line-height gap
|
|
255
|
+
* opens underneath it, and a cleared border, outline and underline so a client that
|
|
256
|
+
* inherits link styling leaves it unframed.
|
|
257
|
+
*
|
|
258
|
+
* @example <Email.Img src="https://acme.com/logo.png" alt="Acme" width={120} />
|
|
259
|
+
*/
|
|
260
|
+
export function Img(handle) {
|
|
261
|
+
return () => {
|
|
262
|
+
let { src, alt, width, height, radius, gap } = handle.props;
|
|
263
|
+
return (_jsx("img", { src: src, alt: alt, width: width, height: height, style: `display:block;border:0;outline:none;text-decoration:none;max-width:100%;${radius ? `border-radius:${radius}px;` : ""}${gap ? `margin:0 0 ${gap}px;` : ""}` }));
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* A horizontal rule drawn as a top border, since clients disagree about what an
|
|
268
|
+
* `<hr>` looks like by default and several render the native one as an inset
|
|
269
|
+
* two-tone groove.
|
|
270
|
+
*
|
|
271
|
+
* @example <Email.Hr />
|
|
272
|
+
*/
|
|
273
|
+
export function Hr(handle) {
|
|
274
|
+
return () => {
|
|
275
|
+
let { color, gap = 24 } = handle.props;
|
|
276
|
+
return (_jsx("hr", { class: color === undefined ? "mail-rule" : undefined, style: `width:100%;margin:${gap}px 0;padding:0;border:none;border-top:1px solid ${color ?? BORDER_COLOR};` }));
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
/** Monospace stack, chosen so no family needs quoting inside a `style` attribute. */
|
|
280
|
+
export const MONO_FAMILY = "ui-monospace, SFMono-Regular, Menlo, Consolas, Liberation Mono, monospace";
|
|
281
|
+
/** Fill behind code, light enough to sit inside the card without reading as a block. */
|
|
282
|
+
export const CODE_COLOR = "#f4f4f5";
|
|
283
|
+
/**
|
|
284
|
+
* A short run of code inside a sentence.
|
|
285
|
+
*
|
|
286
|
+
* The size is `0.9em` rather than a pixel value so it tracks whatever it is set inside —
|
|
287
|
+
* a heading, body copy, a footer — instead of turning into body-sized code in a caption.
|
|
288
|
+
*
|
|
289
|
+
* @example <Email.Text>Set <Email.CodeInline>DEBUG=1</Email.CodeInline> and retry.</Email.Text>
|
|
290
|
+
*/
|
|
291
|
+
export function CodeInline(handle) {
|
|
292
|
+
return () => (_jsx("code", { class: "mail-code", style: `font-family:${MONO_FAMILY};font-size:0.9em;padding:2px 5px;border-radius:4px;background-color:${CODE_COLOR};color:${TEXT_COLOR};`, children: handle.props.children }));
|
|
293
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single error type the mail package reports. Every failure a caller can
|
|
3
|
+
* branch on — invalid message, failed render, rejected delivery — arrives as a
|
|
4
|
+
* `MailError` inside a `Result`, so no send path throws.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Error carried by a failed send. The original provider or render error is kept
|
|
11
|
+
* as `cause`, so a log line can report the root problem directly from it.
|
|
12
|
+
*/
|
|
13
|
+
export declare class MailError extends Error {
|
|
14
|
+
name: string;
|
|
15
|
+
/**
|
|
16
|
+
* Creates a mail error.
|
|
17
|
+
*
|
|
18
|
+
* @param message - Human-readable description of what went wrong.
|
|
19
|
+
* @param options - Standard error options; pass `cause` to keep the original error.
|
|
20
|
+
*/
|
|
21
|
+
constructor(message: string, options?: ErrorOptions);
|
|
22
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single error type the mail package reports. Every failure a caller can
|
|
3
|
+
* branch on — invalid message, failed render, rejected delivery — arrives as a
|
|
4
|
+
* `MailError` inside a `Result`, so no send path throws.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Error carried by a failed send. The original provider or render error is kept
|
|
11
|
+
* as `cause`, so a log line can report the root problem directly from it.
|
|
12
|
+
*/
|
|
13
|
+
export class MailError extends Error {
|
|
14
|
+
name = "MailError";
|
|
15
|
+
/**
|
|
16
|
+
* Creates a mail error.
|
|
17
|
+
*
|
|
18
|
+
* @param message - Human-readable description of what went wrong.
|
|
19
|
+
* @param options - Standard error options; pass `cause` to keep the original error.
|
|
20
|
+
*/
|
|
21
|
+
constructor(message, options) {
|
|
22
|
+
super(message, options);
|
|
23
|
+
}
|
|
24
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport-agnostic transactional mail: the message contract, the mailer that
|
|
3
|
+
* normalizes and delivers it, the rendering layer that turns a `remix/ui` tree into
|
|
4
|
+
* both body parts, and the unbranded layout kit for authoring one. Transports live
|
|
5
|
+
* behind their own subpaths so importing one never pulls another's dependency.
|
|
6
|
+
*
|
|
7
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
8
|
+
* @copyright Sergio Xalambrí 2026
|
|
9
|
+
*/
|
|
10
|
+
import type { Email as EmailContract } from "./types.js";
|
|
11
|
+
import * as EmailComponents from "./components.js";
|
|
12
|
+
export type { Address, Message, NormalizedMessage, SentMessage, Transport } from "./types.js";
|
|
13
|
+
export type { MailerOptions, SendOptions } from "./mailer.js";
|
|
14
|
+
export type { RenderedEmail } from "./render.js";
|
|
15
|
+
/**
|
|
16
|
+
* One row of an `Email.Table`. Exported under its own name because `Email` is a type
|
|
17
|
+
* alias in type space and a namespace object in value space, so `Email.Table.Row` only
|
|
18
|
+
* resolves in the second of those and an author writing the rows out needs the first.
|
|
19
|
+
*/
|
|
20
|
+
export type EmailTableRow = EmailComponents.Table.Row;
|
|
21
|
+
/** A web font for `Email.Layout` to declare, named here for the same reason as above. */
|
|
22
|
+
export type EmailFont = EmailComponents.Font;
|
|
23
|
+
export { MailError } from "./errors.js";
|
|
24
|
+
export { formatAddress, toAddressList } from "./lib/address.js";
|
|
25
|
+
export { htmlToText } from "./lib/html-to-text.js";
|
|
26
|
+
export { isEmail, Mailer } from "./mailer.js";
|
|
27
|
+
export { buildMimeMessage } from "./mime.js";
|
|
28
|
+
export { render } from "./render.js";
|
|
29
|
+
/**
|
|
30
|
+
* An email authored as a class: who it goes to, what it says, and what it
|
|
31
|
+
* looks like, handed straight to `send()` or `later()`. Declared as an alias
|
|
32
|
+
* so it can share the `Email` identifier with the layout kit below.
|
|
33
|
+
*/
|
|
34
|
+
export type Email = EmailContract;
|
|
35
|
+
/**
|
|
36
|
+
* Layout kit for email bodies, addressed as `Email.*` because this namespace
|
|
37
|
+
* shares its identifier with the `Email` type contract above. `Markdown` and
|
|
38
|
+
* `CodeBlock` live behind `@sdxc/mail/markdown` instead, shipped only where imported.
|
|
39
|
+
*/
|
|
40
|
+
export declare const Email: typeof EmailComponents;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport-agnostic transactional mail: the message contract, the mailer that
|
|
3
|
+
* normalizes and delivers it, the rendering layer that turns a `remix/ui` tree into
|
|
4
|
+
* both body parts, and the unbranded layout kit for authoring one. Transports live
|
|
5
|
+
* behind their own subpaths so importing one never pulls another's dependency.
|
|
6
|
+
*
|
|
7
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
8
|
+
* @copyright Sergio Xalambrí 2026
|
|
9
|
+
*/
|
|
10
|
+
import * as EmailComponents from "./components.js";
|
|
11
|
+
export { MailError } from "./errors.js";
|
|
12
|
+
export { formatAddress, toAddressList } from "./lib/address.js";
|
|
13
|
+
export { htmlToText } from "./lib/html-to-text.js";
|
|
14
|
+
export { isEmail, Mailer } from "./mailer.js";
|
|
15
|
+
export { buildMimeMessage } from "./mime.js";
|
|
16
|
+
export { render } from "./render.js";
|
|
17
|
+
/**
|
|
18
|
+
* Layout kit for email bodies, addressed as `Email.*` because this namespace
|
|
19
|
+
* shares its identifier with the `Email` type contract above. `Markdown` and
|
|
20
|
+
* `CodeBlock` live behind `@sdxc/mail/markdown` instead, shipped only where imported.
|
|
21
|
+
*/
|
|
22
|
+
export const Email = EmailComponents;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Address helpers used by the mailer's normalization and by transports that talk
|
|
3
|
+
* in address strings. They keep the single-or-list shape of the public `Message`
|
|
4
|
+
* out of every consumer and centralize how a display name is quoted.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { Address } from "../types.js";
|
|
10
|
+
/**
|
|
11
|
+
* Coerces the single-or-list shape callers write into the list shape transports
|
|
12
|
+
* read. A missing value normalizes to an empty list, keeping an omitted
|
|
13
|
+
* recipient a valid input.
|
|
14
|
+
*
|
|
15
|
+
* @param value - One address, several addresses, or nothing.
|
|
16
|
+
* @returns A new array; never the caller's own array, so later mutation cannot leak.
|
|
17
|
+
*/
|
|
18
|
+
export declare function toAddressList(value: Address | Address[] | undefined): Address[];
|
|
19
|
+
/**
|
|
20
|
+
* Formats an address as an RFC 5322 mailbox. A display name is quoted only when
|
|
21
|
+
* it contains characters that would otherwise change how the mailbox parses, so
|
|
22
|
+
* ordinary names stay readable in raw headers.
|
|
23
|
+
*
|
|
24
|
+
* @param address - The mailbox to format.
|
|
25
|
+
* @returns `user@example.com` without a name, `Name <user@example.com>` with one.
|
|
26
|
+
* @example formatAddress({ email: "a@b.com", name: "Ada" }); // 'Ada <a@b.com>'
|
|
27
|
+
*/
|
|
28
|
+
export declare function formatAddress(address: Address): string;
|
|
29
|
+
/**
|
|
30
|
+
* Reports whether an address is routable enough to hand to a provider. The check
|
|
31
|
+
* is structural: it rejects the mistakes that produce silent non-delivery — empty,
|
|
32
|
+
* unsplit, or whitespace-bearing values — while accepting any domain shape.
|
|
33
|
+
*
|
|
34
|
+
* @param address - The mailbox to check.
|
|
35
|
+
* @returns `true` when the address has a local part and a domain around one `@`.
|
|
36
|
+
*/
|
|
37
|
+
export declare function isValidAddress(address: Address): boolean;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Address helpers used by the mailer's normalization and by transports that talk
|
|
3
|
+
* in address strings. They keep the single-or-list shape of the public `Message`
|
|
4
|
+
* out of every consumer and centralize how a display name is quoted.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
/** Characters that force a display name to be quoted in a mailbox string. */
|
|
10
|
+
const SPECIAL_CHARACTERS = /[()<>[\]:;@\\,."]/;
|
|
11
|
+
/** Shape of an address that a provider can route: one `@`, no whitespace, both sides present. */
|
|
12
|
+
const MAILBOX = /^[^\s@]+@[^\s@]+$/;
|
|
13
|
+
/**
|
|
14
|
+
* Coerces the single-or-list shape callers write into the list shape transports
|
|
15
|
+
* read. A missing value normalizes to an empty list, keeping an omitted
|
|
16
|
+
* recipient a valid input.
|
|
17
|
+
*
|
|
18
|
+
* @param value - One address, several addresses, or nothing.
|
|
19
|
+
* @returns A new array; never the caller's own array, so later mutation cannot leak.
|
|
20
|
+
*/
|
|
21
|
+
export function toAddressList(value) {
|
|
22
|
+
if (value === undefined)
|
|
23
|
+
return [];
|
|
24
|
+
if (Array.isArray(value))
|
|
25
|
+
return [...value];
|
|
26
|
+
return [value];
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Formats an address as an RFC 5322 mailbox. A display name is quoted only when
|
|
30
|
+
* it contains characters that would otherwise change how the mailbox parses, so
|
|
31
|
+
* ordinary names stay readable in raw headers.
|
|
32
|
+
*
|
|
33
|
+
* @param address - The mailbox to format.
|
|
34
|
+
* @returns `user@example.com` without a name, `Name <user@example.com>` with one.
|
|
35
|
+
* @example formatAddress({ email: "a@b.com", name: "Ada" }); // 'Ada <a@b.com>'
|
|
36
|
+
*/
|
|
37
|
+
export function formatAddress(address) {
|
|
38
|
+
let name = address.name?.trim();
|
|
39
|
+
if (!name)
|
|
40
|
+
return address.email;
|
|
41
|
+
if (!SPECIAL_CHARACTERS.test(name))
|
|
42
|
+
return `${name} <${address.email}>`;
|
|
43
|
+
let quoted = name.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
|
|
44
|
+
return `"${quoted}" <${address.email}>`;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Reports whether an address is routable enough to hand to a provider. The check
|
|
48
|
+
* is structural: it rejects the mistakes that produce silent non-delivery — empty,
|
|
49
|
+
* unsplit, or whitespace-bearing values — while accepting any domain shape.
|
|
50
|
+
*
|
|
51
|
+
* @param address - The mailbox to check.
|
|
52
|
+
* @returns `true` when the address has a local part and a domain around one `@`.
|
|
53
|
+
*/
|
|
54
|
+
export function isValidAddress(address) {
|
|
55
|
+
return MAILBOX.test(address.email);
|
|
56
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derives the plain-text alternative of an email from its rendered HTML, so every
|
|
3
|
+
* message ships both parts without a second authoring step. The conversion is
|
|
4
|
+
* heuristic by design: it keeps link targets and block structure, and drops
|
|
5
|
+
* anything a reader cannot act on.
|
|
6
|
+
*
|
|
7
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
8
|
+
* @copyright Sergio Xalambrí 2026
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Converts rendered email HTML into its plain-text alternative, preserving link
|
|
12
|
+
* targets as `label (href)`, image alt text, and block structure as blank lines
|
|
13
|
+
* so the text part carries the same content the HTML conveys visually.
|
|
14
|
+
*
|
|
15
|
+
* @param html - Rendered HTML of an email body.
|
|
16
|
+
* @returns The plain-text alternative, trimmed and with runs of blank lines collapsed.
|
|
17
|
+
* @example htmlToText('<p>Hi <a href="https://x.dev">here</a></p>'); // "Hi here (https://x.dev)"
|
|
18
|
+
*/
|
|
19
|
+
export declare function htmlToText(html: string): string;
|