@growth-labs/mailer
v0.4.7
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.
Config
import mailer from '@growth-labs/mailer'
mailer({
senderName: 'FEDweek',
fromAddress: '[email protected]',
replyTo: '[email protected]',
d1Binding: 'SITE_DB',
queueBinding: 'EMAIL_QUEUE',
turnstileSiteKey: '...',
turnstileSecretKey: '...', // Via Cloudflare Secrets
doubleOptIn: true,
topics: ['daily-digest', 'breaking-news'], // Optional topic-based subscriptions
signingSecret: '...', // HMAC for unsubscribe tokens
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,
},
})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.
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"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 .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.
