Newsletter renderer #
Markdown in β email-safe, styled HTML out. The output is meant to be copy-pasted into Resend's web editor. This tool does rendering only β it never calls the Resend API and never sends anything.
Built on React Email (Tailwind classes are inlined at render time) +
react-markdown. Brand values (name, logo, footer socials, copyright, asset host) live in one
place β src/config.ts; edit it to make the newsletter yours.
Requirements #
- Bun (runs the
.tsxdirectly, no build step)
bun install
Writing a newsletter #
Create a Markdown file with YAML frontmatter. The header (logo + " Newsletter N") and the footer (unsubscribe / copyright / social links) are baked into the template β you only write the issue metadata and the body.
---
issue: 3
title: Your headline goes here
author: eti
preview: Optional inbox preview line.
---
Intro paragraph...
## A section

More content, [links](https://example.com), `inline code`, lists, and fenced code blocks.
---
Sign-off paragraph.
β eti
Notes:
issueandtitleare required;authorandprevieware optional.- Image
srcmust be an absolute URL (email clients can't resolve relative paths). - The footer's unsubscribe link is the literal
{{{RESEND_UNSUBSCRIBE_URL}}}merge tag, which Resend substitutes at send time β leave it as-is.
Rendering #
bun run render content/example.md # -> out/example.html
bun run render content/example.md out/03.html # explicit output path
Then open the generated file, copy its full contents, and paste into Resend's HTML editor.
Hosting images (--upload-assets) #
React Email and Resend both leave image hosting to you β image srcs pass through the renderer
verbatim, so whatever URL is in your Markdown is what recipients load forever. Don't ship a
third-party CDN into a sent campaign; mirror those images onto your own asset host instead.
Your asset host (set in src/config.ts, e.g. assets.example.com) is backed by a Cloudflare R2
bucket. Pass --upload-assets to have the render step download every image whose host isn't your
asset host, upload it to that bucket under blog/newsletter/<issue>/<slug>-<hash>.<ext> (slug
derived from the alt text, hash from the source URL), and rewrite the URLs in the generated HTML:
bun run render content/example.md --upload-assets
- Images already on your asset host are left untouched.
- Uploads are idempotent β re-running skips objects that already exist. The key includes a hash of the source URL, so editing or reordering content never overwrites another image's object.
- The source
.mdis never modified β only the rendered HTML references the mirrored URLs. The from β to mapping is printed so you can paste the new URLs into your.mdby hand if you'd like future renders to reference them directly (and need no network).
Requires these in .env (uses Bun's built-in S3 client β no extra dependency):
R2_ACCOUNT_ID= # <account-id>.r2.cloudflarestorage.com
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET=
ASSETS_PUBLIC_BASE_URL= # optional; defaults to config.assetsBaseUrl in src/config.ts
Styling #
The element β Tailwind mapping lives in src/markdownComponents.tsx (the reusable core). Design
tokens are Tailwind defaults (text-gray-900, border-gray-200, etc.); the font stack is set in
src/theme.ts. Header/footer chrome is in src/Header.tsx / src/Footer.tsx.
prose is intentionally not used β it is unsupported by React Email's Tailwind component. Style via
the explicit element map instead.