@groundmail/convex is a Convex component for sending transactional email with Groundmail. Call sendEmail from a mutation; the component stores the email in your Convex database, sends it in the background, retries temporary failures without ever sending twice, and keeps each email's delivery status current from Groundmail webhooks.
- Durable: the email is queued in the same transaction as the rest of your mutation. If the mutation fails, nothing is sent.
- Idempotent: pass an
idempotencyKeyand a second send with the same key returns the first email. Every retry to Groundmail carries the sameIdempotency-Keyheader. - Delivery status: delivered, bounced, complained, opened and clicked, queryable and reactive like any Convex data.
Install
npm install @groundmail/convexAdd the component to your app in convex/convex.config.ts:
import { defineApp } from 'convex/server'
import groundmail from '@groundmail/convex/convex.config.js'
const app = defineApp()
app.use(groundmail)
export default appSet your API key on the deployment (create one under API Keys in the dashboard; a gm_test_… key sends nothing, which is handy in development):
npx convex env set GROUNDMAIL_API_KEY gm_your_key_hereThe from address must be on a domain you have verified in Groundmail.
Send an email
// convex/emails.ts
import { Groundmail } from '@groundmail/convex'
import { components } from './_generated/api'
import { mutation } from './_generated/server'
import { v } from 'convex/values'
export const groundmail = new Groundmail(components.groundmail)
export const sendWelcome = mutation({
args: { userId: v.id('users') },
handler: async (ctx, { userId }) => {
const user = await ctx.db.get(userId)
if (!user) throw new Error('No such user')
return groundmail.sendEmail(ctx, {
from: { email: 'hello@yourdomain.com', name: 'Acme' },
to: user.email,
subject: 'Welcome to Acme',
html: '<p>Thanks for signing up.</p>',
tags: { user_id: userId },
idempotencyKey: `welcome:${userId}`,
})
},
})sendEmail returns an id for the email. You can also send a published template with template: { id, variables } instead of html/text, and set cc, bcc, replyTo, headers, attachments, scheduledAt, category, tenant and unsubscribe as in the send API.
Idempotency
With idempotencyKey, a second sendEmail with the same key returns the first email's id and sends nothing, so a retried mutation or a double-clicked button sends one email. Use a key tied to what caused the email, such as welcome:${userId} or receipt:${orderId}.
Without a key, each call is a new email, and the component still gives it its own Idempotency-Key so retries to Groundmail never send it twice.
Retries
Network errors, rate limits (429) and server errors (500, 502, 503) are retried with backoff: 5 retries by default, starting at 10 seconds and doubling. Each retry carries the same Idempotency-Key, and Groundmail releases the key after these errors, so a retry really sends the email, once. A rejected request (an unverified from domain, a suppressed recipient, an invalid address) is not retried; the email's status becomes failed with Groundmail's error message and code.
A 504 means the mail server may still deliver the email. Groundmail keeps that answer under the Idempotency-Key and returns it again for 24 hours, so the component does not retry: the status becomes unknown, and a later webhook event (email.sent, email.delivered, …) moves it on if the email went out. Check groundmailId, or the email log in the dashboard, before sending it again yourself.
If a delivery attempt stalls (the background action crashes or times out before it records a result), a cron inside the component picks the email up after a few minutes and tries again with the same Idempotency-Key. Stalled attempts count toward retryAttempts; when they run out, the status becomes unknown.
const groundmail = new Groundmail(components.groundmail, {
retryAttempts: 3,
initialBackoffMs: 5_000,
})Check delivery status
// convex/emails.ts
import type { EmailId } from '@groundmail/convex'
import { query } from './_generated/server'
export const emailStatus = query({
args: { emailId: v.string() },
handler: async (ctx, { emailId }) => groundmail.status(ctx, emailId as EmailId),
})status returns { status, errorMessage, bounceType, complained, opened, clicked, groundmailId }. status is one of:
| Status | Meaning |
|---|---|
queued |
Stored, about to be sent to Groundmail |
sending |
Being handed to Groundmail; can no longer be cancelled |
scheduled |
Accepted with scheduledAt; it goes out then |
sent |
Accepted for delivery |
delivery_delayed |
The receiving server deferred it; delivery continues |
delivered |
Accepted by the recipient's mailbox provider |
held |
Held before sending: by content scanning, or while sending is paused |
bounced |
Bounced; bounceType is hard or block |
failed |
Rejected by the API, out of retries, or delivery failed |
suppressed |
A recipient is on your suppression list |
cancelled |
Cancelled with cancelEmail before it was sent |
unknown |
Groundmail could not confirm whether it was sent (see Retries); webhooks update it |
groundmail.get(ctx, emailId) returns the whole stored email, and groundmail.cancelEmail(ctx, emailId) cancels one that is still queued. It returns false once sending has started, so an email is never both sent and recorded as cancelled.
Without webhooks, statuses stop at sent. Set up step 4 to get the rest.
Receive delivery webhooks
Add an HTTP route in convex/http.ts:
import { httpRouter } from 'convex/server'
import { httpAction } from './_generated/server'
import { groundmail } from './emails'
const http = httpRouter()
http.route({
path: '/groundmail-webhook',
method: 'POST',
handler: httpAction((ctx, req) => groundmail.handleGroundmailEventWebhook(ctx, req)),
})
export default httpThen create a webhook endpoint pointing at your deployment's HTTP actions URL (it ends in .convex.site), in the dashboard under Webhooks or with the CLI:
groundmail webhooks create --url https://your-deployment.convex.site/groundmail-webhookCopy the signing secret (whsec_…, shown once) to your deployment:
npx convex env set GROUNDMAIL_WEBHOOK_SECRET whsec_your_secretThe handler checks the Groundmail-Signature header, rejects requests older than 5 minutes, and updates the email the event belongs to. Events for email your app sent another way are ignored. See Webhooks for every event and its fields.
React to events
Pass a mutation as onEmailEvent to run your own code on each event, for example to flag a user whose address hard-bounced:
import { Groundmail, vOnEmailEventArgs } from '@groundmail/convex'
import { components, internal } from './_generated/api'
import { internalMutation } from './_generated/server'
export const groundmail: Groundmail = new Groundmail(components.groundmail, {
onEmailEvent: internal.emails.handleEmailEvent,
})
export const handleEmailEvent = internalMutation({
args: vOnEmailEventArgs,
handler: async (ctx, { id, event }) => {
if (event.type === 'email.bounced' && event.data.bounce_type === 'hard') {
// Mark the address as undeliverable in your users table.
}
},
})event is the webhook payload: { type, created_at, data }. data.tags holds the tags you sent, so a user_id tag tells you who the email was for. When a send is rejected or runs out of retries, onEmailEvent also gets an email.failed event with data.bounce_reason set to the error.
Clean up old emails
Emails stay in your Convex database until you delete them. Delete finished ones (delivered, bounced, failed, suppressed, cancelled) on a schedule:
// convex/crons.ts
import { cronJobs } from 'convex/server'
import { internal } from './_generated/api'
import { internalMutation } from './_generated/server'
import { groundmail } from './emails'
export const cleanupEmails = internalMutation({
args: {},
handler: async (ctx) => {
await groundmail.cleanupOldEmails(ctx, 7 * 24 * 60 * 60 * 1000)
},
})
const crons = cronJobs()
crons.daily('clean up sent emails', { hourUTC: 4, minuteUTC: 0 }, internal.crons.cleanupEmails)
export default cronsOptions
| Option | Default | |
|---|---|---|
apiKey |
GROUNDMAIL_API_KEY |
Your gm_… API key |
webhookSecret |
GROUNDMAIL_WEBHOOK_SECRET |
Your webhook endpoint's signing secret |
retryAttempts |
5 |
Retries for temporary failures |
initialBackoffMs |
10000 |
Wait before the first retry; doubles each time |
onEmailEvent |
none | A mutation run on each delivery event |
Testing
Use a gm_test_… key, or send to simulator addresses such as bounce@simulator.groundmail.dev, to see the whole flow, webhooks included, without delivering anything.
To unit-test your own functions with convex-test, register the component:
import { convexTest } from 'convex-test'
import groundmailTest from '@groundmail/convex/test'
const t = convexTest(schema, modules)
groundmailTest.register(t)Notes
- An attachment without
contentTypegets one from its filename extension (.pdfisapplication/pdf, and so on), orapplication/octet-streamwhen the extension is not recognized. - Your API key is stored in the component's tables in your Convex deployment, once per distinct set of options rather than on every email, because sends run in the background where your environment variables are not available. It is never returned by
getorstatus. - Keep attachments small. The email is stored in your Convex database before it is sent, and a Convex document is limited to 1 MB.
- Up to 9 tags per email. The component adds one more to match webhook events to the email.