ValetAlpha

Messaging

Transactional email delivery through Valet's outbox worker.

Valet messaging is outbox-based. Your action code never talks to the provider directly. Instead:

  1. The action writes an outbox row with ctx.email.enqueue(...)
  2. The action commits
  3. 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: log or resend
  • RESEND_API_KEY: required for Resend delivery
  • VALET_EMAIL_FROM: default sender when the message omits from

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 attempted
  • sending: currently leased by the worker
  • failed: retryable failure waiting for the next attempt
  • delivered: provider accepted the message
  • dead: 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:

  1. Check the action result first. A failed action never commits the outbox row.
  2. Query /admin/outbox and look at status, attemptCount, and lastError.
  3. Confirm VALET_EMAIL_PROVIDER, RESEND_API_KEY, and VALET_EMAIL_FROM.
  4. If the row is dead, fix the provider/config issue and enqueue a new message.

On this page