A TypeScript SDK for the mxRaven mail-facing runtime surfaces: SMTP submission, webhook verification and dispatch, inbound message parsing, client notifications, and recipient feedback.
Status: early development. The public API is not yet stable.
pnpm add @mxraven/mail
import { Client, Message } from "@mxraven/mail";
const secret = process.env.MXRAVEN_SECRET;
if (secret === undefined || secret === "") {
throw new Error("MXRAVEN_SECRET is required");
}
const client = new Client({
host: "smtp.mxraven.email",
username: "mxr_tx_ab12cd34ef56",
secret,
});
const result = await client.send(
new Message()
.from("Acme <noreply@acme.example>")
.to("customer@example.com")
.subject("Your receipt")
.text("Thanks for your order.")
.html("<p>Thanks for your order.</p>"),
);
console.log("accepted as", result.messageRef);
await client.close();
The submission service requires STARTTLS and SMTP AUTH, so both are always
used. The SDK does not read environment variables itself; the example validates
the variable before constructing the client. Load secrets from a secret manager
in production. Setting both a plain-text and an HTML body produces a
multipart/alternative message; attachFile and attachInline add
multipart/mixed attachments.
Use sendRaw to stream an already serialized RFC 5322 message with an explicit
envelope. The caller is responsible for RFC 5322 correctness.
await client.sendRaw(
{ from: "bounce@acme.example", to: ["customer@example.com"] },
rawMessageBytes,
);
send and sendRaw accept RFC 2852 and RFC 3461 envelope parameters through
SendOptions:
await client.send(message, {
deliveryBy: { seconds: 3600, mode: "R" }, // DELIVERBY; requires server support
requireTls: true, // REQUIRETLS; requires server support
dsnRet: "FULL", // RET; requires DSN
envid: "order-42", // ENVID; requires DSN
extensionParams: new Map([["X-CUSTOM", "value"]]), // extra MAIL FROM parameters
});
requireTls sets the RFC 8689 REQUIRETLS parameter, requiring TLS on
every delivery hop. The submission server must advertise REQUIRETLS;
otherwise the send throws an SMTPError with code 550 and enhanced status
5.7.30. Submission already uses STARTTLS, so the local TLS precondition is
always met.
Per-recipient DSN parameters go on the envelope, which sendRaw accepts as
objects alongside plain addresses:
await client.sendRaw(
{
from: "bounce@acme.example",
to: [
{
address: "customer@example.com",
notify: ["FAILURE", "DELAY"],
orcpt: "original@acme.example", // sent as rfc822;original@acme.example
},
],
},
rawMessageBytes,
);
deliveryBy fails with DeliveryByUnsupportedError when the server does not
advertise DELIVERBY; mode R must meet the server's advertised minimum
interval.
Rejected commands throw SMTPError; when every recipient is rejected,
SMTPTransactionError is thrown with the per-recipient result. A requested
DELIVERBY deadline on a server that does not advertise the extension throws
DeliveryByUnsupportedError before the transaction starts.
import { AddressError, SMTPError, addressErrorKind } from "@mxraven/mail";
try {
await client.send(message);
} catch (error) {
if (error instanceof SMTPError && error.permanent) {
// 5xx: do not retry the same message.
}
if (error instanceof AggregateError) {
for (const problem of error.errors) {
if (problem instanceof AddressError && problem.kind === addressErrorKind.invalidDomain) {
// The address's domain is not a valid DNS name or IDN.
}
}
}
}
Addresses are parsed with the RFC 5322 mailbox grammar and normalized before
use: internationalized domains become Punycode, domains are lowercased, quoted
local parts are unquoted, and RFC 2047 display names are decoded. Validation
follows the RFC 5321 length limits, so an address that cannot be put on the
wire fails at build time with an AddressError whose kind is one of
addressErrorKind.
parseAddress and parseAddressList expose that parser, so you can validate or
normalize an address without building a message. They return a
MailboxAddress (localPart, domain, displayName), the same normalized
value the SDK puts on the wire; parseAddressList expands groups and is what to
use for a header such as To or Cc.
import { parseAddress, parseAddressList } from "@mxraven/mail";
const sender = parseAddress("Acme <Noreply@例え.jp>");
// { localPart: "Noreply", domain: "xn--r8jz45g.jp", displayName: "Acme" }
parseAddressList('Friends: "Doe, John" <j@example.com>, a@example.com;');
// two mailboxes; the group name and empty group "undisclosed-recipients:;" are
// not addresses
Rejections throw AddressError with a stable kind, so no message matching is
needed. A comment is not a display name, so user@example.com (Bob) parses
without one, and an address that cannot be sent — a bad domain label, an
invalid IP literal, an over-long local part, or a decoded control character — is
rejected rather than silently normalized.
Templates are pluggable; the SDK ships only the contract. Message.render()
accepts any object with a render function that returns HTML, plus optional
text and subject:
import type { TemplateRenderer } from "@mxraven/mail";
const renderer: TemplateRenderer<{ name: string }> = {
render: ({ name }) => ({
html: `<p>Hello ${name}</p>`,
text: `Hello ${name}`,
}),
};
await client.send(
new Message()
.from("Acme <noreply@acme.example>")
.to("customer@example.com")
.subject("Welcome")
.render(renderer, { name: "Ada" }),
);
The renderer runs inside send, so asynchronous engines work. Its HTML
replaces an explicit .html() body, and its text replaces an explicit
.text() body when it returns one; an explicit .subject() always wins. When
both text and HTML are present after rendering, the message is sent as
multipart/alternative.
The companion @mxraven/react
package renders React templates on the server
wherever you call client.send(), never in the browser. Install it alongside
React:
pnpm add @mxraven/react
import { Client, Message } from "@mxraven/mail";
import { react } from "@mxraven/react";
function Welcome({ firstName }: { firstName: string }) {
return <h1>Welcome, {firstName}!</h1>;
}
const client = new Client({
host: "smtp.mxraven.email",
username: "mxr_tx_ab12cd34ef56",
secret: process.env.MXRAVEN_SECRET!,
});
await client.send(
new Message()
.from("Acme <noreply@acme.example>")
.to("customer@example.com")
.subject("Welcome")
.render(react(), <Welcome firstName="Ada" />),
);
Verification, dispatch, and decoding live in a separate entry point that uses the webhook signing secret, not the submission API key.
WebhookHandler verifies the signature, decodes the JSON payload, and
dispatches it to typed listeners registered with on:
import { WebhookHandler, eventType } from "@mxraven/mail/webhook";
const webhook = new WebhookHandler({ secret: process.env.MXRAVEN_WEBHOOK_SECRET! });
webhook.on(eventType.inboundEmail, async (email) => {
console.log(email.task_id, email.message.subject);
});
webhook.on(eventType.deliveryStatus, async (status) => {
console.log(status.status, status.task_id);
});
// Fetch-native runtimes (Workers, Deno, Bun, Next.js, Hono)
export default { fetch: (request: Request) => webhook.handle(request) };
Listeners run sequentially in registration order, and the delivery is only
acknowledged after they resolve. handle never throws for protocol failures;
the response status is 204 accepted, 400 malformed, 401 bad signature,
413 body too large, or 500 when a listener throws — which makes mxRaven
retry the delivery.
Frameworks that do not expose a Fetch Request pass its fields instead; this
is the only mapping an adapter needs:
const response = await webhook.handle({
method: request.method, // the HTTP method
url: publicUrl, // the absolute, public URL the sender called
headers: request.headers, // a header map or Headers
body: rawBody, // the exact raw bytes, never a parsed JSON body
});
The response carries only a status; map it to the framework's response API. Because the signature covers the exact raw body and the public URL, never let a body parser consume the bytes first, and always reconstruct the URL the sender used. See Framework support for concrete recipes.
An inbound-email event contains the message metadata, the decoded header
summary, every header, and a raw_email reference with a short-lived
download URL. The body and attachments are not inlined, so download and parse
the message only when you need it:
import { fetchAndParseRawEmail } from "@mxraven/mail/mime";
webhook.on(eventType.inboundEmail, async (email) => {
const parsed = await fetchAndParseRawEmail(email.raw_email);
console.log(parsed.subject, parsed.attachments.length);
});
For manual control, use the low-level verifier directly.
import { Verifier, eventType } from "@mxraven/mail/webhook";
const verifier = new Verifier({ secret: process.env.MXRAVEN_WEBHOOK_SECRET! });
const event = await verifier.verifyAndDecode(request);
if (event.type === eventType.inboundEmail) {
// event.inboundEmail.raw_email references the full message.
}
The signing secret is used as literal key bytes; do not base64-decode it. Pass
per-key secrets to Verifier with keys for rotation. The raw-email
access_token is a secret; do not log it.
@mxraven/mail/mime decodes raw RFC 5322 bytes into headers, addresses, text
and HTML bodies, and attachments. fetchAndParseRawEmail downloads a payload's
raw_email reference and parses it in one call (shown above); parseEmail
parses bytes you already have:
import { parseEmail } from "@mxraven/mail/mime";
const email = parseEmail(rawBytes);
console.log(email.subject, email.from, email.attachments);
The parser understands multipart/mixed, multipart/alternative, and
multipart/related, decodes base64 and quoted-printable transfer encodings,
decodes RFC 2047 encoded words and reassembles RFC 2231 parameters, and falls
back to UTF-8 and then Latin-1 for unknown charsets. message/rfc822 parts are
returned as attachments rather than parsed recursively.
Fetch-native runtimes accept the request directly — webhook.handle(request)
(or verifier.verifyAndDecode(request)): Cloudflare Workers, Deno, Bun,
Next.js (App Router), Remix, SvelteKit, Astro, Hono (c.req.raw), and
@whatwg-node/server.
Frameworks with their own request objects — IncomingMessage in Express,
FastifyRequest in Fastify, ctx in Koa — map method, public URL, headers, and
raw body onto handle. Express:
import express from "express";
const app = express();
app.set("trust proxy", true); // use X-Forwarded-Proto/Host behind a proxy
app.post(
"/mxraven/webhook",
express.raw({ type: "*/*" }), // keeps the exact bytes on req.body
async (req, res) => {
// express.raw() must be mounted before any express.json() parser.
const response = await webhook.handle({
method: req.method,
url: `${req.protocol}://${req.get("host")}${req.originalUrl}`,
headers: req.headers,
body: req.body,
});
res.sendStatus(response.status);
},
);
In Fastify, capture the raw buffer with an addContentTypeParser hook, then
pass request.method, the public URL, request.headers, and the buffer to
handle. With the low-level Verifier, construct a Request from the same
fields first; decode operates on bytes alone, so it can be used without any
request object.
The runtime surfaces are published as separate entry points so the root import stays free of webhook, notification, and feedback code:
@mxraven/mail/webhook — HMAC-SHA256 verification, typed event dispatch,
payload decoding, and raw message download.@mxraven/mail/mime — inbound message parsing into headers, addresses,
bodies, and attachments.@mxraven/mail/notify — client notification events over NATS JetStream.@mxraven/mail/feedback — tenant spam/ham training and RFC 8058 one-click
unsubscribes.Enable notifications for a listener with the administrative API, then consume that listener's accepted-message and delivery-attempt events over NATS. The client authenticates with the same API key used for SMTP submission and manages the WebSocket transport, acknowledgement, and redelivery:
import { NotificationClient } from "@mxraven/mail/notify";
const secret = process.env.MXRAVEN_SECRET;
if (secret === undefined || secret === "") {
throw new Error("MXRAVEN_SECRET is required");
}
const client = await NotificationClient.connect({
url: "wss://notifications.mxraven.com",
username: "mxr_tx_ab12cd34ef56",
secret,
listener: listenerId,
});
await client.consume((event) => {
if (event.kind === "accepted") {
console.log("accepted", event.accepted.task_id);
} else {
console.log("attempt", event.attempt.taskId, event.attempt.channel, event.attempt.status);
}
});
A handler that returns normally acknowledges the event. A handler that throws
leaves it unacknowledged, and the server redelivers it with exponential backoff.
Return stopConsuming to acknowledge the current event and stop. Cancellation
with an AbortSignal and client.close() are normal stops and resolve the
promise.
An accepted event is the same payload a DELIVER_WEBHOOK delivery carries, so
event.accepted.raw_email downloads with fetchRawEmail from
@mxraven/mail/webhook. An attempt event is a camelCase AttemptEvent whose
channel is smtp, s3, or webhook; correlationId joins it back to the
accepted event's task_id.
messageRef (message_ref on the accepted payload) joins an event to the send
that produced it: send() reports the same value as result.messageRef. It is
the only identifier in an event that the caller already holds, and it groups the
attempts of a fan-out that otherwise have distinct task IDs.
For manual acknowledgement, iterate events with client.events(). Each
delivery exposes ack(), nak({ delay }), and term():
for await (const { event, ack, nak } of client.events({ signal })) {
try {
await process(event);
ack();
} catch {
nak({ delay: 5_000 });
}
}
The stream retains events for 24 hours regardless of acknowledgement, so a
client that is offline longer than that cannot recover the gap. Connections
that share one API key share one durable consumer: events are distributed
between them, not duplicated to each. Use the tls option to trust a private
certificate authority or present a client certificate.
Teach the spam filter with the exact raw message bytes mxRaven processed, or perform a one-click unsubscribe. Learning uses the same submission API key as SMTP submission, over HTTP Basic auth.
import { Client } from "@mxraven/mail/feedback";
const secret = process.env.MXRAVEN_SECRET;
if (secret === undefined || secret === "") {
throw new Error("MXRAVEN_SECRET is required");
}
const feedback = new Client({
baseUrl: "https://api.mxraven.com",
username: "mxr_tx_ab12cd34ef56",
secret,
});
await feedback.learnSpam(rawMessageBytes);
Reference examples live in
examples/.
They are not part of the published package, are excluded from the typecheck, and
are not run by the build. Each file starts with the credentials or paths to
replace, then shows one workflow:
| Example | Shows |
|---|---|
send-with-attachments.ts |
SMTP submission with a regular and an inline cid: attachment |
send-with-react.tsx |
Server-side React Email rendering through @mxraven/react |
webhook-node.ts |
A dependency-free Node HTTP adapter |
webhook-express.ts |
Express 5 with express.raw() and trust proxy |
webhook-fastify.ts |
Fastify with a buffer content-type parser |
webhook-hono.ts |
Hono on Workers/Deno/Bun, passing Request straight through |
parse-inbound.ts |
Decoding an .eml file into headers, bodies, and attachments |
notifications.ts |
Consuming client notification events from a NATS JetStream |
feedback.ts |
Spam/ham training and one-click unsubscribe |
Requires Node.js 20.19 or later and pnpm.
pnpm install # install dependencies
pnpm run build # dual ESM + CJS build with declarations (tsdown)
pnpm run test # vitest
pnpm run lint # oxlint (includes TSDoc validation)
pnpm run format # oxfmt
pnpm run docs # typedoc (Markdown) into docs/
pnpm run check runs formatting, lint, typecheck, test, and build in sequence.
| Concern | Tool |
|---|---|
| Language / typecheck | TypeScript (tsc --noEmit), NodeNext, strict |
| Build (dual ESM + CJS) | tsdown (Rolldown) with declarations and source maps |
| Testing | Vitest |
| Lint / format | oxlint + oxfmt |
| TSDoc validation | eslint-plugin-tsdoc loaded as an oxlint JS plugin |
| API docs | TypeDoc + typedoc-plugin-markdown |
| Versioning / release | Changesets, published from GitHub Actions with npm publish --provenance |
Releases are managed with Changesets. Add a changeset with pnpm run changeset;
merging the generated release pull request bumps versions and publishes to npm
with provenance.