@growth-labs/mailer
v0.8.2
Published
Queue-based email sending engine for Astro + Cloudflare. Newsletter subscriptions with double opt-in, campaign sending, open/click tracking, unsubscribe management. Uses Cloudflare Queues for async delivery.
Readme
@growth-labs/mailer
Queue-based email sending engine for Astro + Cloudflare. Newsletter subscriptions with double opt-in, campaign sending, open/click tracking, unsubscribe management. Uses Cloudflare Queues for async delivery.
"The engine, not the driver." This package sends emails. What to send and to whom is the consumer's concern (e.g. @fulcrum/dispatch layers editorial intelligence on top).
If you only need immediate transactional delivery from a Worker with no subscriber state or queueing, use @growth-labs/email instead.
Astro 6 note: injected routes read D1 and Queue bindings from cloudflare:workers env; no locals.runtime shim is required.
When to use this vs @growth-labs/email
Use @growth-labs/mailer (this package) for newsletter and campaign
workflows. Use @growth-labs/email for transactional one-off sends.
See the @growth-labs/email README for the full distinction.
Both integrations register the package's existing virtual config types through
Astro's astro:config:done type injection. No consumer-owned declaration is
needed for virtual:growth-labs/mailer/config; runtime route selection is unchanged.
Config
import mailer from '@growth-labs/mailer'
mailer({
senderName: 'FEDweek',
fromAddress: '[email protected]',
replyTo: '[email protected]',
d1Binding: 'SITE_DB',
queueBinding: 'EMAIL_QUEUE',
turnstileSiteKey: '...',
turnstileSecretBinding: 'TURNSTILE_SECRET_KEY', // Optional; this is the default
doubleOptIn: true,
topics: ['daily-digest', 'breaking-news'], // Optional topic-based subscriptions
signingSecretBinding: 'MAILER_SIGNING_SECRET', // Optional; this is the default
siteUrl: 'https://fedweek.com',
brand: {
logoUrl: 'https://media.fedweek.com/logos/email.png',
primaryColor: '#1a365d',
accentColor: '#e53e3e',
footerText: '© FEDweek. All rights reserved.',
},
analyticsEnabled: true, // Emit email events to @growth-labs/analytics
analyticsBinding: 'ANALYTICS', // WAE binding used when analytics is enabled
webhookSignature: {
enabled: true,
secret: import.meta.env.MAILER_WEBHOOK_SECRET,
},
})This integration registers the newsletter routes and components listed below.
For a new subscriber, the subscribe route records the subscriber and sends a
confirmation email when doubleOptIn is enabled, or a welcome email when it is not.
Components without delivery routes
Use mailerComponents instead of mailer when rendering SubscribeForm with
an endpoint owned by the consumer, or in a private preview with delivery disabled:
import { mailerComponents } from '@growth-labs/mailer'
mailerComponents({
turnstileSiteKey: 'public-site-key',
subscribePath: '/api/newsletter/subscribe', // Optional; this is the default
topics: ['daily-digest'], // Optional
})This installs only the existing form configuration Vite plugin. It injects no
routes or tracking middleware and requires no D1, Queue, sender or secret binding.
Only these three public options are accepted; secret and delivery options fail
validation. The form still submits to subscribePath: this integration does not
implement subscription delivery or disable the form. The consumer must provide a
Turnstile-protected endpoint, or keep delivery unavailable in its private preview.
Choose one mailer integration per site. The default mailer integration retains
its full route, middleware and runtime security behavior.
Production patterns
from vs replyTo. A common pattern is fromAddress: '[email protected]' paired with replyTo: '[email protected]'. The from lives on a domain you've verified with Cloudflare Email Sending (typically a mail. subdomain); replyTo points at a real human inbox readers can write back to. replyTo is also a per-message override on sendTransactional and a per-site field on SiteMailerConfig (realm pattern, since 0.4.0).
Don't bake fallbacks for fromAddress. The schema requires a valid email (z.string().email()) and ships no default — that's intentional. A consumer-side fallback like process.env.MAILER_FROM_ADDRESS ?? '[email protected]' is a foot-gun: if the env var is missing at deploy time the fallback domain probably isn't verified with Cloudflare Email Sending, every send returns unauthorized_sender, and the queue retries until the budget kills the message. Fail loudly at build/deploy when the env var is missing instead — the schema can't catch your fallback.
Secrets resolve at request time. Bind TURNSTILE_SECRET_KEY and
MAILER_SIGNING_SECRET on the Worker. Plain Worker secrets and Secrets Store
bindings are both supported; Secrets Store bindings are read with async .get()
and cached per isolate. Missing or empty production bindings return a 503 before
subscriber writes. Build-time literal options are ignored outside localhost.
What It Injects
Routes:
POST /api/newsletter/subscribe— Turnstile-protected subscriptionGET /api/newsletter/confirm?token=...— Double opt-in confirmationGET /api/newsletter/unsubscribe?token=...— One-click unsubscribeGET/POST /email/preferences?token=...— Preference center pagePOST /api/email/webhook— ESP webhook receiver (delivery, bounce, complaint), optional HMAC signature verificationGET /api/email/open/:trackingId— Open tracking pixelGET /api/email/click/:trackingId— Click tracking redirect
Components:
<SubscribeForm />— Newsletter signup form with Turnstile<PreferenceCenter />— Topic subscription management
D1 Tables (prefixed gl_)
gl_subscribers— subscriber records (email, name, status, topics, confirmed_at)gl_email_sends— send log (status: queued → sent → delivered → opened → clicked)
Wrangler Bindings
[[d1_databases]]
binding = "SITE_DB"
database_id = "..."
[[queues.producers]]
binding = "EMAIL_QUEUE"
queue = "email-sends"
[[queues.consumers]]
queue = "email-sends"
max_batch_size = 10
[[analytics_engine_datasets]]
binding = "ANALYTICS"
dataset = "your_analytics_dataset"
[[secrets_store_secrets]]
binding = "TURNSTILE_SECRET_KEY"
store_id = "..."
secret_name = "your-turnstile-secret"
[[secrets_store_secrets]]
binding = "MAILER_SIGNING_SECRET"
store_id = "..."
secret_name = "your-mailer-signing-secret"Email Sending Flow
- Consumer calls
sendTransactional(),sendCampaign(), orsendDigest()→ message enqueued - Queue consumer dequeues → sends via Cloudflare Email Sending with retry on retryable failures
- Status updated in
gl_email_sends: queued → sent → delivered/bounced - Open/click tracking updates status further
Key Patterns
- Virtual module:
virtual:growth-labs/mailer/config - Runtime bindings:
import { env } from 'cloudflare:workers' - Status never downgrades (sent → delivered → opened → clicked)
- List-Unsubscribe header on every email (RFC 8058)
- Turnstile on subscribe endpoint (+ IP rate limiting as backup)
- Provider failover logged for observability
- WAE events via the configured Analytics Engine binding — fire-and-forget, non-blocking, and compatible with bindings whose
writeDataPoint()returnsvoid - Cloudflare Email Sending uses the current structured
htmlpayload and forwardsreplyToplus custom headers to the binding .astrocomponent files ship as source, not compiled
Analytics Events
When analyticsEnabled is true and the configured analyticsBinding
exists, the package writes WAE data points for
newsletter_subscribed, newsletter_confirmed,
newsletter_unsubscribed, newsletter_opened,
newsletter_clicked, newsletter_delivered,
newsletter_bounced, newsletter_complained,
newsletter_sent, and newsletter_send_failed.
