md -> react-email -> html
TypeScript 100%

README.md

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 .tsx directly, 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

![alt text](https://absolute-url/to/image.png)

More content, [links](https://example.com), `inline code`, lists, and fenced code blocks.

---

Sign-off paragraph.

β€” eti

Notes:

  • issue and title are required; author and preview are optional.
  • Image src must 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 .md is 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 .md by 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.