Skip to content

Email

Stratal provides an EmailModule for sending transactional emails asynchronously through a queue. Email is delivered by a built-in SMTP client that runs over cloudflare:sockets, so there are zero runtime dependencies for sending. Point the module at any SMTP endpoint (Resend, Postmark, SendGrid, Mailgun, or a self-hosted server) and it just works. The module supports React Email templates, batch sending, and both inline and storage-based attachments. Emails are dispatched to a queue and processed by a built-in consumer, keeping your request handlers fast.

Sending plain html or text email needs no extra packages. The SMTP client is built in.

If you want to render React Email templates, install the renderer and React:

Terminal window
yarn add @react-email/render react

The EmailModule requires the QueueModule, since emails are dispatched through a queue. Register the queue, then configure the email module with your SMTP URL:

import { Module } from 'stratal/module'
import { DI_TOKENS } from 'stratal/di'
import type { StratalEnv } from 'stratal'
import { QueueModule } from 'stratal/queue'
import { EmailModule } from 'stratal/email'
@Module({
imports: [
QueueModule.forRootAsync({
inject: [DI_TOKENS.CloudflareEnv],
useFactory: (env: StratalEnv) => ({
provider: env.ENVIRONMENT === 'test' ? 'sync' : 'cloudflare',
}),
}),
QueueModule.registerQueue('email-queue'),
EmailModule.forRootAsync({
inject: [DI_TOKENS.CloudflareEnv],
useFactory: (env: StratalEnv) => ({
from: { name: 'My App', email: 'noreply@example.com' },
smtp: { url: env.SMTP_URL },
queue: 'email-queue',
}),
}),
],
})
export class AppModule {}

Store the SMTP URL as a secret (for example SMTP_URL) rather than hardcoding credentials.

The smtp.url carries the host, port, and credentials. The scheme selects the transport security:

smtp://user:pass@host:port

Upgrades a plaintext connection to TLS with STARTTLS. Default port 587.

Pointing at a hosted provider is a matter of using its SMTP endpoint and credentials in the URL:

Terminal window
# Resend
SMTP_URL="smtp://resend:re_xxxxxxxx@smtp.resend.com:587"
# Postmark
SMTP_URL="smtp://<token>:<token>@smtp.postmarkapp.com:587"
# SendGrid
SMTP_URL="smtp://apikey:SG.xxxxxxxx@smtp.sendgrid.net:587"
OptionTypeRequiredDescription
from{ name: string; email: string }YesDefault sender address
smtp{ url: string }YesSMTP connection URL (smtp:// or smtps://)
queueQueueBindingYesQueue binding for email dispatch (must be registered with QueueModule.registerQueue)
replyTostringNoDefault reply-to address

Inject EmailService and call send:

import { Transient, inject } from 'stratal/di'
import { EMAIL_TOKENS, type EmailService } from 'stratal/email'
@Transient()
export class NotificationService {
constructor(
@inject(EMAIL_TOKENS.EmailService) private readonly email: EmailService,
) {}
async sendWelcome(userEmail: string, userName: string) {
await this.email.send({
to: userEmail,
subject: `Welcome, ${userName}!`,
html: `<h1>Welcome to our platform, ${userName}!</h1>`,
})
}
}

The send method accepts the following fields:

FieldTypeRequiredDescription
tostring | string[]YesRecipient email(s)
from{ name?: string; email: string }NoSender (defaults to module config)
subjectstringYesEmail subject (1-500 characters)
htmlstringOne of html, text, or templateHTML content
textstringOne of html, text, or templatePlain text content
templateReactElementOne of html, text, or templateReact Email template
ccstring[]NoCarbon copy recipients
bccstring[]NoBlind carbon copy recipients
replyTostringNoReply-to address
attachmentsEmailAttachment[]NoEmail attachments
metadataRecord<string, unknown>NoCustom metadata for tracking

Instead of writing raw HTML, you can use React Email components. Pass a React element as the template prop:

import React from 'react'
import { WelcomeEmail } from './templates/welcome'
await this.email.send({
to: 'user@example.com',
subject: 'Welcome!',
template: React.createElement(WelcomeEmail, { name: 'Alice' }),
})

The template is rendered to HTML before being dispatched to the queue. Define your templates as regular React components:

templates/welcome.tsx
import { Html, Body, Heading, Text } from '@react-email/components'
interface WelcomeEmailProps {
name: string
}
export function WelcomeEmail({ name }: WelcomeEmailProps) {
return (
<Html>
<Body>
<Heading>Welcome, {name}!</Heading>
<Text>We are glad to have you on board.</Text>
</Body>
</Html>
)
}

Send multiple emails in one call using sendBatch:

await this.email.sendBatch({
messages: [
{
to: 'user1@example.com',
subject: 'Notification',
text: 'Hello user 1',
},
{
to: 'user2@example.com',
subject: 'Notification',
text: 'Hello user 2',
},
],
})

Each message is dispatched as a separate queue entry with message type email.send. The batch can contain up to 100 messages.

The email module supports two types of attachments. Each attachment is hard-capped at 20 MB: it is fully buffered before base64 encoding, and exceeding the cap throws.

Provide the file content as a base64-encoded string:

await this.email.send({
to: 'user@example.com',
subject: 'Your invoice',
text: 'Please find your invoice attached.',
attachments: [
{
filename: 'invoice.pdf',
content: base64EncodedPdf,
contentType: 'application/pdf',
},
],
})

Reference a file stored in the Storage module:

await this.email.send({
to: 'user@example.com',
subject: 'Your report',
text: 'Please find your report attached.',
attachments: [
{
filename: 'report.pdf',
storageKey: 'reports/2026/02/monthly-report.pdf',
disk: 'documents',
},
],
})

The EmailConsumer downloads the file from storage at processing time and attaches it to the email.

The email module uses the queue system internally. Here is how the flow works:

  1. You call emailService.send() in your request handler.
  2. The email service renders any React template and dispatches a queue message with type email.send.
  3. The EmailConsumer (auto-registered by the module) picks up the message.
  4. The consumer resolves attachments (decodes inline base64 or downloads from storage).
  5. The consumer sends the message over the built-in SMTP client.
  6. On success, the message is acknowledged. On failure, it is retried.

You do not need to register the EmailConsumer yourself. It is included automatically when you configure the EmailModule.

Email dispatch uses Cloudflare Queues’ built-in retry mechanism:

  • Max retries: 3 attempts with automatic exponential backoff.
  • Dead letter queue: Messages that fail all retries are routed to the configured DLQ for manual inspection.

Configure these options in your wrangler.jsonc consumer entry. See the Queues wrangler configuration for the full example.

Mailpit provides a local SMTP server with a web UI for inspecting captured emails. Run Mailpit with Docker and point the email module at it:

Terminal window
# Start Mailpit
docker run -d --name mailpit -p 8025:8025 -p 1025:1025 axllent/mailpit
# Or via Docker Compose if you have it in your docker-compose.yml
docker compose up -d

Mailpit listens for plaintext SMTP on port 1025, so use an smtp:// URL:

EmailModule.forRoot({
from: { name: 'App', email: 'noreply@example.com' },
smtp: { url: 'smtp://localhost:1025' },
queue: 'email-queue',
})

Open http://localhost:8025 to view captured emails in the Mailpit web UI. All emails sent through your application will appear there instead of being delivered to real recipients.

  • Queues for details on queue configuration and consumers.
  • Storage for setting up file storage used by storage-based attachments.
  • Internationalization for translating email subjects and content.