Skip to content

Repository files navigation

EdgeLetter—the high-performance publishing engine

EdgeLetter is a single Cloudflare Worker (Astro SSR + D1 + R2) that runs the public blog, the admin editor, the media uploader, and the newsletter dispatcher. Each white-label tenant gets one Worker deployment + one D1 database, with all brand visuals configured live via an admin panel — no redeploy required.

Email goes out via one of two providers, selected per-tenant:

  • Resend — HTTP API, called directly from the Worker.
  • Gmail SMTP — TCP from the Worker to smtp.gmail.com:465 via cloudflare:sockets. No sidecar container.

See it in Action

Experience EdgeLetter live: edgeletter.rubrion.ai

Work with Us

Want this platform customized and deployed for your business? We offer end-to-end setup, white-label customization, and managed hosting so you can focus on building your brand. Let's get your publishing platform running today!


Architecture at a glance

                    ┌─RESEND─▶ api.resend.com (HTTPS)
[Reader]──HTTPS──┐  │
                 ├─▶[Astro/CF Worker]──┬──▶[D1] (per-tenant SQLite: posts, subscribers, campaigns, settings)
[Admin] ──HTTPS──┘  │                  └──▶[R2] (media uploads, optional shared bucket)
                    └─GMAIL──▶ smtp.gmail.com:465 (TCP+TLS via cloudflare:sockets)
  • All public pages, the admin UI, /api/*, and media uploads run inside one Worker.
  • D1 holds posts, subscribers, campaigns, settings (schema in src/db/schema.ts).
  • R2 holds uploaded images and videos, organized as edgeletter/<CLIENT_SLUG>/<yyyy-mm>/<uuid>.<ext>. One bucket can be shared across tenants — slug-prefixed paths keep them isolated.
  • Brand visuals (name, tagline, logo, favicon, theme color, email From-address) live in D1 and are editable from /admin/settings without redeploy.
  • Provider choice is a config flip (EMAIL_PROVIDER var); no code changes.

Built-in features

  • Markdown editor with live preview, drag-drop / paste / button image + video upload (R2-backed, 50 MB cap), per-post Publish + Send to active subscribers.
  • Newsletter dispatch with List-Unsubscribe + one-click POST headers (Gmail/Yahoo bulk-sender compliant), plain-text alternative, and per-subscriber unsubscribe links.
  • Subscriber unsubscribe/api/unsubscribe?id=<uuid> (GET for link clicks, POST for one-click). Sets subscribers.status = 'unsubscribed' so future dispatches skip them.
  • Post-delete media cleanup — when a post is deleted from the admin, any R2 objects it referenced under your own bucket prefix are removed too. Externally-pasted URLs are left alone.
  • Dark / light mode toggle in the header. Detects prefers-color-scheme on first visit, then persists user choice in localStorage.
  • i18nen and pt-BR translations for all public-facing UI. Detects browser Accept-Language on first visit, persists choice in a lang cookie. Adds ?lang toggle in the header.
  • Live brand admin at /admin/settings — change name, tagline, logo, favicon, accent color, email From-address without a deploy.
  • Embeddable blog widget at /embed/blog (and /embed/blog/<slug>) — themeable iframe with auto-resize, drop-in subscribe form, double opt-in, optional Cloudflare Turnstile, and a configurable origin allowlist. Configure defaults and copy the snippet from /admin/widget.
  • "Powered by Rubrion" watermark on every embedded widget by default (small pill with link to rubrion.ai). Toggle off in /admin/widget for paid white-label deployments.

Prerequisites

Tool Version Used for
Bun ≥ 1.3 Package manager + dev server
Cloudflare account Workers + D1 + R2
wrangler (vendored) 4.x Provisioning + deploy (bunx wrangler ...)
Resend account Only if EMAIL_PROVIDER=RESEND
Gmail account + App Password Only if EMAIL_PROVIDER=GMAIL

Per-tenant deployment

Run all commands from the repo root. Replace <tenant> with the tenant's slug (lowercase, hyphen-separated).

1. Install

bun install

2. Create the D1 database

bunx wrangler d1 create <tenant>-edgeletter

Copy the returned database_id into wrangler.jsonc:

"d1_databases": [
  {
    "binding": "DB",
    "database_name": "<tenant>-edgeletter",
    "database_id": "<paste-here>",
    "migrations_dir": "./drizzle"
  }
]

⚠ The binding name must be DB for every tenant — that's the contract the code reads. Only database_name and database_id change per tenant.

3. Create (or pick) an R2 bucket for media

# If a media bucket doesn't already exist:
bunx wrangler r2 bucket create <your-bucket>

# Optional but recommended: attach a public custom domain (e.g. media.example.com)
# via the Cloudflare dashboard → R2 → Bucket → Settings → Connect Domain.

One bucket can be shared across all whitelabel tenants — uploads land under edgeletter/<CLIENT_SLUG>/... so each tenant has its own folder.

4. Set tenant vars in wrangler.jsonc

Only infrastructure and build-time vars live here. Brand visuals are managed in the admin UI after first deploy.

"routes": [
  { "pattern": "<tenant>.example.com", "custom_domain": true }
],
"vars": {
  "CLIENT_DOMAIN": "<tenant>.example.com",
  "CLIENT_SLUG": "<tenant>",
  "CLIENT_FONT": "Inter",
  "MEDIA_PUBLIC_BASE": "https://media.example.com",
  "EMAIL_PROVIDER": "RESEND"
},
"r2_buckets": [
  { "binding": "MEDIA", "bucket_name": "<your-bucket>" }
]

5. Apply database migrations

# Local (creates .wrangler/state/v3/d1/...)
bunx wrangler d1 migrations apply <tenant>-edgeletter --local

# Remote (production)
bunx wrangler d1 migrations apply <tenant>-edgeletter --remote

This creates posts, subscribers, campaigns, and settings tables.

To regenerate the SQL after a schema change: bun run db:generate.

6. Set secrets

Secrets are stored encrypted on Cloudflare and never appear in source. Use wrangler secret put:

bunx wrangler secret put MASTER_ADMIN_KEY        # always

# If EMAIL_PROVIDER=RESEND
bunx wrangler secret put RESEND_API_KEY

# If EMAIL_PROVIDER=GMAIL
bunx wrangler secret put GMAIL_USER
bunx wrangler secret put GMAIL_APP_PASSWORD

7. Regenerate types and deploy

bun run cf-typegen        # refresh worker-configuration.d.ts
bun run deploy            # astro build + wrangler deploy

8. Configure brand visuals via admin

Open https://<tenant>.example.com/admin/login, paste your MASTER_ADMIN_KEY, then navigate to /admin/settings and fill in:

  • Brand name
  • Tagline
  • Logo URL (or leave empty for text)
  • Favicon URL
  • Theme primary color (hex)
  • Email From-address (Resend only — domain must be verified in Resend)

Saved values take effect immediately on the next page render. Empty a field to revert to its wrangler.jsonc seed (or the hard-coded default if no seed is set).

That's it. No second container to host.


Configuration reference

EdgeLetter splits configuration into three layers, each with a different lifecycle:

Admin-managed (D1 settings table) — change anytime, no redeploy

Setting Purpose Wrangler seed key
clientName Brand name shown across UI, OG metadata, RSS, emails CLIENT_NAME
clientTagline Homepage subhead CLIENT_TAGLINE
clientLogoUrl Header logo (when set, replaces the brand text) CLIENT_LOGO_URL
clientFaviconUrl Custom favicon CLIENT_FAVICON_URL
themePrimaryColor Accent color, injected as --theme-primary THEME_PRIMARY_COLOR
emailFromAddress Resend From address (e.g. news@brand.com). Domain must be verified in Resend. Ignored when EMAIL_PROVIDER=GMAIL. EMAIL_FROM_ADDRESS
widgetDefaultTheme Default theme for /embed/blog (auto / light / dark)
widgetDefaultLimit Default number of posts in the widget (1–50)
widgetShowDate / widgetShowExcerpts Toggle date and excerpt rendering in the widget
widgetMaxWidth Optional CSS max-width for widget content (e.g. 720px)
widgetAccentOverride Hex color overriding themePrimaryColor inside the widget only
widgetAllowedOrigins Comma-separated origins (or *) allowed to call /api/subscribe and /api/unsubscribe cross-origin
widgetHideWatermark 1 removes the "Powered by Rubrion" pill from the widget. Default 0 (visible). Reserved for white-label customers — server-side only, not overridable via query string.

Resolution at request time: DB row → wrangler.jsonc seed → hard-coded default. Saving an empty value in admin removes the override and falls back to the seed.

Wrangler vars — change requires redeploy

Var Required when Purpose
CLIENT_DOMAIN always Canonical URLs, sitemap, default Resend from (noreply@$CLIENT_DOMAIN), Astro site URL
CLIENT_SLUG always (R2 uploads) Folder name under edgeletter/ in the media bucket. Keeps tenant uploads isolated
CLIENT_FONT optional Google Font family name (e.g. Inter, Playfair Display). Read at build time
MEDIA_PUBLIC_BASE always (R2 uploads) Public base URL of the R2 bucket (e.g. https://media.example.com). Used to build asset URLs after upload
EMAIL_PROVIDER always RESEND or GMAIL

CLIENT_FONT is read at build time by astro.config.mjs (it configures Astro's font integration), so changing it requires a redeploy. It accepts either a bare family name ("Inter") or a full CSS stack ("'Inter', system-ui, sans-serif") — only the first family is used for Google Fonts loading; Astro auto-injects metric-matched fallbacks.

Wrangler bindings — provisioned, named the same across tenants

Binding Type Notes
DB D1 Per-tenant database. Binding name must always be "DB"
MEDIA R2 Bucket for image / video uploads. Bucket name can be shared; isolation is via CLIENT_SLUG prefix
ASSETS Static assets Astro's dist/ output

Secrets — wrangler secret put

Secret Required when Purpose
MASTER_ADMIN_KEY always Login key for /admin/login. Stored in an HttpOnly cookie after login
RESEND_API_KEY EMAIL_PROVIDER=RESEND Resend API key (re_...)
GMAIL_USER EMAIL_PROVIDER=GMAIL Gmail address. Used as both SMTP login and the From: address (Gmail rejects mismatched senders)
GMAIL_APP_PASSWORD EMAIL_PROVIDER=GMAIL Gmail App Password — not the account password

Local development (.dev.vars)

.dev.vars is the Wrangler equivalent of .env — only loaded when wrangler dev runs. Do not commit it. Mirror the production secrets:

MASTER_ADMIN_KEY=local-dev-admin-key
RESEND_API_KEY=re_dev_xxx
GMAIL_USER=your-gmail@gmail.com
GMAIL_APP_PASSWORD=xxxxxxxxxxxxxxxx

Local development

bunx wrangler d1 migrations apply <tenant>-edgeletter --local   # one-time
bun run dev                                                    # http://localhost:4321

bun run dev runs astro dev, which has a Vite server but no Cloudflare bindings. For features that depend on cloudflare:workers env (D1, R2, env vars), use bunx wrangler dev instead.

Smoke test:

curl -X POST http://localhost:4321/api/subscribe \
  -H 'Content-Type: application/json' \
  -d '{"email":"me@test.com"}'
# → {"ok":true}

bunx wrangler d1 execute <tenant>-edgeletter --local \
  --command "SELECT email, status FROM subscribers"

Then open /admin/login, paste your MASTER_ADMIN_KEY, configure brand visuals at /admin/settings, write a markdown post (drag-drop images!), and hit Publish + Send.

⚠ The Gmail SMTP path uses cloudflare:sockets, which only runs on the Cloudflare runtime (production or wrangler dev). It cannot run in plain Node/Bun.


Switching email providers

Migration is a config-only change — no code edits, no infra moves.

From → To Steps
Resend → Gmail 1. wrangler secret put GMAIL_USER and GMAIL_APP_PASSWORD. 2. Update EMAIL_PROVIDER=GMAIL in wrangler.jsonc. 3. bun run deploy.
Gmail → Resend 1. wrangler secret put RESEND_API_KEY. 2. Update EMAIL_PROVIDER=RESEND. 3. bun run deploy. (Also set the From address in /admin/settings if you want something other than noreply@$CLIENT_DOMAIN.)

The from address differs between providers:

  • Resend uses emailFromAddress from /admin/settings if set, otherwise falls back to noreply@$CLIENT_DOMAIN. The domain must be verified in Resend either way.
  • Gmail uses $GMAIL_USER directly (Gmail rejects mismatched senders, so you can't override the address — only the display name).

Email deliverability (avoiding spam)

For Resend, three DNS records on $CLIENT_DOMAIN are required for emails to land in inboxes rather than spam folders:

Record What it does
DKIM (CNAMEs Resend provides) Cryptographic proof the email wasn't tampered with
SPF (TXT) Authorises Resend's servers to send on your behalf
DMARC (TXT on _dmarc.$CLIENT_DOMAIN) Policy: e.g. v=DMARC1; p=none; rua=mailto:postmaster@$CLIENT_DOMAIN

Add them in your Resend dashboard → Domains → Add domain → follow instructions. Without DKIM/SPF, Gmail / Outlook will reject or spam-fold messages even if everything else is configured correctly.

EdgeLetter already sends the headers Gmail's bulk-sender policy requires (List-Unsubscribe, List-Unsubscribe-Post: List-Unsubscribe=One-Click, plain-text alternative).


Operational notes

  • Free-tier Worker CPU is 10ms per invocation. Each Gmail send opens a TCP+TLS handshake to smtp.gmail.com:465, then reuses one session for all recipients. Realistic ceiling on the free tier is roughly a few hundred recipients per publish before you hit CPU caps. The paid Workers tier ($5/mo) lifts the cap to 30s CPU.
  • Gmail send quota — Gmail SMTP caps at ~500 messages/day per account. Tenants with growing lists must move to Resend before hitting that wall.
  • R2 free tier — 10 GB storage + 1M Class A + 10M Class B operations/month. Egress is free, always. Realistically covers any newsletter blog without paid usage.
  • Media upload limit — 50 MB per file (jpg, png, webp, gif, svg, mp4, webm). Configurable in src/pages/api/media/upload.ts.
  • Orphan media — uploaded files that never end up referenced in a post stay in R2. Storage cost is trivial, deletion risk is real (broken images in already-sent emails), so we don't background-sweep. Posts deleted from the admin UI do get their referenced media cleaned up.
  • HTML sanitization — Admin-authored markdown is rendered through marked without sanitization. Acceptable while the admin is the only author; before opening writes to a wider audience, add isomorphic-dompurify to the publish path in src/pages/api/publish.ts.
  • Per-recipient sends — Each subscriber gets their own envelope (MAIL FROM / RCPT TO) so they only see their own address in To:. For very large lists, batching via Resend's /emails/batch endpoint is the next optimization.

Useful commands

Command What it does
bun run dev Astro dev server (no CF bindings)
bunx wrangler dev Wrangler dev server (full CF bindings: D1, R2, env vars)
bun run build Server build to dist/
bun run deploy Build + wrangler deploy
bun run cf-typegen Regenerate worker-configuration.d.ts after editing wrangler.jsonc
bun run db:generate Generate a new SQL migration in drizzle/ after editing src/db/schema.ts
bunx wrangler d1 migrations apply <db> --local Apply pending migrations to local D1
bunx wrangler d1 migrations apply <db> --remote Apply pending migrations to production D1
bunx wrangler tail Stream production logs
bunx wrangler d1 execute <db> --remote --command "..." Run SQL against production D1

License

This project is licensed under the GPLv3 License.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages