@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
| Option | Type | Default | Description |
|---|---|---|---|
provider | 'log' | 'smtp' | 'ses' | 'sendgrid' | 'log' | Backend. |
options | MailProviderOptions | — | Provider-specific configuration. |
defaults | { from?: string } | — | Applied when a message omits the field. |
templates | Record<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 (&) 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()).
| Status | Meaning |
|---|---|
up | The provider is connected and reachable, or cannot be probed (reachable is 'unknown'). |
down | The 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
| Export | Kind |
|---|---|
adaptNodemailerModule | function |
adaptSesModule | function |
createProvider | function |
escapeHtml | function |
loadNodemailerModule | function |
loadSesModule | function |
MailPlugin | function |
toNodemailerMessage | function |
toSendGridBody | function |
toSesInput | function |
validateSesClient | function |
validateSmtpTransport | function |
LogProvider | class |
MailService | class |
SendGridProvider | class |
SesProvider | class |
SmtpProvider | class |
TemplateEngine | class |
IMailer | interface |
ISesClient | interface |
ISmtpTransport | interface |
LogProviderOptions | interface |
MailComponentTemplate | interface |
MailMessage | interface |
MailPluginOptions | interface |
MailProviderOptions | interface |
MailServiceOptions | interface |
MailStringTemplate | interface |
NodemailerModule | interface |
RenderedTemplate | interface |
SendGridProviderOptions | interface |
SesProviderOptions | interface |
SesSdkModule | interface |
SmtpProviderOptions | interface |
IMailHttp | type |
MailProviderType | type |
MailTemplate | type |
OutgoingMail | type |
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
| Option | Provider | Description |
|---|---|---|
provider | — | 'log' (default), 'smtp', 'ses', 'sendgrid'. |
defaults.from | all | Sender applied when a message omits from; else send throws. |
templates | all | Named body templates for sendTemplate: { html?, text? } strings, or { view, text? } view components rendered through CAPABILITIES.VIEW (M102). |
options.host / port / secure / auth | smtp | nodemailer transport config (ignored when transport injected). |
options.transport | smtp | Injected ISmtpTransport facade (bypasses the lazy import). |
options.region / accessKeyId / secretAccessKey | ses | AWS client config (ignored when client injected). |
options.client | ses | Injected ISesClient facade (bypasses the lazy SDK import). |
options.apiKey / endpoint | sendgrid | SendGrid API key (Bearer) and endpoint (default v3 send URL). |
options.http | sendgrid | Injected fetch-shaped function (defaults to global fetch). |
options.sink | log | Called with each sent OutgoingMail — a read-back seam. |
Exports
MailPlugin(options?)— plugin factory.createProvider(type, options, ctx)— provider builder.MailService— theIMailerimplementation (default-fromresolution + template dispatch).TemplateEngine,escapeHtml— the template renderer (both arms;renderis 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 in0.7.0) reports the transport’s REACHABILITY, distinct from whether the mailer was constructed:truereachable,falsecontacted and unreachable,undefinedwhen the question cannot be asked — never readundefinedas healthy.MailServiceimplements it by delegating to the configured provider’s own probe, so a provider with none yieldsundefined. Every built-in provider that ships a probe answers concretely —logis alwaystrue(it touches no network) andsendgridcalls the scopes endpoint — so theundefinedarm is reached by the two providers that DROP their probe when the injected dependency cannot support one:seswith a client exposing noisHealthy, andsmtpwith a transport exposing noverify(). Themailhealth indicator reads reachability through this same method rather than past it to the provider, so the capability and the indicator cannot disagree. It exists onIMailerso a HOLDER of the capability can ask —notification-plugin’s email channel does, and AI_GUIDELINES §2.2 forbids it importingmail-pluginto find a probe.sendTemplate’s envelope isOmit<MailMessage, 'html' | 'text'>—subjectstays REQUIRED; the template provides thehtml/textbodies only, never the subject.MailTemplateis a union of two arms that never mix in one template (a template carrying bothviewandhtmlis a compile error). String arm (MailStringTemplate): in an HTML template, interpolateddatavalues are HTML-escaped (& < > " '); text templates substitute raw. A placeholder whose key is absent fromdata, or an unknown template name, rejects. Component arm (MailComponentTemplate, M102):viewand the optionaltextareComponents rendered through theIViewEngineregistered underCAPABILITIES.VIEW, withdatapassed verbatim as each component’s props —viewintohtml,textintotext(verbatim; write it as a plain(props) => stringfunction, since one written with thehtmltag or JSX is HTML-escaped by its runtime and puts entities such as&into a plain-text body). Escaping is the rendering runtime’s (anhtmltagged 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 asundefinedrather than throwing, because the committedsendTemplatetypesdataasRecord<string, unknown>; the compile-time route isengine.render(Component, props)by hand, thenmailer.send.MailPlugindeclaresCAPABILITIES.VIEWinoptionalDependenciesand resolves the engine ONCE atregister(); a component template configured with no provider fails atregister()naming both remedies (registerViewPluginor any other provider of the token, or remove the component templates) — never on the firstsendTemplate. Only the registry is consulted: a container-supplied engine registered byDecoratorPluginlands after this plugin has run. A rendering failure (ViewRenderError,UnresolvedSuspenseError) propagates unwrapped and the provider is never reached.TemplateEngine.render(name, data)returnsPromise<RenderedTemplate>(asynchronous since M102, becauseIViewEngine.rendermay answer a promise); every refusal is a rejection.LogProvidernever sends real email —.messagesretains the most recent 1,000 messages in send order, whilesinkreceives every message and logging usesctx.logger. Like every provider, it refuses a send outsideconnect()..disconnect()with'LogProvider is not connected', so a send afterapp.stop()rejects.SmtpProviderneeds raw sockets, so it is Node/Deno/Bun only;SendGridProvideris the Cloudflare Workers-portable path.
Health status
Since M70c the indicator reports two signals: the provider’s lifecycle (isReady()) and its
reachability (isHealthy()).
| Status | Meaning |
|---|---|
up | The provider is connected and reachable, or cannot be probed (reachable is 'unknown'). |
down | The 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).