email

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:

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.