@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,149 @@
|
|
|
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
|
+
/** HTML comments, which never carry reader-visible content. */
|
|
11
|
+
const COMMENT = /<!--[\s\S]*?-->/g;
|
|
12
|
+
/** A document type declaration, which describes the document format. */
|
|
13
|
+
const DOCTYPE = /<!doctype[^>]*>/gi;
|
|
14
|
+
/**
|
|
15
|
+
* Blocks hidden from sighted readers with `display:none`, such as a preheader.
|
|
16
|
+
* Non-greedy matching closes at the first same-tag closing tag, which is correct
|
|
17
|
+
* for the hidden single-element blocks email layouts use.
|
|
18
|
+
*/
|
|
19
|
+
const HIDDEN_BLOCK = /<(div|span|p)\b[^>]*display\s*:\s*none[^>]*>[\s\S]*?<\/\1>/gi;
|
|
20
|
+
/**
|
|
21
|
+
* Blocks marked `data-skip-in-text`, the explicit signal an author gives that an
|
|
22
|
+
* element belongs to the HTML part alone, covering visible content such as a
|
|
23
|
+
* decorative rule, a spacer, or a logo's alt text.
|
|
24
|
+
*/
|
|
25
|
+
const SKIPPED_BLOCK = /<(\w+)\b[^>]*\bdata-skip-in-text\b[^>]*>[\s\S]*?<\/\1>/gi;
|
|
26
|
+
/** The same marker on an element that closes itself, which has nothing to drop between tags. */
|
|
27
|
+
const SKIPPED_VOID = /<\w+\b[^>]*\bdata-skip-in-text\b[^>]*\/?>/gi;
|
|
28
|
+
/** Elements whose content belongs to the document structure. */
|
|
29
|
+
const DROPPED_BLOCK = /<(head|script|style|title)\b[^>]*>[\s\S]*?<\/\1>/gi;
|
|
30
|
+
/** Explicit line breaks, the one inline element that carries layout meaning. */
|
|
31
|
+
const LINE_BREAK = /<br\s*\/?>/gi;
|
|
32
|
+
/** A link with its target, captured as double-quoted, single-quoted, or bare. */
|
|
33
|
+
const ANCHOR = /<a\b[^>]*href\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))[^>]*>([\s\S]*?)<\/a>/gi;
|
|
34
|
+
/** End of a list item, which separates entries with a single newline. */
|
|
35
|
+
const LIST_ITEM_END = /<\/li\s*>/gi;
|
|
36
|
+
/** Start of a list item, replaced by a bullet marker. */
|
|
37
|
+
const LIST_ITEM_START = /<li\b[^>]*>/gi;
|
|
38
|
+
/**
|
|
39
|
+
* An ordered list, whose items are numbered in the order they appear. Matching
|
|
40
|
+
* closes at the first same-tag closing tag, so a nested ordered list numbers
|
|
41
|
+
* as part of its parent's sequence.
|
|
42
|
+
*/
|
|
43
|
+
const ORDERED_LIST = /<ol\b[^>]*>[\s\S]*?<\/ol\s*>/gi;
|
|
44
|
+
/**
|
|
45
|
+
* An image, replaced by what it says. Alt text is the whole of an image in a text part,
|
|
46
|
+
* and often in the HTML one too: every major client blocks remote images until asked.
|
|
47
|
+
*/
|
|
48
|
+
const IMAGE = /<img\b[^>]*\balt\s*=\s*("([^"]*)"|'([^']*)')[^>]*>/gi;
|
|
49
|
+
/** Boundaries that read as a paragraph break. */
|
|
50
|
+
const PARAGRAPH_BOUNDARY = /<\/?(p|div|h[1-6]|table|ul|ol|dl|blockquote|section|article|header|footer|hr|address|pre|figure)\b[^>]*>/gi;
|
|
51
|
+
/** End of a table cell, which separates cells on the same line with a space. */
|
|
52
|
+
const CELL_END = /<\/(td|th)\s*>/gi;
|
|
53
|
+
/** End of a table row or definition entry, which ends the line so the next row starts immediately below it. */
|
|
54
|
+
const ROW_END = /<\/(tr|dt|dd|caption)\s*>/gi;
|
|
55
|
+
/** Any remaining tag, dropped once its structural meaning has been applied. */
|
|
56
|
+
const TAG = /<[^>]+>/g;
|
|
57
|
+
/** A character reference in named, decimal, or hexadecimal form. */
|
|
58
|
+
const ENTITY = /&(#\d+|#x[0-9a-f]+|[a-z]+);/gi;
|
|
59
|
+
/** Named references worth decoding: the ones a renderer emits or copy commonly uses. */
|
|
60
|
+
const NAMED_ENTITIES = {
|
|
61
|
+
amp: "&",
|
|
62
|
+
apos: "'",
|
|
63
|
+
gt: ">",
|
|
64
|
+
hellip: "…",
|
|
65
|
+
lt: "<",
|
|
66
|
+
mdash: "—",
|
|
67
|
+
nbsp: " ",
|
|
68
|
+
ndash: "–",
|
|
69
|
+
quot: '"',
|
|
70
|
+
};
|
|
71
|
+
/** Runs of horizontal whitespace, including the no-break space entities decode to. */
|
|
72
|
+
const HORIZONTAL_WHITESPACE = /[ \t\r\f\v\u00a0]+/g;
|
|
73
|
+
/** Three or more newlines, collapsed so structure never turns into empty screens. */
|
|
74
|
+
const EXTRA_NEWLINES = /\n{3,}/g;
|
|
75
|
+
/** Removes markup from a fragment that has already had its structure applied. */
|
|
76
|
+
function stripTags(html) {
|
|
77
|
+
return html.replace(TAG, "");
|
|
78
|
+
}
|
|
79
|
+
/** Resolves character references to the characters they stand for, leaving unknown ones intact. */
|
|
80
|
+
function decodeEntities(text) {
|
|
81
|
+
return text.replace(ENTITY, (match, reference) => {
|
|
82
|
+
let name = reference.toLowerCase();
|
|
83
|
+
let code = name.startsWith("#x")
|
|
84
|
+
? Number.parseInt(name.slice(2), 16)
|
|
85
|
+
: name.startsWith("#")
|
|
86
|
+
? Number.parseInt(name.slice(1), 10)
|
|
87
|
+
: Number.NaN;
|
|
88
|
+
if (Number.isNaN(code))
|
|
89
|
+
return NAMED_ENTITIES[name] ?? match;
|
|
90
|
+
if (code < 1 || code > 0x10ffff)
|
|
91
|
+
return match;
|
|
92
|
+
return String.fromCodePoint(code);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Renders a link as text. The target is kept beside the label because a plain-text
|
|
97
|
+
* reader has no other way to reach it, and dropped when the label already is the
|
|
98
|
+
* target so the URL is not printed twice.
|
|
99
|
+
*/
|
|
100
|
+
function formatLink(href, label) {
|
|
101
|
+
let target = href.trim();
|
|
102
|
+
let text = label.trim();
|
|
103
|
+
if (!target)
|
|
104
|
+
return text;
|
|
105
|
+
if (!text)
|
|
106
|
+
return target;
|
|
107
|
+
if (text === target || text === target.replace(/^mailto:/i, ""))
|
|
108
|
+
return text;
|
|
109
|
+
return `${text} (${target})`;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Converts rendered email HTML into its plain-text alternative, preserving link
|
|
113
|
+
* targets as `label (href)`, image alt text, and block structure as blank lines
|
|
114
|
+
* so the text part carries the same content the HTML conveys visually.
|
|
115
|
+
*
|
|
116
|
+
* @param html - Rendered HTML of an email body.
|
|
117
|
+
* @returns The plain-text alternative, trimmed and with runs of blank lines collapsed.
|
|
118
|
+
* @example htmlToText('<p>Hi <a href="https://x.dev">here</a></p>'); // "Hi here (https://x.dev)"
|
|
119
|
+
*/
|
|
120
|
+
export function htmlToText(html) {
|
|
121
|
+
let text = html
|
|
122
|
+
.replace(COMMENT, "")
|
|
123
|
+
.replace(DOCTYPE, "")
|
|
124
|
+
.replace(HIDDEN_BLOCK, "")
|
|
125
|
+
.replace(SKIPPED_BLOCK, "")
|
|
126
|
+
.replace(SKIPPED_VOID, "")
|
|
127
|
+
.replace(DROPPED_BLOCK, "")
|
|
128
|
+
.replace(LINE_BREAK, "\n")
|
|
129
|
+
.replace(ANCHOR, (_match, _target, double, single, bare, label) => formatLink(double ?? single ?? bare ?? "", stripTags(label)))
|
|
130
|
+
.replace(IMAGE, (_match, _quoted, double, single) => {
|
|
131
|
+
return double ?? single ?? "";
|
|
132
|
+
})
|
|
133
|
+
.replace(ORDERED_LIST, (list) => {
|
|
134
|
+
let position = 0;
|
|
135
|
+
return list.replace(LIST_ITEM_START, () => `${(position += 1)}. `);
|
|
136
|
+
})
|
|
137
|
+
.replace(LIST_ITEM_END, "\n")
|
|
138
|
+
.replace(LIST_ITEM_START, "- ")
|
|
139
|
+
.replace(CELL_END, " ")
|
|
140
|
+
.replace(ROW_END, "\n")
|
|
141
|
+
.replace(PARAGRAPH_BOUNDARY, "\n\n");
|
|
142
|
+
return decodeEntities(stripTags(text))
|
|
143
|
+
.replace(HORIZONTAL_WHITESPACE, " ")
|
|
144
|
+
.split("\n")
|
|
145
|
+
.map((line) => line.trim())
|
|
146
|
+
.join("\n")
|
|
147
|
+
.replace(EXTRA_NEWLINES, "\n\n")
|
|
148
|
+
.trim();
|
|
149
|
+
}
|
package/dist/mailer.d.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mailer: it owns normalization (defaults, address coercion, plain-text
|
|
3
|
+
* derivation, validation) and hands the result to a transport. Delivery is a
|
|
4
|
+
* value, so `send()` reports failure as a `Result` and never throws, and
|
|
5
|
+
* `later()` gives fire-and-forget mail a defined lifetime instead of an
|
|
6
|
+
* unawaited promise.
|
|
7
|
+
*
|
|
8
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
9
|
+
* @copyright Sergio Xalambrí 2026
|
|
10
|
+
*/
|
|
11
|
+
import type { Result } from "@sdxc/result";
|
|
12
|
+
import type { Address, Email, Message, SentMessage, Transport } from "./types.js";
|
|
13
|
+
import { MailError } from "./errors.js";
|
|
14
|
+
/**
|
|
15
|
+
* Fields that replace what a `Message` or an `Email` already provides for a single
|
|
16
|
+
* send, such as redirecting an email to a forwarded address.
|
|
17
|
+
*/
|
|
18
|
+
export type SendOptions = Partial<Message>;
|
|
19
|
+
/** Configuration every message a mailer sends inherits. */
|
|
20
|
+
export interface MailerOptions {
|
|
21
|
+
transport: Transport;
|
|
22
|
+
/** Sender identity for the app; a message may override it. */
|
|
23
|
+
from: Address;
|
|
24
|
+
/** Where replies go by default; a message or email may override it. */
|
|
25
|
+
replyTo?: Address | Address[];
|
|
26
|
+
/** Headers added to every message, with per-message headers winning. */
|
|
27
|
+
headers?: Record<string, string>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Reports whether a value is an `Email` rather than a plain `Message`.
|
|
31
|
+
* Discrimination is structural: a callable `body` is the one member only an email
|
|
32
|
+
* has, so anything else is a message.
|
|
33
|
+
*
|
|
34
|
+
* @param value - The value handed to `send()` or `later()`.
|
|
35
|
+
* @returns `true` when the value carries a callable `body`.
|
|
36
|
+
*/
|
|
37
|
+
export declare function isEmail(value: Message | Email): value is Email;
|
|
38
|
+
/**
|
|
39
|
+
* Sends mail through a transport, applying one app's sender identity to every
|
|
40
|
+
* message. The instance is request-scoped when `later()` is used, because the
|
|
41
|
+
* queue it fills belongs to whoever flushes it.
|
|
42
|
+
*/
|
|
43
|
+
export declare class Mailer {
|
|
44
|
+
#private;
|
|
45
|
+
/**
|
|
46
|
+
* Creates a mailer.
|
|
47
|
+
*
|
|
48
|
+
* @param options - Transport and sender configuration; see {@link MailerOptions}.
|
|
49
|
+
*/
|
|
50
|
+
constructor(options: MailerOptions);
|
|
51
|
+
/** Number of messages waiting for the next `flush()`. */
|
|
52
|
+
get pending(): number;
|
|
53
|
+
/**
|
|
54
|
+
* Normalizes and delivers a message, awaiting the outcome. Never throws: a
|
|
55
|
+
* render failure, an invalid message, a rejected delivery, and a transport
|
|
56
|
+
* that throws all arrive as a `MailError` failure for the caller to inspect.
|
|
57
|
+
*
|
|
58
|
+
* @param input - A plain message, or an email object that renders its own body.
|
|
59
|
+
* @param overrides - Fields that replace what the input provides for this send.
|
|
60
|
+
* @returns The provider's identifier on success, a `MailError` on failure.
|
|
61
|
+
* @example let result = await mailer.send(new TeamInviteEmail(invite));
|
|
62
|
+
*/
|
|
63
|
+
send(input: Message | Email, overrides?: SendOptions): Promise<Result<SentMessage, MailError>>;
|
|
64
|
+
/**
|
|
65
|
+
* Queues a message to be sent once the caller flushes, which is how a send whose
|
|
66
|
+
* failure must not affect the response gets a defined lifetime. Nothing is
|
|
67
|
+
* rendered or validated yet; that happens at flush time.
|
|
68
|
+
*
|
|
69
|
+
* @param input - A plain message, or an email object that renders its own body.
|
|
70
|
+
* @param overrides - Fields that replace what the input provides for this send.
|
|
71
|
+
* @example ctx.email.later(new PasswordResetEmail(user));
|
|
72
|
+
*/
|
|
73
|
+
later(input: Message | Email, overrides?: SendOptions): void;
|
|
74
|
+
/**
|
|
75
|
+
* Sends everything `later()` queued and empties the queue. Results come back in
|
|
76
|
+
* queue order for the caller to log; like `send()`, it never throws, so a failed
|
|
77
|
+
* deferred message cannot break whoever is flushing.
|
|
78
|
+
*
|
|
79
|
+
* @returns One result per queued message, in the order they were queued.
|
|
80
|
+
*/
|
|
81
|
+
flush(): Promise<Result<SentMessage, MailError>[]>;
|
|
82
|
+
}
|
package/dist/mailer.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mailer: it owns normalization (defaults, address coercion, plain-text
|
|
3
|
+
* derivation, validation) and hands the result to a transport. Delivery is a
|
|
4
|
+
* value, so `send()` reports failure as a `Result` and never throws, and
|
|
5
|
+
* `later()` gives fire-and-forget mail a defined lifetime instead of an
|
|
6
|
+
* unawaited promise.
|
|
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 { isValidAddress, toAddressList } from "./lib/address.js";
|
|
14
|
+
import { htmlToText } from "./lib/html-to-text.js";
|
|
15
|
+
import { render } from "./render.js";
|
|
16
|
+
/**
|
|
17
|
+
* Reports whether a value is an `Email` rather than a plain `Message`.
|
|
18
|
+
* Discrimination is structural: a callable `body` is the one member only an email
|
|
19
|
+
* has, so anything else is a message.
|
|
20
|
+
*
|
|
21
|
+
* @param value - The value handed to `send()` or `later()`.
|
|
22
|
+
* @returns `true` when the value carries a callable `body`.
|
|
23
|
+
*/
|
|
24
|
+
export function isEmail(value) {
|
|
25
|
+
return typeof value.body === "function";
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Chooses the plain-text part. An explicit one always wins, so a caller can
|
|
29
|
+
* replace a derived version that reads badly; otherwise it derives from the
|
|
30
|
+
* HTML actually sent, keeping the two parts from disagreeing on an override.
|
|
31
|
+
*/
|
|
32
|
+
function derivePlainText(message, overrides) {
|
|
33
|
+
if (overrides?.text !== undefined)
|
|
34
|
+
return overrides.text;
|
|
35
|
+
if (overrides?.html !== undefined)
|
|
36
|
+
return htmlToText(overrides.html);
|
|
37
|
+
if (message.text !== undefined)
|
|
38
|
+
return message.text;
|
|
39
|
+
if (message.html !== undefined)
|
|
40
|
+
return htmlToText(message.html);
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Builds a `Message-ID` inside the sender's domain, which is what receivers expect
|
|
45
|
+
* of a generated identifier. Callers that need a stable value set `messageId` on
|
|
46
|
+
* the message instead of reaching for a clock or id seam.
|
|
47
|
+
*/
|
|
48
|
+
function generateMessageId(from) {
|
|
49
|
+
let domain = from.email.split("@").at(1) ?? "localhost";
|
|
50
|
+
return `<${crypto.randomUUID()}@${domain}>`;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Rejects messages no transport could deliver, so the failure names the mistake
|
|
54
|
+
* instead of surfacing as a provider error later: a missing sender or recipient,
|
|
55
|
+
* an address that cannot be routed, or a message with no body at all.
|
|
56
|
+
*/
|
|
57
|
+
function validate(message) {
|
|
58
|
+
if (!message.from.email)
|
|
59
|
+
return failure(new MailError("A message needs a sender address."));
|
|
60
|
+
if (message.to.length === 0) {
|
|
61
|
+
return failure(new MailError("A message needs at least one recipient."));
|
|
62
|
+
}
|
|
63
|
+
let addresses = [message.from, ...message.to, ...message.cc, ...message.bcc, ...message.replyTo];
|
|
64
|
+
for (let address of addresses) {
|
|
65
|
+
if (isValidAddress(address))
|
|
66
|
+
continue;
|
|
67
|
+
return failure(new MailError(`"${address.email}" is not a valid email address.`));
|
|
68
|
+
}
|
|
69
|
+
if (!message.html && !message.text) {
|
|
70
|
+
return failure(new MailError("A message needs an HTML or a plain-text body."));
|
|
71
|
+
}
|
|
72
|
+
return success(message);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Sends mail through a transport, applying one app's sender identity to every
|
|
76
|
+
* message. The instance is request-scoped when `later()` is used, because the
|
|
77
|
+
* queue it fills belongs to whoever flushes it.
|
|
78
|
+
*/
|
|
79
|
+
export class Mailer {
|
|
80
|
+
/** Configuration shared by every message this mailer sends. */
|
|
81
|
+
#options;
|
|
82
|
+
/** Messages queued by `later()`, drained by `flush()`. */
|
|
83
|
+
#queue = [];
|
|
84
|
+
/**
|
|
85
|
+
* Creates a mailer.
|
|
86
|
+
*
|
|
87
|
+
* @param options - Transport and sender configuration; see {@link MailerOptions}.
|
|
88
|
+
*/
|
|
89
|
+
constructor(options) {
|
|
90
|
+
this.#options = options;
|
|
91
|
+
}
|
|
92
|
+
/** Number of messages waiting for the next `flush()`. */
|
|
93
|
+
get pending() {
|
|
94
|
+
return this.#queue.length;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Normalizes and delivers a message, awaiting the outcome. Never throws: a
|
|
98
|
+
* render failure, an invalid message, a rejected delivery, and a transport
|
|
99
|
+
* that throws all arrive as a `MailError` failure for the caller to inspect.
|
|
100
|
+
*
|
|
101
|
+
* @param input - A plain message, or an email object that renders its own body.
|
|
102
|
+
* @param overrides - Fields that replace what the input provides for this send.
|
|
103
|
+
* @returns The provider's identifier on success, a `MailError` on failure.
|
|
104
|
+
* @example let result = await mailer.send(new TeamInviteEmail(invite));
|
|
105
|
+
*/
|
|
106
|
+
async send(input, overrides) {
|
|
107
|
+
let prepared = await this.#normalize(input, overrides);
|
|
108
|
+
if (isFailure(prepared))
|
|
109
|
+
return prepared;
|
|
110
|
+
let message = prepared.data;
|
|
111
|
+
let outcome = await wrap(() => this.#options.transport.send(message));
|
|
112
|
+
if (isFailure(outcome)) {
|
|
113
|
+
return failure(new MailError("The transport threw while sending the message.", {
|
|
114
|
+
cause: outcome.error,
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
return outcome.data;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Queues a message to be sent once the caller flushes, which is how a send whose
|
|
121
|
+
* failure must not affect the response gets a defined lifetime. Nothing is
|
|
122
|
+
* rendered or validated yet; that happens at flush time.
|
|
123
|
+
*
|
|
124
|
+
* @param input - A plain message, or an email object that renders its own body.
|
|
125
|
+
* @param overrides - Fields that replace what the input provides for this send.
|
|
126
|
+
* @example ctx.email.later(new PasswordResetEmail(user));
|
|
127
|
+
*/
|
|
128
|
+
later(input, overrides) {
|
|
129
|
+
this.#queue.push({ input, overrides });
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Sends everything `later()` queued and empties the queue. Results come back in
|
|
133
|
+
* queue order for the caller to log; like `send()`, it never throws, so a failed
|
|
134
|
+
* deferred message cannot break whoever is flushing.
|
|
135
|
+
*
|
|
136
|
+
* @returns One result per queued message, in the order they were queued.
|
|
137
|
+
*/
|
|
138
|
+
async flush() {
|
|
139
|
+
let queued = this.#queue.splice(0, this.#queue.length);
|
|
140
|
+
return await Promise.all(queued.map((entry) => this.send(entry.input, entry.overrides)));
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Turns an email object into a plain message by reading its addressing and
|
|
144
|
+
* rendering its body to both parts. Plain messages pass through untouched.
|
|
145
|
+
*/
|
|
146
|
+
async #toMessage(input) {
|
|
147
|
+
if (!isEmail(input))
|
|
148
|
+
return success(input);
|
|
149
|
+
let rendered = await wrap(() => render(input.body()));
|
|
150
|
+
if (isFailure(rendered)) {
|
|
151
|
+
return failure(new MailError("Failed to render the email body.", { cause: rendered.error }));
|
|
152
|
+
}
|
|
153
|
+
return success({
|
|
154
|
+
to: input.to,
|
|
155
|
+
subject: input.subject,
|
|
156
|
+
replyTo: input.replyTo,
|
|
157
|
+
headers: input.headers,
|
|
158
|
+
html: rendered.data.html,
|
|
159
|
+
text: rendered.data.text,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Produces the shape transports read: configured defaults filled in, address
|
|
164
|
+
* lists coerced, a plain-text part derived, and the source email recorded for
|
|
165
|
+
* type-based lookup. Validation runs last, so a failure describes the final message.
|
|
166
|
+
*/
|
|
167
|
+
async #normalize(input, overrides) {
|
|
168
|
+
let converted = await this.#toMessage(input);
|
|
169
|
+
if (isFailure(converted))
|
|
170
|
+
return converted;
|
|
171
|
+
let message = { ...converted.data, ...overrides };
|
|
172
|
+
let from = message.from ?? this.#options.from;
|
|
173
|
+
return validate({
|
|
174
|
+
from,
|
|
175
|
+
to: toAddressList(message.to),
|
|
176
|
+
cc: toAddressList(message.cc),
|
|
177
|
+
bcc: toAddressList(message.bcc),
|
|
178
|
+
replyTo: toAddressList(message.replyTo ?? this.#options.replyTo),
|
|
179
|
+
subject: message.subject,
|
|
180
|
+
html: message.html,
|
|
181
|
+
text: derivePlainText(converted.data, overrides),
|
|
182
|
+
headers: { ...this.#options.headers, ...message.headers },
|
|
183
|
+
date: message.date ?? new Date(),
|
|
184
|
+
messageId: message.messageId ?? generateMessageId(from),
|
|
185
|
+
email: isEmail(input) ? input : undefined,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown as an email body, and the highlighted code block it renders fences with.
|
|
3
|
+
*
|
|
4
|
+
* A separate entry point keeps the highlighter out of mail bundles that carry no markdown.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { Token } from "@sdxc/highlight";
|
|
10
|
+
import type { Markdown as Ast } from "@sdxc/markdown";
|
|
11
|
+
import type { Handle } from "remix/ui";
|
|
12
|
+
export declare namespace CodeBlock {
|
|
13
|
+
/** Props accepted by {@link CodeBlock}. */
|
|
14
|
+
interface Props {
|
|
15
|
+
code: string;
|
|
16
|
+
/** Language to highlight as; an unknown one still renders, left unpainted. */
|
|
17
|
+
language?: string;
|
|
18
|
+
/** Already-highlighted runs, as a painted document carries them. */
|
|
19
|
+
tokens?: Token[];
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A fenced block of code, highlighted, inside a single-cell table.
|
|
24
|
+
*
|
|
25
|
+
* The table keeps the block's background solid in Outlook, which paints a `<pre>`
|
|
26
|
+
* no wider than its text, so long lines wrap since an inbox offers no scrollbar.
|
|
27
|
+
*
|
|
28
|
+
* @example <CodeBlock language="bash" code="bun run deploy" />
|
|
29
|
+
*/
|
|
30
|
+
export declare function CodeBlock(handle: Handle<CodeBlock.Props>): () => import("remix/ui").RemixElement;
|
|
31
|
+
export declare namespace Markdown {
|
|
32
|
+
/** Props accepted by {@link Markdown}. */
|
|
33
|
+
interface Props {
|
|
34
|
+
/** The document to render; its code blocks arrive painted when the caller painted them. */
|
|
35
|
+
document: Ast.Document;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Renders a parsed document as an email body, using the layout kit for every element.
|
|
40
|
+
*
|
|
41
|
+
* The document arrives parsed, so a caller holding one pays for no parser here, and the
|
|
42
|
+
* same tree an inbox receives is the tree a page renders.
|
|
43
|
+
*
|
|
44
|
+
* @example <Markdown document={notes} />
|
|
45
|
+
*/
|
|
46
|
+
export declare function Markdown(handle: Handle<Markdown.Props>): () => import("remix/ui").RemixElement;
|
package/dist/markdown.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "remix/ui/jsx-runtime";
|
|
2
|
+
import { tokenize } from "@sdxc/highlight";
|
|
3
|
+
import { CODE_COLOR, CodeInline, Heading, Hr, Img, Link, MONO_FAMILY, Text } from "./components.js";
|
|
4
|
+
/**
|
|
5
|
+
* The colour of each token type, and the class that recolours it in dark mode.
|
|
6
|
+
*
|
|
7
|
+
* Six colours for twenty types, because an inbox reads at a glance and every
|
|
8
|
+
* colour spent here is another rule the dark-mode stylesheet has to carry.
|
|
9
|
+
*/
|
|
10
|
+
const TOKENS = {
|
|
11
|
+
comment: { color: "#6a737d", class: "mail-tok-comment" },
|
|
12
|
+
keyword: { color: "#d73a49", class: "mail-tok-keyword" },
|
|
13
|
+
operator: { color: "#24292e", class: "mail-tok-punctuation" },
|
|
14
|
+
punctuation: { color: "#24292e", class: "mail-tok-punctuation" },
|
|
15
|
+
string: { color: "#032f62", class: "mail-tok-string" },
|
|
16
|
+
regex: { color: "#032f62", class: "mail-tok-string" },
|
|
17
|
+
"attr-value": { color: "#032f62", class: "mail-tok-string" },
|
|
18
|
+
builtin: { color: "#032f62", class: "mail-tok-string" },
|
|
19
|
+
number: { color: "#005cc5", class: "mail-tok-number" },
|
|
20
|
+
boolean: { color: "#005cc5", class: "mail-tok-number" },
|
|
21
|
+
constant: { color: "#005cc5", class: "mail-tok-number" },
|
|
22
|
+
property: { color: "#005cc5", class: "mail-tok-number" },
|
|
23
|
+
function: { color: "#6f42c1", class: "mail-tok-function" },
|
|
24
|
+
"class-name": { color: "#6f42c1", class: "mail-tok-function" },
|
|
25
|
+
tag: { color: "#6f42c1", class: "mail-tok-function" },
|
|
26
|
+
"attr-name": { color: "#6f42c1", class: "mail-tok-function" },
|
|
27
|
+
variable: { color: "#6f42c1", class: "mail-tok-function" },
|
|
28
|
+
inserted: { color: "#22863a", class: "mail-tok-inserted" },
|
|
29
|
+
deleted: { color: "#b31d28", class: "mail-tok-deleted" },
|
|
30
|
+
plain: undefined,
|
|
31
|
+
};
|
|
32
|
+
/** Hairline colour around a data table's cells, matching the kit's own rules. */
|
|
33
|
+
const TABLE_BORDER_COLOR = "#e4e4e7";
|
|
34
|
+
/** One highlighted run, as a coloured span or as bare text where nothing paints it. */
|
|
35
|
+
function highlight(tokens) {
|
|
36
|
+
return tokens.map((token, index) => {
|
|
37
|
+
let painted = TOKENS[token.type];
|
|
38
|
+
if (!painted)
|
|
39
|
+
return token.value;
|
|
40
|
+
return (_jsx("span", { class: painted.class, style: `color:${painted.color};`, children: token.value }, index));
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* A fenced block of code, highlighted, inside a single-cell table.
|
|
45
|
+
*
|
|
46
|
+
* The table keeps the block's background solid in Outlook, which paints a `<pre>`
|
|
47
|
+
* no wider than its text, so long lines wrap since an inbox offers no scrollbar.
|
|
48
|
+
*
|
|
49
|
+
* @example <CodeBlock language="bash" code="bun run deploy" />
|
|
50
|
+
*/
|
|
51
|
+
export function CodeBlock(handle) {
|
|
52
|
+
return () => {
|
|
53
|
+
let { code, language, tokens } = handle.props;
|
|
54
|
+
let content = highlight(tokens ?? tokenize(code, language ?? "plain"));
|
|
55
|
+
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: _jsx("tr", { children: _jsx("td", { class: "mail-code", style: `padding:12px 16px;border-radius:6px;background-color:${CODE_COLOR};`, children: _jsx("pre", { style: `margin:0;padding:0;font-family:${MONO_FAMILY};font-size:13px;line-height:1.5;white-space:pre-wrap;word-break:break-word;`, children: content }) }) }) }) }));
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** An image's alternative text is inline content in the source and an attribute in the markup. */
|
|
59
|
+
function textOf(nodes) {
|
|
60
|
+
return nodes.map(textOfNode).join("");
|
|
61
|
+
}
|
|
62
|
+
/** Reads the prose out of one node, a break becoming the space it stands for. */
|
|
63
|
+
function textOfNode(node) {
|
|
64
|
+
switch (node.type) {
|
|
65
|
+
case "text":
|
|
66
|
+
case "inlineCode":
|
|
67
|
+
case "inlineHtml":
|
|
68
|
+
case "html":
|
|
69
|
+
return node.value;
|
|
70
|
+
case "code":
|
|
71
|
+
return node.content;
|
|
72
|
+
case "softBreak":
|
|
73
|
+
case "hardBreak":
|
|
74
|
+
return " ";
|
|
75
|
+
case "variable":
|
|
76
|
+
return `{% $${node.name} %}`;
|
|
77
|
+
case "footnoteReference":
|
|
78
|
+
return `[${node.identifier}]`;
|
|
79
|
+
case "thematicBreak":
|
|
80
|
+
return "";
|
|
81
|
+
default:
|
|
82
|
+
return textOf([...node.children]);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** Renders a run of nodes in order, which is what every parent does with its children. */
|
|
86
|
+
function children(nodes) {
|
|
87
|
+
return nodes.map(convert);
|
|
88
|
+
}
|
|
89
|
+
/** Each cell takes the alignment of the column it sits in, which the table carries once. */
|
|
90
|
+
function convertRow(node, key, align) {
|
|
91
|
+
return (_jsx("tr", { children: node.children.map((cell, index) => convertCell(cell, index, node.header, align[index] ?? null)) }, key));
|
|
92
|
+
}
|
|
93
|
+
/** A header row's cells are the column headings, so they carry the weight a reader scans for. */
|
|
94
|
+
function convertCell(node, key, header, align) {
|
|
95
|
+
let style = `padding:8px 12px;border:1px solid ${TABLE_BORDER_COLOR};font-family:inherit;font-size:14px;line-height:1.4;text-align:${align ?? "left"};vertical-align:top;`;
|
|
96
|
+
if (header) {
|
|
97
|
+
return (_jsx("th", { class: "mail-rule", style: `${style}font-weight:600;`, children: children(node.children) }, key));
|
|
98
|
+
}
|
|
99
|
+
return (_jsx("td", { class: "mail-rule", style: style, children: children(node.children) }, key));
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Turns one node into email components.
|
|
103
|
+
*
|
|
104
|
+
* Content an inbox cannot lay out still renders, in whatever form reads: an
|
|
105
|
+
* ordered list becomes a real `<ol>`, so its plain-text conversion numbers each item.
|
|
106
|
+
*/
|
|
107
|
+
function convert(node, key) {
|
|
108
|
+
switch (node.type) {
|
|
109
|
+
case "heading": {
|
|
110
|
+
if (node.level === 1)
|
|
111
|
+
return _jsx(Heading, { children: children(node.children) }, key);
|
|
112
|
+
if (node.level === 2) {
|
|
113
|
+
return (_jsx(Heading, { level: 2, children: children(node.children) }, key));
|
|
114
|
+
}
|
|
115
|
+
return (_jsx(Heading, { level: 3, children: children(node.children) }, key));
|
|
116
|
+
}
|
|
117
|
+
case "paragraph":
|
|
118
|
+
return _jsx(Text, { children: children(node.children) }, key);
|
|
119
|
+
case "code":
|
|
120
|
+
return (_jsx(CodeBlock, { code: node.content, language: node.language, tokens: node.tokens }, key));
|
|
121
|
+
case "list": {
|
|
122
|
+
if (node.ordered) {
|
|
123
|
+
return (_jsx("ol", { start: node.start, style: "margin:0 0 16px;padding:0 0 0 20px;list-style-type:decimal;", children: children(node.children) }, key));
|
|
124
|
+
}
|
|
125
|
+
return (_jsx("ul", { style: "margin:0 0 16px;padding:0 0 0 20px;list-style-type:disc;", children: children(node.children) }, key));
|
|
126
|
+
}
|
|
127
|
+
case "listItem":
|
|
128
|
+
return (_jsx("li", { style: "margin:0 0 6px;font-family:inherit;line-height:1.6;", children: node.children.map((child, index) => child.type === "paragraph" ? children(child.children) : convert(child, index)) }, key));
|
|
129
|
+
case "blockquote":
|
|
130
|
+
return (_jsx("blockquote", { class: "mail-rule", style: "margin:0 0 16px;padding:0 0 0 16px;border-left:3px solid #e4e4e7;", children: children(node.children) }, key));
|
|
131
|
+
case "alert":
|
|
132
|
+
return (_jsxs("blockquote", { class: "mail-rule", style: "margin:0 0 16px;padding:0 0 0 16px;border-left:3px solid #e4e4e7;", children: [_jsx(Text, { size: 13, muted: true, children: node.kind.toUpperCase() }), children(node.children)] }, key));
|
|
133
|
+
case "table":
|
|
134
|
+
return (_jsx("table", { width: "100%", cellPadding: "0", cellSpacing: "0", style: "width:100%;margin:0 0 16px;border-collapse:collapse;", children: _jsx("tbody", { children: node.children.map((row, index) => convertRow(row, index, node.align)) }) }, key));
|
|
135
|
+
case "tableRow":
|
|
136
|
+
return convertRow(node, key, []);
|
|
137
|
+
case "tableCell":
|
|
138
|
+
return convertCell(node, key, false, null);
|
|
139
|
+
case "thematicBreak":
|
|
140
|
+
return _jsx(Hr, {}, key);
|
|
141
|
+
case "html":
|
|
142
|
+
case "inlineHtml":
|
|
143
|
+
return node.value;
|
|
144
|
+
case "footnoteDefinition":
|
|
145
|
+
return (_jsxs("div", { style: "margin:0 0 16px;font-family:inherit;font-size:14px;line-height:1.6;", children: [_jsx("strong", { style: "font-weight:600;", children: `[${node.identifier}] ` }), children(node.children)] }, key));
|
|
146
|
+
case "tag":
|
|
147
|
+
return children([...node.children]);
|
|
148
|
+
case "text":
|
|
149
|
+
return node.value;
|
|
150
|
+
case "emphasis":
|
|
151
|
+
return (_jsx("em", { style: "font-style:italic;", children: children(node.children) }, key));
|
|
152
|
+
case "strong":
|
|
153
|
+
return (_jsx("strong", { style: "font-weight:600;", children: children(node.children) }, key));
|
|
154
|
+
case "strikethrough":
|
|
155
|
+
return (_jsx("s", { style: "text-decoration:line-through;", children: children(node.children) }, key));
|
|
156
|
+
case "inlineCode":
|
|
157
|
+
return _jsx(CodeInline, { children: node.value }, key);
|
|
158
|
+
case "link":
|
|
159
|
+
return (_jsx(Link, { href: node.href, children: children(node.children) }, key));
|
|
160
|
+
case "image":
|
|
161
|
+
return _jsx(Img, { src: node.src, alt: textOf(node.children), gap: 16 }, key);
|
|
162
|
+
case "softBreak":
|
|
163
|
+
return "\n";
|
|
164
|
+
case "hardBreak":
|
|
165
|
+
return _jsx("br", {}, key);
|
|
166
|
+
case "footnoteReference":
|
|
167
|
+
return _jsx("sup", { style: "font-size:0.75em;line-height:1;", children: `[${node.identifier}]` }, key);
|
|
168
|
+
case "variable":
|
|
169
|
+
return `{% $${node.name} %}`;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Renders a parsed document as an email body, using the layout kit for every element.
|
|
174
|
+
*
|
|
175
|
+
* The document arrives parsed, so a caller holding one pays for no parser here, and the
|
|
176
|
+
* same tree an inbox receives is the tree a page renders.
|
|
177
|
+
*
|
|
178
|
+
* @example <Markdown document={notes} />
|
|
179
|
+
*/
|
|
180
|
+
export function Markdown(handle) {
|
|
181
|
+
return () => _jsx(_Fragment, { children: children(handle.props.document.children) });
|
|
182
|
+
}
|