Skip to content

Transactional Mail

@lastshotlabs/slingshot-mail owns outbound transactional email. It keeps three concerns separate: provider transport, template rendering, and queueing.

Use the mail package when you need:

  • templated emails driven by app events
  • a provider swap between Resend, SES, Postmark, or SendGrid
  • a memory queue for local development or a durable queue for production
Terminal window
bun add @lastshotlabs/slingshot-mail
import {
createMailPlugin,
createRawHtmlRenderer,
createResendProvider,
} from '@lastshotlabs/slingshot-mail';
function readWelcomePayload(payload: unknown): { email: string; identifier: string } {
if (
typeof payload === 'object' &&
payload !== null &&
'email' in payload &&
'identifier' in payload &&
typeof payload.email === 'string' &&
typeof payload.identifier === 'string'
) {
return { email: payload.email, identifier: payload.identifier };
}
throw new Error('auth:delivery.welcome payload is missing email or identifier');
}
const mail = createMailPlugin({
provider: createResendProvider({ apiKey: process.env.RESEND_KEY! }),
renderer: createRawHtmlRenderer({
templates: {
welcome: {
subject: 'Welcome to Slingshot',
html: '<p>Hello {{name}}</p>',
},
},
}),
from: 'noreply@example.com',
subscriptions: [
{
event: 'auth:delivery.welcome',
template: 'welcome',
recipientMapper: payload => readWelcomePayload(payload).email,
dataMapper: payload => ({ name: readWelcomePayload(payload).identifier }),
},
],
});

Both queue backends accept an onDeadLetter option for jobs that exhaust their retry budget or hit a permanent failure. The callback’s third argument is an optional HookServices accessor — use it to read framework state (entities, capabilities), emit observability events, or write to a DLQ entity:

// @skip-typecheck
import { createMailPlugin } from '@lastshotlabs/slingshot-mail';
createMailPlugin({
provider: /* ... */,
renderer: /* ... */,
from: 'noreply@example.com',
subscriptions: [],
onDeadLetter: (job, error, services) => {
services?.bus.emit('app:mail.dlq', { jobId: job.id, error: error.message });
},
});

services is undefined when the queue is invoked outside an app context (most unit tests). When the queue is plugin-owned, the plugin sets getHookServices during setupMiddleware so production paths always supply it. See HookServices for the contract and the late-binding pattern.

  • Use a durable queue for production delivery if you cannot tolerate job loss on restart.
  • Keep from explicit even when the provider has a default sender.
  • Prefer template validation on startup so missing templates fail early.