@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,71 @@
1
+ /**
2
+ * Transport for the Workers email sending binding, whose structured send call takes
3
+ * the same fields a normalized message already holds, so it maps them across and
4
+ * assembles no MIME of its own. The binding is injected rather than read from an
5
+ * ambient global, which keeps the platform surface a single seam this package can be
6
+ * tested and corrected at.
7
+ *
8
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
9
+ * @copyright Sergio Xalambrí 2026
10
+ */
11
+ import { failure, isFailure, success, wrap } from "@sdxc/result";
12
+ import { MailError } from "./errors.js";
13
+ import { formatAddress } from "./lib/address.js";
14
+ /** Returns the formatted list, or `undefined` when empty so an absent field is omitted. */
15
+ function optionalAddresses(addresses) {
16
+ if (addresses.length === 0)
17
+ return undefined;
18
+ return addresses.map(formatAddress);
19
+ }
20
+ /**
21
+ * Maps a normalized message onto the binding's send payload. Only the first
22
+ * reply-to mailbox survives, since the platform's field holds one address, so
23
+ * every shipped `Reply-To` is one the platform actually wrote itself.
24
+ */
25
+ function toPayload(message) {
26
+ let replyTo = message.replyTo.at(0);
27
+ return {
28
+ from: formatAddress(message.from),
29
+ to: message.to.map(formatAddress),
30
+ cc: optionalAddresses(message.cc),
31
+ bcc: optionalAddresses(message.bcc),
32
+ replyTo: replyTo && formatAddress(replyTo),
33
+ subject: message.subject,
34
+ text: message.text,
35
+ html: message.html,
36
+ headers: message.headers,
37
+ };
38
+ }
39
+ /**
40
+ * Transport that delivers through the Workers email sending binding, which writes
41
+ * its own `Date` and `Message-ID` and returns the identifier its delivery logs are
42
+ * keyed by, enforcing sender and destination limits from its configuration on send.
43
+ *
44
+ * @example
45
+ * let transport = new CloudflareTransport(env.EMAIL);
46
+ * let mailer = new Mailer({ transport, from: SENDER });
47
+ */
48
+ export class CloudflareTransport {
49
+ binding;
50
+ /**
51
+ * Creates the transport around an app's binding.
52
+ *
53
+ * @param binding - The `send_email` binding declared in the app's Wrangler configuration.
54
+ */
55
+ constructor(binding) {
56
+ this.binding = binding;
57
+ }
58
+ /**
59
+ * Hands the message's fields to the binding, which composes and delivers it.
60
+ *
61
+ * @param message - The normalized message to deliver.
62
+ * @returns The identifier the platform assigned on success, a `MailError` on rejection.
63
+ */
64
+ async send(message) {
65
+ let outcome = await wrap(() => this.binding.send(toPayload(message)));
66
+ if (isFailure(outcome)) {
67
+ return failure(new MailError("The mail binding rejected the message.", { cause: outcome.error }));
68
+ }
69
+ return success({ messageId: outcome.data.messageId });
70
+ }
71
+ }
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Layout kit for email bodies, built for the constraints mail clients impose:
3
+ * table layout, inline styles, and no external stylesheet. It carries no product
4
+ * branding — colors, logo, and footer content are props — so every app styles its
5
+ * own mail without forking the components.
6
+ *
7
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
8
+ * @copyright Sergio Xalambrí 2026
9
+ */
10
+ import type { Handle, RemixNode } from "remix/ui";
11
+ /**
12
+ * A web font to load, and what to use instead everywhere it does not arrive. It
13
+ * is a prop of {@link Layout} instead of its own component because `@font-face`
14
+ * only works inside `<head>`, which only the layout renders in the document.
15
+ */
16
+ export interface Font {
17
+ /** Family name, as the `@font-face` rule declares it and the copy asks for it. */
18
+ family: string;
19
+ /**
20
+ * What to fall back to, as a CSS font list. It is required because most readers get
21
+ * it: `@font-face` is unsupported in Gmail, Yahoo, and Outlook on Windows, which is
22
+ * most of any list, so this is the font the email is actually set in.
23
+ */
24
+ fallback: string;
25
+ /** Where to fetch the font, omitted for a family the reader is assumed to have. */
26
+ src?: {
27
+ /** Absolute URL of the font file. */
28
+ url: string;
29
+ /** Format at that URL, which the rule has to name for the client to accept it. */
30
+ format: "woff" | "woff2" | "truetype" | "opentype" | "embedded-opentype" | "svg";
31
+ };
32
+ /** Weight this file provides. */
33
+ weight?: number;
34
+ /** Style this file provides. */
35
+ style?: "normal" | "italic";
36
+ }
37
+ export declare namespace Layout {
38
+ /** Props accepted by {@link Layout}. */
39
+ interface Props {
40
+ /** Body of the email, usually headings, text, and a button. */
41
+ children?: RemixNode;
42
+ /**
43
+ * Preheader shown beside the subject in inbox previews. It is hidden in the
44
+ * body and dropped from the plain-text part, so it never reads as duplicate copy.
45
+ */
46
+ preview?: string;
47
+ /** Logo rendered above the content; omitted means no logo. */
48
+ logo?: {
49
+ src: string;
50
+ alt: string;
51
+ width?: number;
52
+ };
53
+ /** Document title, which some clients show when the email is opened in a browser. */
54
+ title?: string;
55
+ /** Language of the copy, so screen readers pronounce it correctly. */
56
+ lang?: string;
57
+ /** Page color behind the content card. */
58
+ background?: string;
59
+ /** Content card color. */
60
+ surface?: string;
61
+ /** Body copy color. */
62
+ color?: string;
63
+ /**
64
+ * Font stack applied to the whole document. Omitted with `fonts` set, the stack is
65
+ * built from the first of those; omitted with neither, the kit's own stack is used.
66
+ */
67
+ fontFamily?: string;
68
+ /** Web fonts to declare, in preference order. */
69
+ fonts?: Font[];
70
+ /** Content width in pixels. */
71
+ width?: number;
72
+ /**
73
+ * CSS appended inside the kit's `prefers-color-scheme: dark` block, for the dark
74
+ * counterpart of colors the app's own components paint with. It is escaped as a
75
+ * text node, so it cannot contain `>` or `&`.
76
+ */
77
+ darkStyles?: string;
78
+ }
79
+ }
80
+ /**
81
+ * Wraps an email body in a centered, fixed-width card of inline-styled tables,
82
+ * with colors as props so the kit stays unbranded, and renders the document's
83
+ * only stylesheet — the dark counterpart of its colors; see {@link DARK_RULES}.
84
+ *
85
+ * @example <Email.Layout preview="Your invite is ready"><Email.Text>Hi</Email.Text></Email.Layout>
86
+ */
87
+ export declare function Layout(handle: Handle<Layout.Props>): () => import("remix/ui").RemixElement;
88
+ export declare namespace Heading {
89
+ /** Props accepted by {@link Heading}. */
90
+ interface Props {
91
+ /** Heading copy. */
92
+ children?: RemixNode;
93
+ /** Outline level, which also picks the font size. */
94
+ level?: 1 | 2 | 3;
95
+ /** Heading color. */
96
+ color?: string;
97
+ /** Horizontal alignment of the copy. */
98
+ align?: "left" | "center" | "right";
99
+ }
100
+ }
101
+ /**
102
+ * Renders a heading with the margin collapsed to zero on top, because mail clients
103
+ * disagree on default heading margins and an inline reset is the only fix that holds.
104
+ *
105
+ * @example <Email.Heading level={2}>You have been invited</Email.Heading>
106
+ */
107
+ export declare function Heading(handle: Handle<Heading.Props>): () => import("remix/ui").RemixElement;
108
+ export declare namespace Text {
109
+ /** Props accepted by {@link Text}. */
110
+ interface Props {
111
+ /** Paragraph copy. */
112
+ children?: RemixNode;
113
+ /** Copy color; defaults to the body color, or the muted one when `muted` is set. */
114
+ color?: string;
115
+ /** Renders the paragraph in the de-emphasized color. */
116
+ muted?: boolean;
117
+ /** Font size in pixels. */
118
+ size?: number;
119
+ /** Horizontal alignment of the copy. */
120
+ align?: "left" | "center" | "right";
121
+ }
122
+ }
123
+ /**
124
+ * Renders a paragraph of body copy with an explicit line height, since clients
125
+ * that strip the document styles fall back to a cramped default otherwise.
126
+ *
127
+ * @example <Email.Text muted>This link expires in 24 hours.</Email.Text>
128
+ */
129
+ export declare function Text(handle: Handle<Text.Props>): () => import("remix/ui").RemixElement;
130
+ export declare namespace Button {
131
+ /** Props accepted by {@link Button}. */
132
+ interface Props {
133
+ /** Target of the call to action. */
134
+ href: string;
135
+ /** Button label. */
136
+ children?: RemixNode;
137
+ /** Fill color of the button. */
138
+ background?: string;
139
+ /** Label color of the button. */
140
+ color?: string;
141
+ /** Corner radius in pixels. */
142
+ radius?: number;
143
+ }
144
+ }
145
+ /**
146
+ * Renders a call-to-action as a link inside a single-cell table so the fill
147
+ * survives in clients that drop CSS backgrounds on anchors, with the padding
148
+ * on the cell since Word supports neither inline-block nor padding on an anchor.
149
+ *
150
+ * @example <Email.Button href={url}>Accept invite</Email.Button>
151
+ */
152
+ export declare function Button(handle: Handle<Button.Props>): () => import("remix/ui").RemixElement;
153
+ export declare namespace Table {
154
+ /** One row: what is being reported, and what it says. */
155
+ interface Row {
156
+ /** Name of the thing reported, rendered in the de-emphasized color. */
157
+ label: string;
158
+ /** The value as the reader sees it, already formatted and translated. */
159
+ value: RemixNode;
160
+ }
161
+ /** Props accepted by {@link Table}. */
162
+ interface Props {
163
+ /** Rows in reading order. Rendering nothing for an empty list is deliberate. */
164
+ rows: Row[];
165
+ /** Color of the hairlines between rows. */
166
+ borderColor?: string;
167
+ }
168
+ }
169
+ /**
170
+ * A set of facts as a two-column table: label on the left, value against the
171
+ * right, with hairlines between rows — a scannable column beats one paragraph
172
+ * per fact, and stays a layout every mail client renders alike.
173
+ *
174
+ * @example <Email.Table rows={[{ label: "Status", value: "Up" }]} />
175
+ */
176
+ export declare function Table(handle: Handle<Table.Props>): () => import("remix/ui").RemixElement | null;
177
+ export declare namespace Footer {
178
+ /** Props accepted by {@link Footer}. */
179
+ interface Props {
180
+ /** Footer content, such as an address, an unsubscribe link, or a legal note. */
181
+ children?: RemixNode;
182
+ /** Footer copy color. */
183
+ color?: string;
184
+ /** Color of the hairline above the footer. */
185
+ borderColor?: string;
186
+ }
187
+ }
188
+ /**
189
+ * Closes an email with de-emphasized copy under a hairline. Content is entirely a
190
+ * prop, so the kit never ships a company name, address, or unsubscribe wording.
191
+ *
192
+ * @example <Email.Footer>Sent because you were invited to a team.</Email.Footer>
193
+ */
194
+ export declare function Footer(handle: Handle<Footer.Props>): () => import("remix/ui").RemixElement;
195
+ export declare namespace Section {
196
+ /** Props accepted by {@link Section}. */
197
+ interface Props {
198
+ /** Content of the section. */
199
+ children?: RemixNode;
200
+ /** Padding shorthand applied inside the section, in CSS syntax. */
201
+ padding?: string;
202
+ /** Fill behind the section; omitted means it inherits whatever it sits on. */
203
+ background?: string;
204
+ /** Horizontal alignment of the content. */
205
+ align?: "left" | "center" | "right";
206
+ }
207
+ }
208
+ /**
209
+ * A full-width band of content, as a single-cell table. Padding sits on the
210
+ * cell and the fill on the table — the split Outlook needs, since it drops
211
+ * padding declared directly on a `<table>`, so the fill paints the whole band.
212
+ *
213
+ * @example <Email.Section padding="24px 0"><Email.Text>Grouped copy</Email.Text></Email.Section>
214
+ */
215
+ export declare function Section(handle: Handle<Section.Props>): () => import("remix/ui").RemixElement;
216
+ export declare namespace Row {
217
+ /** Props accepted by {@link Row}. */
218
+ interface Props {
219
+ /** The row's cells, which should be {@link Column} elements. */
220
+ children?: RemixNode;
221
+ /** Space below the row in pixels. */
222
+ gap?: number;
223
+ }
224
+ }
225
+ /**
226
+ * Puts its columns side by side as one table row, the one horizontal
227
+ * arrangement no client gets wrong, since Outlook supports neither flex nor
228
+ * grid; width stays fixed, so keep the column count low for phones.
229
+ *
230
+ * @example <Email.Row><Email.Column width="50%">Left</Email.Column><Email.Column>Right</Email.Column></Email.Row>
231
+ */
232
+ export declare function Row(handle: Handle<Row.Props>): () => import("remix/ui").RemixElement;
233
+ export declare namespace Column {
234
+ /** Props accepted by {@link Column}. */
235
+ interface Props {
236
+ /** Content of the cell. */
237
+ children?: RemixNode;
238
+ /**
239
+ * Width as a CSS length or percentage; a bare number is read as pixels. Omitted
240
+ * lets the table divide the space.
241
+ */
242
+ width?: string | number;
243
+ /** Horizontal alignment of the content. */
244
+ align?: "left" | "center" | "right";
245
+ /** Vertical alignment of the content against the tallest cell in the row. */
246
+ valign?: "top" | "middle" | "bottom";
247
+ /** Padding shorthand inside the cell, in CSS syntax. */
248
+ padding?: string;
249
+ }
250
+ }
251
+ /**
252
+ * One cell of a {@link Row}.
253
+ *
254
+ * The width is set as both the `width` attribute and a style, because Outlook reads
255
+ * the attribute and ignores the style while everything newer does the reverse.
256
+ *
257
+ * @example <Email.Column width="120" valign="top"><Email.Img src={logo} alt="Acme" /></Email.Column>
258
+ */
259
+ export declare function Column(handle: Handle<Column.Props>): () => import("remix/ui").RemixElement;
260
+ export declare namespace Link {
261
+ /** Props accepted by {@link Link}. */
262
+ interface Props {
263
+ /** Where the link goes. */
264
+ href: string;
265
+ /** Link text. */
266
+ children?: RemixNode;
267
+ /** Link color; omitted inherits the surrounding copy, so dark mode carries it. */
268
+ color?: string;
269
+ /** Whether the link is underlined. */
270
+ underline?: boolean;
271
+ }
272
+ }
273
+ /**
274
+ * An inline link, opening in a new tab, whose color is inherited so it always
275
+ * matches the copy around it and never needs its own entry in the dark rules;
276
+ * the underline alone marks it as a link, as a mail reader expects.
277
+ *
278
+ * @example <Email.Link href={url}>the settings page</Email.Link>
279
+ */
280
+ export declare function Link(handle: Handle<Link.Props>): () => import("remix/ui").RemixElement;
281
+ export declare namespace Img {
282
+ /** Props accepted by {@link Img}. */
283
+ interface Props {
284
+ /** Absolute URL of the image; a relative one resolves against nothing in an inbox. */
285
+ src: string;
286
+ /**
287
+ * What the image says, for the reader who never sees it. Required since most
288
+ * readers are that reader — every major client blocks remote images until
289
+ * asked — so pass an empty string for an image that carries no meaning.
290
+ */
291
+ alt: string;
292
+ /** Width in pixels. */
293
+ width?: number;
294
+ /** Height in pixels; omitted lets the image keep its ratio. */
295
+ height?: number;
296
+ /** Corner radius in pixels. */
297
+ radius?: number;
298
+ /** Space below the image in pixels. */
299
+ gap?: number;
300
+ }
301
+ }
302
+ /**
303
+ * An image with the four resets an inbox needs: `display:block` so no line-height gap
304
+ * opens underneath it, and a cleared border, outline and underline so a client that
305
+ * inherits link styling leaves it unframed.
306
+ *
307
+ * @example <Email.Img src="https://acme.com/logo.png" alt="Acme" width={120} />
308
+ */
309
+ export declare function Img(handle: Handle<Img.Props>): () => import("remix/ui").RemixElement;
310
+ export declare namespace Hr {
311
+ /** Props accepted by {@link Hr}. */
312
+ interface Props {
313
+ /** Rule color. */
314
+ color?: string;
315
+ /** Space above and below the rule in pixels. */
316
+ gap?: number;
317
+ }
318
+ }
319
+ /**
320
+ * A horizontal rule drawn as a top border, since clients disagree about what an
321
+ * `<hr>` looks like by default and several render the native one as an inset
322
+ * two-tone groove.
323
+ *
324
+ * @example <Email.Hr />
325
+ */
326
+ export declare function Hr(handle: Handle<Hr.Props>): () => import("remix/ui").RemixElement;
327
+ /** Monospace stack, chosen so no family needs quoting inside a `style` attribute. */
328
+ export declare const MONO_FAMILY = "ui-monospace, SFMono-Regular, Menlo, Consolas, Liberation Mono, monospace";
329
+ /** Fill behind code, light enough to sit inside the card without reading as a block. */
330
+ export declare const CODE_COLOR = "#f4f4f5";
331
+ export declare namespace CodeInline {
332
+ /** Props accepted by {@link CodeInline}. */
333
+ interface Props {
334
+ /** The code, as text. */
335
+ children?: RemixNode;
336
+ }
337
+ }
338
+ /**
339
+ * A short run of code inside a sentence.
340
+ *
341
+ * The size is `0.9em` rather than a pixel value so it tracks whatever it is set inside —
342
+ * a heading, body copy, a footer — instead of turning into body-sized code in a caption.
343
+ *
344
+ * @example <Email.Text>Set <Email.CodeInline>DEBUG=1</Email.CodeInline> and retry.</Email.Text>
345
+ */
346
+ export declare function CodeInline(handle: Handle<CodeInline.Props>): () => import("remix/ui").RemixElement;