Messaging
Transactional email delivery through Valet's outbox worker.
Valet messaging is outbox-based. Your action code never talks to the provider directly. Instead:
- The action writes an outbox row with
ctx.email.enqueue(...) - The action commits
- Valet's outbox worker delivers the email after commit
That gives you atomic database-plus-message behavior.
Send an email
import { defineAction, v } from './_generated/valet/api'
export const sendReceipt = defineAction({
args: { orderId: v.id('orders') },
access: 'authenticated',
handler: async (ctx, args) => {
const order = await ctx.db.get(args.orderId)
if (!order) throw new Error('Order not found')
await ctx.email.enqueue({
to: order.email,
subject: 'Your receipt',
html: `<p>Order ${order._id} is confirmed.</p>`,
})
return { accepted: true }
},
})Supported fields:
await ctx.email.enqueue({
to: 'user@example.com', // or ['a@example.com', 'b@example.com']
subject: 'Hello',
html: '<p>Hello</p>', // html or text is required
text: 'Hello',
from: 'Acme <hello@example.com>', // optional
deliveryKey: 'receipt-order-123', // recommended for retry-safe actions
})Provider configuration
By default, Valet uses the local log transport. That is ideal in development because the worker accepts the email and logs the payload instead of calling a real provider.
To send through Resend:
bunx valet-dev env set VALET_EMAIL_PROVIDER resend
bunx valet-dev env set RESEND_API_KEY re_xxx
bunx valet-dev env set VALET_EMAIL_FROM "Acme <hello@example.com>"Relevant server-side env vars:
VALET_EMAIL_PROVIDER:logorresendRESEND_API_KEY: required for Resend deliveryVALET_EMAIL_FROM: default sender when the message omitsfrom
Transactions and atomicity
Email enqueue is safe inside ctx.db.transaction(...):
await ctx.db.transaction(async (tx) => {
const order = await tx.get(args.orderId)
if (!order) throw new Error('Order not found')
await tx.patch(order._id, { status: 'paid' })
await ctx.email.enqueue({
to: order.email,
subject: 'Payment received',
text: 'Your order is confirmed.',
})
})If the transaction fails, the outbox row is rolled back with the database writes.
That is the main reason ctx.email.enqueue(...) exists instead of exposing raw provider SDKs inside user code.
deliveryKey and retry-safe actions
If an action uses safeToRetry: true, it must give each enqueue a stable deliveryKey:
await ctx.email.enqueue({
to: order.email,
subject: 'Your receipt',
html: `<p>Order ${order._id} is confirmed.</p>`,
deliveryKey: `receipt-${order._id}`,
})Without deliveryKey, a retry-safe action is rejected at runtime because the outbox enqueue would be ambiguous on retry.
Delivery model
The worker retries transient failures with backoff. Durable states are visible in _valet_outbox and the admin surface:
curl "$VALET_PROJECT_URL/admin/outbox" \
-H "Authorization: Bearer $VALET_DEPLOY_KEY"Filter by status:
curl "$VALET_PROJECT_URL/admin/outbox?status=dead" \
-H "Authorization: Bearer $VALET_DEPLOY_KEY"Common statuses:
pending: queued and not yet attemptedsending: currently leased by the workerfailed: retryable failure waiting for the next attemptdelivered: provider accepted the messagedead: permanently failed after retries or validation
Valet also prunes delivered and dead rows after their retention windows so the outbox does not grow forever.
Push notifications
ctx.push.enqueue(...) is present in the API surface, but push delivery is still deferred in this branch. Calling it throws a clear runtime error that points you back to ctx.email.enqueue(...).
Debugging delivery
When email does not show up:
- Check the action result first. A failed action never commits the outbox row.
- Query
/admin/outboxand look atstatus,attemptCount, andlastError. - Confirm
VALET_EMAIL_PROVIDER,RESEND_API_KEY, andVALET_EMAIL_FROM. - If the row is
dead, fix the provider/config issue and enqueue a new message.