Guide/Email Marketing Infrastructure

Email Marketing Infrastructure

Configure SES, Stalwart, R2, and provider webhooks for Growth email marketing.

Growth keeps email marketing state in Convex and adds external infrastructure as optional adapters. Each organization can add application-level senders from Email marketing > Settings. A sender stores the application name, sender address, provider, and credentials for Resend, Amazon SES, or Stalwart SMTP.

If no application sender exists, Growth falls back to the platform-level EMAIL_MARKETING_PROVIDER setting.

Architecture

LayerProviderPurpose
AppVercel + TanStack StartOrganization UI and docs
BackendConvexAuth, data, crons, webhook staging, sequence ticks
Application sendersResend, SES, Stalwart SMTPPer-organization sender identity and credentials
Default senderResendPlatform fallback delivery path
High-volume senderAmazon SES v2Low-cost campaign and sequence sends
Send pacingConvex schedulerCampaign sends run in paced batches, resumable and idempotent
HTML archiveCloudflare R2Optional rendered-email archive with 30-day retention metadata

Convex Env Vars

Set these in Convex, not Vercel:

EMAIL_CREDENTIALS_SECRET="generate-a-long-random-secret"
EMAIL_MARKETING_PROVIDER="resend" # or "ses"

EMAIL_SES_REGION="eu-west-1"
EMAIL_SES_CONFIGURATION_SET=""
SES_SNS_TOPIC_ARN=""

EMAIL_HTML_ARCHIVE_ENABLED="false"
EMAIL_HTML_ARCHIVE_DAYS="30"

# Shared secret checked on the Stalwart delivery webhook (see Stalwart SMTP).
STALWART_WEBHOOK_SECRET=""

R2 archive uses the existing file backend env vars:

R2_S3_URL=""
R2_S3_ACCESS_KEY_ID=""
R2_S3_SECRET_ACCESS_KEY=""
R2_S3_BUCKET_NAME=""
R2_URL=""

Application Senders

Open Email marketing > Settings and add one sender per application. The form stores:

  • Application name.
  • From name and From email.
  • Optional Reply-to and domain.
  • Provider credentials.
  • Default sender flag.

Campaigns can select a specific application sender. Sequences use the default application sender. When a sender is selected, Growth sends through that sender's stored credentials instead of the global fallback.

Set EMAIL_CREDENTIALS_SECRET before storing production credentials. The value is used to encrypt provider credentials in Convex. If it is missing, local development still works, but production should always set it.

Resend

For application-level Resend sending, paste the application Resend API key in the Settings form. The sender domain must be verified in Resend, and the From email must belong to that verified domain.

Growth sends through the Resend Node SDK and records the Resend message ID for webhook reconciliation.

Amazon SES

SES sending uses the AWS SDK for JavaScript v3 and SendEmailCommand. For application-level sending, enter the SES region, access key ID, secret access key, and optional configuration set in Settings.

For platform fallback sending, AWS credentials are resolved by the SDK credential chain. In Convex, set the normal AWS env vars alongside EMAIL_SES_REGION:

AWS_ACCESS_KEY_ID=""
AWS_SECRET_ACCESS_KEY=""
EMAIL_SES_REGION="eu-west-1"
EMAIL_MARKETING_PROVIDER="ses"

If you use an SES configuration set for delivery/open/click events, set:

EMAIL_SES_CONFIGURATION_SET="growth-marketing"
SES_SNS_TOPIC_ARN="arn:aws:sns:eu-west-1:123456789012:growth-email-events"

Subscribe the Convex site endpoint to the SNS topic:

POST https://<convex-site-domain>/email/webhooks/ses?secret=<SES_WEBHOOK_SECRET>

The webhook handles SNS subscription confirmation, validates SES_SNS_TOPIC_ARN when configured, stages the event in Convex, and returns quickly. Campaign counters and suppressions are updated by the Convex event drain cron.

Platform SMTP Sender

Everything the organizations have not claimed - the system stream (sign-in links, contact form, admin alerts) and the fallback for an organization with no sending application - goes through the platform's own SMTP server when one is configured:

PLATFORM_SMTP_HOST="mail.example.com"
PLATFORM_SMTP_USERNAME="[email protected]"
PLATFORM_SMTP_PASSWORD=""
# Optional, shown with their defaults:
PLATFORM_SMTP_PORT="465"
PLATFORM_SMTP_SECURE="true"
PLATFORM_SMTP_REJECT_UNAUTHORIZED="true"

All three of host, username and password must be present; a half-configured transport is ignored rather than failing at send time. With PLATFORM_SMTP_SECURE left on, submission uses implicit TLS on 465; turning it off switches to 587 and makes STARTTLS mandatory, because cleartext submission is never acceptable.

Leave PLATFORM_SMTP_REJECT_UNAUTHORIZED alone. Turning certificate verification off would silently accept a man-in-the-middle on the submission credentials. If the server's certificate expires, sends fail loudly - that is the intended behavior, and the fix is to renew the certificate, not to disable the check.

When no platform SMTP is configured, the historical @convex-dev/resend component path applies and needs RESEND_API_KEY.

Delivery events come back through the shared Stalwart webhook below, correlated by the SMTP queue id the server returns at submission time.

Per-Connection SES Endpoint

The endpoint above is for the platform's SES account. An organization running its own AWS account has its own SNS topic and uses a per-connection endpoint instead, with a secret stored on that connection rather than a shared env var:

POST https://<convex-site-domain>/email/webhooks/ses/<connectionId>?secret=<per-connection secret>

Events staged through it are scoped to that organization, so two customer AWS accounts can never attribute an event to each other's send. Both endpoints verify the SNS message signature and only fetch the signing certificate from sns.<region>.amazonaws.com.

Event types are mapped explicitly rather than by substring: DeliveryDelay is ignored (the message is still in flight), and only a Permanent bounce counts as a hard bounce — a Transient one is recorded as a failure and suppresses nothing.

See Sending From Your Own Domain for the customer-facing setup.

Stalwart SMTP

Stalwart is supported through authenticated SMTP. In Settings, enter the SMTP host, port, TLS mode, username, and password for the application mailbox.

Recommended production options:

OptionValue
Port465
Implicit TLSenabled
Verify TLSenabled

Port 587 with STARTTLS is also supported by disabling implicit TLS and enabling required STARTTLS.

The SMTP transport runs with URL and file access disabled (SSRF hardening), so attachments are downloaded from R2 and embedded as buffers at send time, and threading headers (In-Reply-To, References) are carried through. Open tracking uses the self-hosted pixel and works regardless of provider.

Delivery and bounce tracking

Unlike Resend or SES, a self-hosted Stalwart server needs a webhook to report Delivered and Bounced. Without it you still see Sent and, thanks to the pixel, Opened — but never Delivered.

Growth records the queue id from Stalwart's SMTP accept line (250 ... queued with id <hex>) as the send's provider message id, and the webhook correlates delivery events back to it by queue id — no X-Growth-Send-Id round-trip is required.

Configure a webhook on the Stalwart server (v0.16+ manages webhooks as a JMAP WebHook object; older builds use the equivalent config) pointing at:

POST https://<convex-site-domain>/email/webhooks/stalwart

Requirements:

The handler accepts Stalwart's native batch payload ({"events":[{ "type", "data": { "queueId", "to" } }]}), quoting the numeric queueId/spanId values (which exceed 2^53) before parsing so they stay exact. Events are deduplicated per (type, queueId, recipient), staged in Convex, then applied to emailSends by the event drain cron.

Send Pacing

Campaign sends run as paced batches on the Convex scheduler: each batch processes one page of the audience, records every accepted recipient in emailSends, then schedules the next batch after a short delay. Because eligibility rechecks emailSends per campaign and contact, re-running a batch never double-sends, and throughput stays under SES's default 50 emails per second without any external rate limiter.

R2 HTML Archive

Set EMAIL_HTML_ARCHIVE_ENABLED=true to store rendered HTML before sending. Objects are written under:

email-html/<organizationId>/<yyyy-mm-dd>/<sendId>.html

EMAIL_HTML_ARCHIVE_DAYS controls the retention metadata, defaulting to 30 days. Configure bucket lifecycle rules in Cloudflare R2 to physically delete objects after the same window.

Webhook Endpoints

EndpointPurpose
/resend-webhookPlatform Resend provider events
/email/webhooks/sesAWS SNS events from SES
/email/webhooks/stalwartStalwart native delivery/bounce events (header secret)
/email/webhooks/resend/:connectionIdAn organization's own Resend account: delivery events and inbound email.received
/email/webhooks/brevo/:connectionId?secret=An organization's own Brevo account: delivery events and inbound parsing
/-/t/o/:trackingIdSelf-hosted open-tracking pixel
/-/t/c/:trackingIdSelf-hosted click-tracking redirect (signed)
/-/unsub/:tokenPublic one-click unsubscribe
/-/resub/:tokenPublic resubscribe

All provider webhooks stage events in Convex first. Expensive joins, counter updates, suppressions, and sequence progression happen in scheduled Convex workers. Open and click tracking are self-hosted, so they work with every provider including Stalwart. For inbound support email, see the Support Inbox guide.

Email Marketing IntegrationSending From Your Own Domain