js/email.ts
fino:email - transactional email messages, SMTP delivery, and DKIM signing.
The surface is deliberately small and composable: renderMessage() turns an
EmailMessage into RFC 5322/MIME text, SmtpClient drives a complete SMTP
submission transaction over a caller-supplied connection, dkimSign()
produces a DKIM-Signature header, and send() dispatches a message to any
EmailTransport. Application code should depend on the EmailTransport
interface so the delivery mechanism — direct SMTP, a hosted provider's HTTP
API, or an in-memory capture in tests — stays swappable. Hosted provider
adapters are expected to live in external packages that implement
EmailTransport; this module provides the message model and wire formats
they share.
Rendering guards against header injection: any CR or LF in the sender, recipients, subject, or attachment filenames throws, so those fields can be populated from untrusted input without smuggling extra headers.
import { send, type EmailTransport } from 'fino:email';
const transport: EmailTransport = {
async send(message) {
// deliver via a provider API, an SmtpClient, etc.
return { id: 'provider-1' };
}
};
const result = await send({
from: 'Ada <ada@example.test>',
to: 'ops@example.test',
subject: 'Deploy finished',
text: 'Deploy finished without errors.',
html: '<p>Deploy finished <strong>without errors</strong>.</p>'
}, { transport });
Useful references:
- SMTP: https://www.rfc-editor.org/rfc/rfc5321
- Internet message format: https://www.rfc-editor.org/rfc/rfc5322
- STARTTLS for SMTP: https://www.rfc-editor.org/rfc/rfc3207
- SMTP AUTH: https://www.rfc-editor.org/rfc/rfc4954
- DKIM: https://www.rfc-editor.org/rfc/rfc6376
Types
type EmailAddress = string
Mailbox address used in sender and recipient fields.
Accepts either a bare address (ada@example.test) or the display-name form
(Ada Lovelace <ada@example.test>). renderMessage() emits the value
verbatim in headers, while SmtpClient extracts the angle-bracket address
for the SMTP envelope.
Interfaces
interface EmailMessage {
Message to render or deliver.
Provide at least one of text or html; when both are present the message
renders as multipart/alternative so receiving clients pick their preferred
body. The optional date, messageId, and boundary fields exist mainly
to make rendered output deterministic in tests — production callers can
leave them unset and let renderMessage() fill in defaults.
import type { EmailMessage } from 'fino:email';
const message: EmailMessage = {
from: 'Ada <ada@example.test>',
to: ['ops@example.test', 'dev@example.test'],
subject: 'Nightly report',
text: 'All 412 checks passed.',
html: '<p>All <strong>412</strong> checks passed.</p>'
};
Properties
from: EmailAddress
Sender mailbox, rendered as the From header and used for the SMTP envelope sender.
to: EmailAddress | EmailAddress[]
One or more recipient mailboxes, rendered comma-separated in the To header.
subject: string
Subject line. Throws at render time if it contains CR or LF.
text?: string
Plain-text body, used alone or as the fallback part of a multipart/alternative message.
html?: string
HTML body. When text is also set, both render as multipart/alternative.
date?: Date
Override for the Date header; defaults to the time of rendering.
messageId?: string
Override for the Message-ID header; a generated @fino.local id is used when unset.
boundary?: string
Explicit MIME boundary base for deterministic output in tests; random when unset.
attachments?: EmailAttachment[]
Files to attach. Any attachment switches the top-level structure to multipart/mixed.
interface EmailAttachment {
Attachment rendered into a MIME message.
Every attachment part is emitted with Content-Transfer-Encoding: base64.
Uint8Array content is base64-encoded during rendering; string content is
emitted verbatim, so a string must already be base64 text.
Properties
filename: string
Name presented in the part's Content-Disposition. Throws at render time if it contains CR or LF.
contentType?: string
MIME type of the part; defaults to application/octet-stream.
content: string | Uint8Array
Part payload: raw bytes to be base64-encoded, or a string that is already base64 text.
interface EmailSendResult {
Outcome reported by a transport after a send attempt.
Which fields are populated depends on the transport: SmtpClient fills
accepted and rejected from the SMTP envelope, while hosted providers
typically return a provider-side message id.
Properties
id?: string
Provider-assigned message identifier, when the transport supplies one.
accepted?: string[]
Envelope recipient addresses the transport accepted.
rejected?: string[]
Envelope recipient addresses the transport refused.
interface EmailTransport {
Delivery backend accepted by send().
Implement this single-method interface to plug in any delivery mechanism:
an SmtpClient, a hosted provider's HTTP API, or an in-memory capture for
tests. Implementations may be synchronous or asynchronous and should throw
(or reject) when delivery fails outright.
import { send, type EmailTransport } from 'fino:email';
const providerTransport: EmailTransport = {
async send(message) {
const response = await fetch('https://api.mailer.test/v1/send', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(message)
});
const { id } = await response.json();
return { id };
}
};
await send({
from: 'a@example.test',
to: 'b@example.test',
subject: 'Hello',
text: 'Hi there'
}, { transport: providerTransport });
Methods
send(message: EmailMessage): Promise<EmailSendResult> | EmailSendResult
Deliver one message, resolving with whatever delivery metadata the backend reports.
Functions
function renderMessage(message: EmailMessage): string
Render an email message as RFC 5322/MIME text.
Lines use CRLF endings and the structure follows the message content: a
text-only or HTML-only message renders as a single part, text plus HTML
renders multipart/alternative, and any attachments promote the top level
to multipart/mixed (nesting the alternative part when both bodies are
present). Missing optional fields are filled with defaults — Date becomes
the current time, Message-ID a generated @fino.local id, and the MIME
boundary a random fino- token.
Throws if from, subject, any recipient, or any attachment filename
contains CR or LF. This is the header-injection guard: it means those
fields can carry untrusted input without allowing extra headers to be
smuggled into the output.
import { renderMessage } from 'fino:email';
const raw = renderMessage({
from: 'Ada <ada@example.test>',
to: 'ops@example.test',
subject: 'Nightly report',
text: 'All 412 checks passed.',
attachments: [{
filename: 'report.csv',
contentType: 'text/csv',
content: new TextEncoder().encode('check,status\nlint,pass\n')
}]
});
async function send(message: EmailMessage, options: {
transport: EmailTransport;
}): Promise<EmailSendResult>
Send a message through an injected SMTP or provider transport.
A thin dispatch helper: it awaits options.transport.send(message) and
returns the result, so application code depends on the EmailTransport
interface rather than on a concrete client. Errors thrown or rejected by the
transport propagate unchanged.
import { send } from 'fino:email';
const result = await send({
from: 'a@example.test',
to: 'b@example.test',
subject: 'Welcome',
text: 'Thanks for signing up.'
}, { transport: myTransport });
console.log(result.id ?? result.accepted);
function smtpTransport(client: SmtpClient): EmailTransport
Use an SmtpClient wherever an EmailTransport is accepted.
SmtpClient already implements EmailTransport, so this identity helper
only narrows the type: it keeps the client's SMTP-specific AUTH options out
of code written against the generic transport interface.
import { SmtpClient, smtpTransport, send } from 'fino:email';
const client = new SmtpClient({ reader: replies, writer: socket });
await send(message, { transport: smtpTransport(client) });
async function dkimSign(message: string, options: {
domain: string;
selector: string;
privateKey: string | Uint8Array | ArrayBuffer;
headers: string[];
now?: number;
}): Promise<string>
Create a DKIM-Signature header for a rendered message.
Hashes the message body with SHA-256 to fill the bh= tag (base64url),
assembles the DKIM tag list
(v=1; a=hmac-sha256; d=...; s=...; t=...; h=...; bh=...), and signs that
tag list with HMAC-SHA256 keyed by privateKey to fill the b= tag (also
base64url). The hashed body is the text between the first blank line and
the next one (or the end of the message) — the full body of a simple
single-part message, but only the first block of a body that itself
contains blank lines, such as multipart output. headers populates the
h= tag naming the covered headers, and now overrides the t=
timestamp (seconds since the epoch) for deterministic output in tests.
Note the algorithm: a=hmac-sha256 is a symmetric-key scheme, so the
signature can only be checked by a party that shares the key. That suits
first-party verification pipelines; it is not the RSA/Ed25519 public-key
signing that third-party receivers expect from RFC 6376.
import { renderMessage, dkimSign } from 'fino:email';
const raw = renderMessage(message);
const header = await dkimSign(raw, {
domain: 'example.test',
selector: 's1',
privateKey: sharedKey,
headers: ['from', 'to', 'subject']
});
const signed = `${header}\r\n${raw}`;
function dkimSigner(options: Parameters<typeof dkimSign>[1])
Create a reusable DKIM signer function.
Binds the signing options once and returns a function that signs each rendered message it is given — convenient when every outbound message from a domain shares the same selector and key.
import { renderMessage, dkimSigner } from 'fino:email';
const sign = dkimSigner({
domain: 'example.test',
selector: 's1',
privateKey: sharedKey,
headers: ['from', 'to', 'subject']
});
const raw = renderMessage(message);
const header = await sign(raw);
Classes
class SmtpClient implements EmailTransport {
Minimal SMTP client that drives one complete submission transaction.
The client is connection-agnostic: server replies are consumed from the
reader queue (an array of reply lines, copied at construction, so every
reply the transaction will need must be supplied up front in protocol
order) and commands go out through writer.write(), which may return a
promise. This shape makes scripted sessions and tests straightforward —
the caller owns whatever produced the replies.
A single send() call performs the whole conversation: greeting, EHLO,
optional AUTH PLAIN, MAIL FROM, one RCPT TO per recipient, DATA
with the dot-stuffed rendered message, and QUIT.
import { SmtpClient } from 'fino:email';
const client = new SmtpClient({
reader: [
'220 mx.example.test ESMTP\r\n',
'250-mx.example.test\r\n250 AUTH PLAIN LOGIN\r\n',
'235 ok\r\n',
'250 sender ok\r\n',
'250 recipient ok\r\n',
'354 go\r\n',
'250 queued\r\n',
'221 bye\r\n'
],
writer: { write: (chunk) => socket.write(chunk) }
});
const result = await client.send({
from: 'a@example.test',
to: 'b@example.test',
subject: 'Hello',
text: 'Hi there'
}, { username: 'user', password: 'pass' });
Constructors
constructor(connection: {
reader: string[];
writer: {
write(chunk: string): void | Promise<void>;
};
})
Capture the server reply queue (copied) and the command writer for the transaction.
Methods
async send(message: EmailMessage, options: {
username?: string;
password?: string;
} = {}): Promise<EmailSendResult>
Send one message through the SMTP transaction.
Runs the full command sequence, checking the three-digit status code of
each queued reply and throwing on any unexpected code (for example
SMTP expected 250, got 550 ...). When username or password is
provided, the client issues AUTH PLAIN after EHLO. Envelope addresses
are extracted from angle-bracket display-name forms, and the rendered
message is dot-stuffed before the terminating CRLF.CRLF.
Resolves with the accepted envelope recipients. rejected is always
empty in the result because a refused recipient aborts the transaction
with a throw instead of being collected.