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