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
| Layer | Provider | Purpose |
|---|---|---|
| App | Vercel + TanStack Start | Organization UI and docs |
| Backend | Convex | Auth, data, crons, webhook staging, sequence ticks |
| Application senders | Resend, SES, Stalwart SMTP | Per-organization sender identity and credentials |
| Default sender | Resend | Platform fallback delivery path |
| High-volume sender | Amazon SES v2 | Low-cost campaign and sequence sends |
| Send pacing | Convex scheduler | Campaign sends run in paced batches, resumable and idempotent |
| HTML archive | Cloudflare R2 | Optional 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>