@setu-ts/mail-plugin

Transactional email. Registers an IMailer under CAPABILITIES.MAIL ('mail').

Four providers ship: LogProvider (zero-dependency default), SmtpProvider (over nodemailer), SesProvider (AWS SESv2), and SendGridProvider (SendGrid v3 HTTP API over fetch, so it is Workers-portable).

Installation

import { MailPlugin } from '@setu-ts/mail-plugin';

Usage

import { MailPlugin } from '@setu-ts/mail-plugin';
import { CAPABILITIES, type IMailer } from '@setu-ts/common';

app.register(MailPlugin({
  provider: 'sendgrid',
  options: { apiKey: process.env.SENDGRID_API_KEY! },
  defaults: { from: 'no-reply@example.com' },
  templates: {
    welcome: { html: '<p>Hello {{ name }}</p>' },
  },
}));

const mailer = app.services.get<IMailer>(CAPABILITIES.MAIL);

await mailer.send({ to: 'ada@example.com', subject: 'Hi', text: 'Hello' });

// `subject` is required on sendTemplate; the template supplies the body.
await mailer.sendTemplate('welcome', { to: 'ada@example.com', subject: 'Welcome' }, {
  name: 'Ada',
});

Options

OptionTypeDefaultDescription
provider'log' | 'smtp' | 'ses' | 'sendgrid''log'Backend.
optionsMailProviderOptions—Provider-specific configuration.
defaults{ from?: string }—Applied when a message omits the field.
templatesRecord<string, MailTemplate>—Named bodies available to sendTemplate: {{ variable }} strings, or view components rendered through CAPABILITIES.VIEW (see Templates).

LogProvider.messages retains the most recent 1,000 outgoing messages in send order. The optional sink still receives every message, including those later dropped from that retained tail.

Runtime support

SmtpProvider needs raw sockets, so it runs on Node/Deno/Bun only. LogProvider, SesProvider, and SendGridProvider work on every runtime including Cloudflare Workers.

Templates

A template is one of two arms, and the two never mix in one template (a template carrying both view and html is a compile error).

String templates render named {{ variable }} placeholders. The html body is HTML-escaped; a missing variable or an unknown template throws rather than rendering an empty string.

Component templates ({ view, text? }) render through the view engine registered under CAPABILITIES.VIEW — a JSX component, an html tagged template, or a plain (props) => string function — with sendTemplate’s data passed to each component verbatim as its props. view renders the HTML body; the optional text renders the plain-text body and is used verbatim, so write it as a plain (props) => string function — a text component written with the html tag or JSX is HTML-escaped like any other, which puts entities (&amp;) into a plain-text mail. Configuring one requires a CAPABILITIES.VIEW provider: MailPlugin refuses at register() otherwise, naming both remedies, so the failure is a startup failure rather than a throw on the first send.

import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { ViewPlugin } from '@setu-ts/view-plugin';
import { MailPlugin } from '@setu-ts/mail-plugin';
import { CAPABILITIES, type IMailer } from '@setu-ts/common';
import { html } from 'hono/html';

interface WelcomeProps {
  readonly name: string;
  readonly plan: string;
}

// The `html` tag escapes `name` and `plan`; a JSX component would too.
const WelcomeHtml = (p: WelcomeProps) =>
  html`
    <h1>Welcome ${p.name}</h1>
    <p>You are on the ${p.plan} plan.</p>
  `;
const WelcomeText = (p: WelcomeProps) => `Welcome ${p.name}. You are on the ${p.plan} plan.`;

const app = createApplication({
  plugins: [
    RuntimePlugin(),
    ViewPlugin({ engine: 'hono-html' }),
    MailPlugin({
      defaults: { from: 'no-reply@example.com' },
      templates: {
        welcome: { view: WelcomeHtml, text: WelcomeText },
      },
    }),
  ],
});
await app.start();

const mailer = app.services.get<IMailer>(CAPABILITIES.MAIL);
await mailer.sendTemplate('welcome', { to: 'ada@example.com', subject: 'Welcome' }, {
  name: 'Ada',
  plan: 'team',
});

Two things differ from the string arm. Escaping is the rendering runtime’s: an html template and a JSX component escape their interpolations, while a hand-written template literal does not (the same caveat IViewEngine.render carries for a page). And there is no missing-key check: a component reads whatever it reads, so a key absent from data renders as undefined rather than throwing. The committed sendTemplate signature types data as Record<string, unknown>; for compile-time props, render by hand — engine.render(WelcomeHtml, props) on the engine resolved from CAPABILITIES.VIEW, then mailer.send({ ..., html }).

TemplateEngine.render is asynchronous for both arms, because a view component’s render may be.

Health indicator

Registered under the mail capability. Since M70c it reports two signals: the provider’s lifecycle (isReady()) and its reachability (isHealthy()).

StatusMeaning
upThe provider is connected and reachable, or cannot be probed (reachable is 'unknown').
downThe provider is not connected, or is connected but unreachable.

data reports { provider, reachable }, where reachable is true, false, or 'unknown' when the provider has no liveness check (e.g. the log provider always reports true).

Readiness (M119b, V10-49). The indicator is registered outside readiness by default. Its status mapping is unchanged: a mail backend that is unreachable still reads down, so /health shows the outage (503) and alerts still fire — but /ready ignores the indicator and stays 200, because an SMTP outage is shared by every replica and does not stop request serving. An application that wants a mail outage to drain its replicas restores the previous behaviour with HealthPlugin({ readiness: { include: ['mail'] } }).

Exports

ExportKind
adaptNodemailerModulefunction
adaptSesModulefunction
createProviderfunction
escapeHtmlfunction
loadNodemailerModulefunction
loadSesModulefunction
MailPluginfunction
toNodemailerMessagefunction
toSendGridBodyfunction
toSesInputfunction
validateSesClientfunction
validateSmtpTransportfunction
LogProviderclass
MailServiceclass
SendGridProviderclass
SesProviderclass
SmtpProviderclass
TemplateEngineclass
IMailerinterface
ISesClientinterface
ISmtpTransportinterface
LogProviderOptionsinterface
MailComponentTemplateinterface
MailMessageinterface
MailPluginOptionsinterface
MailProviderOptionsinterface
MailServiceOptionsinterface
MailStringTemplateinterface
NodemailerModuleinterface
RenderedTemplateinterface
SendGridProviderOptionsinterface
SesProviderOptionsinterface
SesSdkModuleinterface
SmtpProviderOptionsinterface
IMailHttptype
MailProviderTypetype
MailTemplatetype
OutgoingMailtype

Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it drifts.

Full API

Every export and option is documented in PUBLIC_API.md.

API reference

MailPlugin() (@setu-ts/mail-plugin)

Provides email sending: registers an IMailer under CAPABILITIES.MAIL, backed by a pluggable provider. The default provider is 'log' (zero-dependency, every runtime — records/logs each message instead of sending it). No mail SDK is a hard dependency — the SMTP and SES providers accept an injected client facade or lazily import their package (AI_GUIDELINES §12.2), and SendGrid sends over web-standard fetch. A zero-dependency template engine renders named {{ variable }} bodies for sendTemplate.

Registration

import { MailPlugin } from '@setu-ts/mail-plugin';

// Log provider (default) — records mail instead of sending; for tests/local dev
app.register(MailPlugin({ defaults: { from: 'noreply@myapp.com' } }));

// SMTP via nodemailer (Node/Deno/Bun only — not Cloudflare Workers)
app.register(
  MailPlugin({
    provider: 'smtp',
    options: {
      host: config.get('SMTP_HOST'),
      port: 587,
      auth: { user: config.get('SMTP_USER'), pass: config.get('SMTP_PASS') },
    },
    defaults: { from: 'noreply@myapp.com' },
    templates: { welcome: { html: '<h1>Hi {{ name }}</h1>', text: 'Hi {{ name }}' } },
  }),
);

// SendGrid v3 HTTP API (zero dependency, Workers-portable)
app.register(
  MailPlugin({
    provider: 'sendgrid',
    options: { apiKey: config.get('SENDGRID_API_KEY') },
    defaults: { from: 'noreply@myapp.com' },
  }),
);

Usage

import { CAPABILITIES } from '@setu-ts/common';
import type { IMailer } from '@setu-ts/common';

app.router.post('/users', async (ctx) => {
  const mailer = ctx.services.get<IMailer>(CAPABILITIES.MAIL);
  const user = await createUser(ctx.request.body);

  await mailer.send({
    to: user.email,
    subject: 'Welcome to MyApp',
    html: '<h1>Welcome!</h1><p>Thank you for joining.</p>',
    text: 'Welcome! Thank you for joining.',
  });

  // Using templates — `subject` is required on the envelope (the committed
  // IMailer.sendTemplate signature keeps it); the template supplies html/text.
  await mailer.sendTemplate(
    'welcome',
    {
      to: user.email,
      subject: 'Welcome to MyApp',
    },
    {
      name: user.name,
    },
  );

  return ctx.response.status(201).json(user);
});

Options

OptionProviderDescription
provider—'log' (default), 'smtp', 'ses', 'sendgrid'.
defaults.fromallSender applied when a message omits from; else send throws.
templatesallNamed body templates for sendTemplate: { html?, text? } strings, or { view, text? } view components rendered through CAPABILITIES.VIEW (M102).
options.host / port / secure / authsmtpnodemailer transport config (ignored when transport injected).
options.transportsmtpInjected ISmtpTransport facade (bypasses the lazy import).
options.region / accessKeyId / secretAccessKeysesAWS client config (ignored when client injected).
options.clientsesInjected ISesClient facade (bypasses the lazy SDK import).
options.apiKey / endpointsendgridSendGrid API key (Bearer) and endpoint (default v3 send URL).
options.httpsendgridInjected fetch-shaped function (defaults to global fetch).
options.sinklogCalled with each sent OutgoingMail — a read-back seam.

Exports

  • MailPlugin(options?) — plugin factory. createProvider(type, options, ctx) — provider builder.
  • MailService — the IMailer implementation (default-from resolution + template dispatch).
  • TemplateEngine, escapeHtml — the template renderer (both arms; render is asynchronous since M102) and its HTML escaper.
  • LogProvider, SmtpProvider, SesProvider, SendGridProvider — provider classes.
  • adaptNodemailerModule / loadNodemailerModule / toNodemailerMessage / validateSmtpTransport, adaptSesModule / loadSesModule / toSesInput / validateSesClient, toSendGridBody — provider adapter/mapper/validator helpers.
  • MailServiceOptions, MailPluginOptions, MailProviderType, MailProviderOptions, MailTemplate, MailStringTemplate, MailComponentTemplate, OutgoingMail, RenderedTemplate, LogProviderOptions, SmtpProviderOptions, NodemailerModule, SesProviderOptions, SesSdkModule, SendGridProviderOptions — types.
  • ISmtpTransport, ISesClient, IMailHttp — structural injection types.
  • IMailer, MailMessage — re-exported from @setu-ts/common.

Notes

  • IMailer.isHealthy?() (OPTIONAL, added in 0.7.0) reports the transport’s REACHABILITY, distinct from whether the mailer was constructed: true reachable, false contacted and unreachable, undefined when the question cannot be asked — never read undefined as healthy. MailService implements it by delegating to the configured provider’s own probe, so a provider with none yields undefined. Every built-in provider that ships a probe answers concretely — log is always true (it touches no network) and sendgrid calls the scopes endpoint — so the undefined arm is reached by the two providers that DROP their probe when the injected dependency cannot support one: ses with a client exposing no isHealthy, and smtp with a transport exposing no verify(). The mail health indicator reads reachability through this same method rather than past it to the provider, so the capability and the indicator cannot disagree. It exists on IMailer so a HOLDER of the capability can ask — notification-plugin’s email channel does, and AI_GUIDELINES §2.2 forbids it importing mail-plugin to find a probe.
  • sendTemplate’s envelope is Omit<MailMessage, 'html' | 'text'> — subject stays REQUIRED; the template provides the html/text bodies only, never the subject.
  • MailTemplate is a union of two arms that never mix in one template (a template carrying both view and html is a compile error). String arm (MailStringTemplate): in an HTML template, interpolated data values are HTML-escaped (& < > " '); text templates substitute raw. A placeholder whose key is absent from data, or an unknown template name, rejects. Component arm (MailComponentTemplate, M102): view and the optional text are Components rendered through the IViewEngine registered under CAPABILITIES.VIEW, with data passed verbatim as each component’s props — view into html, text into text (verbatim; write it as a plain (props) => string function, since one written with the html tag or JSX is HTML-escaped by its runtime and puts entities such as &amp; into a plain-text body). Escaping is the rendering runtime’s (an html tagged template and a JSX component escape; a hand-written template literal does not). There is NO missing-key check on this arm: an absent key renders as undefined rather than throwing, because the committed sendTemplate types data as Record<string, unknown>; the compile-time route is engine.render(Component, props) by hand, then mailer.send. MailPlugin declares CAPABILITIES.VIEW in optionalDependencies and resolves the engine ONCE at register(); a component template configured with no provider fails at register() naming both remedies (register ViewPlugin or any other provider of the token, or remove the component templates) — never on the first sendTemplate. Only the registry is consulted: a container-supplied engine registered by DecoratorPlugin lands after this plugin has run. A rendering failure (ViewRenderError, UnresolvedSuspenseError) propagates unwrapped and the provider is never reached.
  • TemplateEngine.render(name, data) returns Promise<RenderedTemplate> (asynchronous since M102, because IViewEngine.render may answer a promise); every refusal is a rejection.
  • LogProvider never sends real email — .messages retains the most recent 1,000 messages in send order, while sink receives every message and logging uses ctx.logger. Like every provider, it refuses a send outside connect().. disconnect() with 'LogProvider is not connected', so a send after app.stop() rejects. SmtpProvider needs raw sockets, so it is Node/Deno/Bun only; SendGridProvider is the Cloudflare Workers-portable path.

Health status

Since M70c the indicator reports two signals: the provider’s lifecycle (isReady()) and its reachability (isHealthy()).

StatusMeaning
upThe provider is connected and reachable, or cannot be probed (reachable is 'unknown').
downThe provider is not connected, or is connected but unreachable.

data reports { provider, reachable }, where reachable is true, false, or 'unknown' when the provider has no liveness check (e.g. the log provider always reports true).

Agent card · TypeDoc API reference