Email Sending
Transactional email with pluggable transports (SMTP, Resend, SendGrid, Mailgun, Alibaba DirectMail),.strav templates, automatic CSS inlining, and optional Tailwind support. To receive mail — provider webhooks (Postmark, Mailgun) or IMAP polling — see Email Receiving.
Quick start
import { mail } from '@strav/signal'
// Fluent builder
await mail.to('user@example.com')
.subject('Welcome!')
.template('welcome', { name: 'Alice' })
.send()
// Convenience method
await mail.send({
to: 'user@example.com',
subject: 'Welcome!',
template: 'welcome',
data: { name: 'Alice' },
})
// Raw HTML (no template)
await mail.raw({
to: 'user@example.com',
subject: 'Alert',
html: 'Something happened
',
text: 'Something happened',
})
Setup
Using a service provider (recommended)
import { MailProvider } from '@strav/signal'
app.use(new MailProvider())
The MailProvider registers MailManager as a singleton. It depends on the config provider.
To enable async sending via the queue, register the queue handler separately:
import { mail } from '@strav/signal'
mail.registerQueueHandler()
Manual setup
import { MailManager } from '@strav/signal'
import { mail } from '@strav/signal'
app.singleton(MailManager)
app.resolve(MailManager)
// Register queue handler for async sending (optional)
mail.registerQueueHandler()
Create config/mail.ts:
import { env } from '@strav/kernel'
export default {
default: env('MAIL_DRIVER', 'log'),
from: env('MAIL_FROM', 'noreply@localhost'),
templatePrefix: env('MAIL_TEMPLATE_PREFIX', 'emails'),
inlineCss: env.bool('MAIL_INLINE_CSS', true),
tailwind: env.bool('MAIL_TAILWIND', false),
smtp: {
host: env('SMTP_HOST', '127.0.0.1'),
port: env.int('SMTP_PORT', 587),
secure: env.bool('SMTP_SECURE', false),
auth: {
user: env('SMTP_USER', ''),
pass: env('SMTP_PASS', ''),
},
},
resend: {
apiKey: env('RESEND_API_KEY', ''),
},
sendgrid: {
apiKey: env('SENDGRID_API_KEY', ''),
},
mailgun: {
apiKey: env('MAILGUN_API_KEY', ''),
domain: env('MAILGUN_DOMAIN', ''),
},
alibaba: {
accessKeyId: env('ALIBABA_ACCESS_KEY_ID', ''),
accessKeySecret: env('ALIBABA_ACCESS_KEY_SECRET', ''),
accountName: env('ALIBABA_MAIL_ACCOUNT', ''),
},
log: {
output: env('MAIL_LOG_OUTPUT', 'console'),
},
}
Fluent builder
mail.to() returns a PendingMail with chainable methods:
await mail.to('user@example.com') // required
.from('support@app.com') // overrides config default
.cc('manager@app.com') // string or string[]
.bcc(['audit@app.com', 'log@app.com'])
.replyTo('support@app.com')
.subject('Your invoice is ready')
.template('invoice', { amount, items }) // .strav template + data
.attach({ filename: 'invoice.pdf', content: pdfBuffer, contentType: 'application/pdf' })
.send()
Template rendering
.template(name, data) renders a .strav template via the existing ViewEngine. The name is prefixed with the configured templatePrefix (default 'emails'), so .template('welcome', data) renders views/emails/welcome.strav.
Templates support full .strav syntax — @layout, @block, @include, @if, @each, {{ expr }}, {!! raw !!}.
Raw content
Use.html() and .text() instead of .template() for raw content:
await mail.to('user@example.com')
.subject('Quick note')
.html('Hello world
')
.text('Hello world')
.send()
Inspecting before send
Call.build() to get the finalized MailMessage without sending:
const message = await mail.to('test@example.com')
.subject('Test')
.template('welcome', { name: 'Alice' })
.build()
console.log(message.html) // rendered + CSS-inlined HTML
Email templates
Templates live underviews/emails/ (or wherever templatePrefix points). They support layout inheritance for consistent branding.
Email layout
{{-- views/emails/layout.strav --}}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #4f46e5; color: white; padding: 20px; text-align: center; }
.content { padding: 20px; }
.footer { color: #6b7280; font-size: 12px; text-align: center; padding: 20px; }
.btn { display: inline-block; background: #4f46e5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; }
</style>
</head>
<body>
<div class="container">
<div class="header"><h1>{{ appName }}</h1></div>
<div class="content">{!! content !!}</div>
<div class="footer">© {{ year }} {{ appName }}</div>
</div>
</body>
</html>
Email template
{{-- views/emails/welcome.strav --}}
@layout('emails.layout')
@block('content')
Welcome, {{ name }}!
Thanks for signing up. Your account is ready.
@end
Sending
await mail.to(user.email)
.subject('Welcome!')
.template('welcome', {
appName: 'My App',
year: new Date().getFullYear(),
name: user.name,
verifyUrl: `https://app.example.com/verify/${token}`,
})
.send()
The <style> block in the layout is automatically inlined into style="" attributes on each element before sending, so the email renders correctly in all clients.
CSS inlining
Most email clients strip<style> blocks and ignore external stylesheets. The mail module automatically inlines CSS using juice after template rendering.
This is enabled by default (inlineCss: true in config). The inliner:
- Converts
<style>rules to inlinestyle=""attributes - Preserves
@mediaqueries (for responsive email) - Preserves
@font-faceand@keyframes - Applies
width/heightas HTML attributes (Outlook compatibility) - Removes
<style>tags after inlining
To disable:
// config/mail.ts
export default {
inlineCss: false,
// ...
}
Tailwind CSS support
If your email templates use Tailwind utility classes, enable Tailwind compilation:
// config/mail.ts
export default {
tailwind: true,
// ...
}
When enabled, the mail module extracts class names from the rendered HTML, compiles them to CSS via Tailwind's programmatic API, injects a <style> block, then inlines everything with juice.
Tailwind is not a dependency of the framework — it is dynamically imported. Install it in your project if you want this feature:
bun add tailwindcss
If tailwindcss is not installed, the Tailwind step is silently skipped and only regular <style> blocks are inlined.
Transports
SMTP
Uses nodemailer under the hood.MAIL_DRIVER=smtp
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=apikey
SMTP_PASS=your-password
Works with any SMTP provider: Amazon SES, Postmark, Mailgun, Mailtrap, etc.
Resend
Uses the Resend HTTP API viafetch — no SDK needed.
MAIL_DRIVER=resend
RESEND_API_KEY=re_...
SendGrid
Uses the SendGrid v3 Mail Send API viafetch — no SDK needed.
MAIL_DRIVER=sendgrid
SENDGRID_API_KEY=SG....
Mailgun
Uses the Mailgun HTTP API viafetch — no SDK needed.
MAIL_DRIVER=mailgun
MAILGUN_API_KEY=key-...
MAILGUN_DOMAIN=mg.example.com
For EU regions, add a baseUrl in your config:
mailgun: {
apiKey: env('MAILGUN_API_KEY', ''),
domain: env('MAILGUN_DOMAIN', ''),
baseUrl: 'https://api.eu.mailgun.net',
},
Alibaba DirectMail
Uses the Alibaba Cloud DirectMail API viafetch with HMAC-SHA1 signature — no SDK needed.
MAIL_DRIVER=alibaba
ALIBABA_ACCESS_KEY_ID=LTAI...
ALIBABA_ACCESS_KEY_SECRET=...
ALIBABA_MAIL_ACCOUNT=noreply@example.com
For non-default regions, add a region in your config:
alibaba: {
accessKeyId: env('ALIBABA_ACCESS_KEY_ID', ''),
accessKeySecret: env('ALIBABA_ACCESS_KEY_SECRET', ''),
accountName: env('ALIBABA_MAIL_ACCOUNT', ''),
region: 'ap-southeast-1',
},
> Note: The Alibaba DirectMail SingleSendMail API does not support CC, BCC, or attachments. Use the SMTP transport with Alibaba's SMTP endpoint if you need those features.
Log
Logs email details to the console or a file. Useful for development and testing.
MAIL_DRIVER=log
MAIL_LOG_OUTPUT=console # or a file path like 'logs/mail.log'
Custom transport
Implement theMailTransport interface and swap it in:
import type { MailTransport, MailMessage, MailResult } from '@strav/signal'
import { MailManager } from '@strav/signal'
class PostmarkTransport implements MailTransport {
async send(message: MailMessage): Promise {
const response = await fetch('https://api.postmarkapp.com/email', {
method: 'POST',
headers: {
'X-Postmark-Server-Token': 'your-token',
'Content-Type': 'application/json',
},
body: JSON.stringify({
From: message.from,
To: Array.isArray(message.to) ? message.to.join(',') : message.to,
Subject: message.subject,
HtmlBody: message.html,
TextBody: message.text,
}),
})
const data = await response.json() as { MessageID: string }
return { messageId: data.MessageID }
}
}
// In bootstrap
MailManager.useTransport(new PostmarkTransport())
Queue integration
Use.queue() instead of .send() to push the email onto the job queue for async delivery:
await mail.to(user.email)
.subject('Weekly Report')
.template('report', { stats })
.queue()
// With options
await mail.to(user.email)
.subject('Reminder')
.template('reminder', { task })
.queue({ queue: 'emails', delay: 60_000 })
The template is rendered and CSS is inlined at enqueue time — the queue worker only needs to call the transport's send() method.
Register the queue handler in your bootstrap:
import { mail } from '@strav/signal'
mail.registerQueueHandler()
For multi-channel delivery (email + in-app + webhook + Discord) triggered by domain events, see the Notification module which reuses the Mail module for its email channel.
Attachments
await mail.to('user@example.com')
.subject('Your report')
.template('report', data)
.attach({
filename: 'report.pdf',
content: pdfBuffer,
contentType: 'application/pdf',
})
.send()
For inline images (CID), set the cid field:
await mail.to('user@example.com')
.subject('Photo')
.html('See this: 
')
.attach({
filename: 'photo.jpg',
content: imageBuffer,
contentType: 'image/jpeg',
cid: 'photo123',
})
.send()
Testing
Swap in a mock transport withMailManager.useTransport():
import { test, expect, beforeEach } from 'bun:test'
import { MailManager } from '@strav/signal'
import { mail } from '@strav/signal'
import type { MailTransport, MailMessage, MailResult } from '@strav/signal'
class MockTransport implements MailTransport {
sent: MailMessage[] = []
async send(message: MailMessage): Promise {
this.sent.push(message)
return { messageId: `mock-${this.sent.length}` }
}
}
let mockTransport: MockTransport
beforeEach(() => {
mockTransport = new MockTransport()
MailManager.useTransport(mockTransport)
})
test('sends welcome email', async () => {
await mail.to('user@example.com')
.subject('Welcome')
.html('Hi
')
.send()
expect(mockTransport.sent).toHaveLength(1)
expect(mockTransport.sent[0].subject).toBe('Welcome')
})
Controller example
import { mail } from '@strav/signal'
export default class InvitationController {
async create(ctx: Context) {
const [session, user, org] = ctx.get('session', 'user', 'organization')
const { email, role } = await ctx.body<{ email: string; role: string }>()
const token = randomHex(32)
await BaseModel.db.sql`
INSERT INTO "invitation" ("organization_id", "email", "role", "token", "invited_by")
VALUES (${org.id}, ${email}, ${role}, ${token}, ${user.id})
`
await mail.to(email)
.subject(`You're invited to ${org.name}`)
.template('invitation', {
appName: 'My App',
year: new Date().getFullYear(),
orgName: org.name,
inviterName: user.name,
acceptUrl: `https://app.example.com/invite/${token}`,
})
.send()
session.flash('success', `Invitation sent to ${email}.`)
return ctx.redirect(`/org/${org.slug}/members`)
}
}