Transactional Mail
@lastshotlabs/slingshot-mail owns outbound transactional email. It keeps three concerns separate:
provider transport, template rendering, and queueing.
When to use it
Section titled “When to use it”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
Quick start
Section titled “Quick start”bun add @lastshotlabs/slingshot-mailimport { 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 }), }, ],});Dead-letter callbacks
Section titled “Dead-letter callbacks”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-typecheckimport { 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.
Production notes
Section titled “Production notes”- Use a durable queue for production delivery if you cannot tolerate job loss on restart.
- Keep
fromexplicit even when the provider has a default sender. - Prefer template validation on startup so missing templates fail early.