A very small publishing pipeline: an email becomes an HTML page.
email -> parser -> normalized post -> renderer -> HTML -> GitHub -> GitHub Pages
Inspired by old-school plain-text email archives like yarchive.net:
metadata, a horizontal rule, and the message body in a <pre> block. Nothing else.
- The email is the document. There's no separate authoring step.
- GitHub is storage and version history. There's no database.
- GitHub Pages is the web server. There's no backend.
- HTML is the output format. There's no Markdown, no templating engine, no frontend framework, no JavaScript on the published pages.
- Parsing is minimal: extract headers and the plain-text body, escape it, wrap it. That's the whole job.
This is not a blogging platform, a CMS, or a general-purpose archive. It is intentionally boring.
input: an .eml file
1. parser.js - parses raw MIME with postal-mime, extracts headers + plain-text body
tags.js - pulls "#word" tags out of the subject (see "Pages, tags, and the archive")
2. (normalized post object: from, to, subject, tags, date, messageId, inReplyTo, references, body)
3. filename.js - builds a deterministic "YYYY-MM-DD-slug.html" name
4. renderer.js - HTML-escapes every field and fills in templates/post.html
5a. cli.js - writes the result to output/posts/ and regenerates output/archive.html
5b. cli-github.js - commits the result to posts/ in a GitHub repo (create-only), then
updates posts.json and archive.html there too
Each stage is a plain function with no knowledge of the others:
parser.jsdoesn't know HTML exists.renderer.jsdoesn't know where files get written or what GitHub is.github.jsdoesn't know what MIME is.
That separation is what lets the same core (parser -> renderer -> filename -> github)
run unchanged from a future Cloudflare Worker instead of the CLI.
npm installnode src/cli.js ./test/fixtures/sample.eml
# or
npm run publish -- ./test/fixtures/sample.emlOutput appears in output/:
output/
posts/2026-08-30-why-i-still-use-unix.html - the rendered post
posts.json - manifest: [{ filename, subject, date, tags }, ...]
archive.html - regenerated from posts.json on every publish
Re-running against the same email never overwrites an existing post file - it
appends -2, -3, etc. to the filename instead. posts.json and archive.html
are always regenerated in full from the current manifest, so they never go stale.
index.html (Home) and about.html are separate, static, hand-edited pages - see
"Pages, tags, and the archive" below.
src/cli-github.js renders an .eml the same way the local command does, then
commits the HTML straight to a GitHub repo via the Contents
API, so GitHub Pages can serve it.
Configure it with environment variables:
GITHUB_TOKEN= # required - a token with Contents: write on the target repo
GITHUB_OWNER= # required - e.g. "octocat"
GITHUB_REPO= # required - e.g. "archive"
GITHUB_BRANCH=main # optional - defaults to "main"
Copy .env.example to .env and fill it in, or export the variables directly.
There's no dotenv dependency here - if you're on Node 20+ you can load a .env file
with the built-in flag instead:
node --env-file=.env src/cli-github.js ./test/fixtures/sample.emlOtherwise, export the variables inline:
GITHUB_TOKEN=ghp_xxx GITHUB_OWNER=octocat GITHUB_REPO=archive \
npm run publish:github -- ./test/fixtures/sample.emlThis commits to posts/<date>-<slug>.html in the target repo, with a commit message
like Publish: Why I still use Unix, then updates posts.json and archive.html at
the repo root to include it (see "Pages, tags, and the archive" below).
Publishing is create-only. Before writing, it checks whether the target path
already exists on the branch; if it does, it refuses to overwrite it and exits
non-zero instead. This is different from the local CLI's -2/-3 filename
collision behavior - a remote publication should be deterministic, not
auto-renamed. Re-running the same command against an already-published email fails
loudly rather than silently duplicating or clobbering it.
Use a fine-grained personal access
token restricted to the one
target repository, with only the "Contents" permission set to Read and write. Never
commit a token to the repo; .env is gitignored.
GitHub publishing is intentionally its own adapter (src/cli-github.js +
src/github.js), separate from Cloudflare Email Routing. That phase isn't built
yet - see "Future: Cloudflare architecture" below.
The site has three top-level pages, linked from the nav on every page
(Home | Index | About):
index.html("Home") - a short static page explaining the project. Plain HTML, hand-edited directly likestyle.css; nothing generates it.about.html("About") - a static page about whoever's publishing. Also hand-edited directly - fill in your own bio there.archive.html("Index") - the one page that's auto-generated: every post, grouped by tag. Rebuilt in full on every publish, never hand-edited.
Put #word anywhere in an email's subject to tag it, e.g.:
Subject: Why I still use Unix #unix #philosophy
src/tags.js pulls those out (lowercased, deduped) and strips them from the
subject everywhere else - the post's title, its own page, and the filename slug
all show the clean "Why I still use Unix", not the raw subject with hashtags in
it. The tags themselves are shown on the post's own page (a Tags: meta line)
and drive archive.html.
archive.html groups every post by tag, alphabetically, newest post first
within each tag (src/archive.js); an untagged post lands in an "Uncategorized"
section at the end, rather than being dropped. It's built from posts.json, a
flat array of { filename, subject, date, tags } per post (src/manifest.js).
That file is plain data, not a database: no queries, no server, just something
archive.html gets rebuilt from. posts/ itself holds only the emailed posts'
own HTML - the manifest and archive live at the repo root, next to index.html,
about.html, and style.css.
The body is still plain text preserved in a <pre> block - not Markdown, not
HTML - but three narrow, predictable substitutions are applied (src/inlineFormat.js):
**bold** or __bold__ -> <strong>
*italic* or _italic_ -> <em>
<https://example.com> -> a link (bracket-wrapped, brackets dropped)
bare https://example.com -> a link
That's it - no lists, headers, or blockquotes get converted. Single and double
delimiters are deliberately different (matching standard Markdown, not any one
mail client's plain-text-export convention), so both can be used unambiguously in
the same email. Links are resolved before emphasis, so a _ or & that's
legitimately part of a URL can never be misread as emphasis syntax or corrupt an
href. Nesting (bold containing italic, etc.) isn't supported.
npm testUses Node's built-in test runner (node --test) - no extra test framework
dependency. src/github.js is tested by injecting a fake fetchImpl (see
test/github.test.js), so the suite never talks to a real GitHub repo.
All email-derived content is HTML-escaped before it reaches the page: subject, from,
to, date, Message-ID, In-Reply-To, References, and the body. The email's HTML MIME
part is never read into the output - only the plain-text part is used, and it is
rendered as inert text inside a <pre> block, never as markup. There is no
JavaScript on published pages, so there is nothing on the page that can execute
attacker-controlled content.
Filenames are derived only from a whitelisted [a-z0-9-] character set (see
src/filename.js), so a hostile subject line cannot produce a path-traversal or
unexpected file location.
GitHub credentials are read from environment variables only (GITHUB_TOKEN, see
src/config.js) and are never hard-coded or logged. Use a fine-grained token scoped
to only the target repo's Contents: write permission. src/github.js redacts the
token from any error message it constructs, even if a mocked or malformed API
response were to echo it back.
No database, no CMS, no admin dashboard, no user accounts, no frontend framework,
no Markdown parsing, no rich-text/WYSIWYG editing, no comments, no search, no
analytics, no attachment publishing, no HTML email rendering, and no thread/reply
UI (yet - see below). Two exceptions: src/allowlist.js on the email-triggered
path (a single-address, fail-closed sender check, not a general authentication
system), and tags/the archive page (a subject-line convention plus a flat JSON
manifest, not a database or CMS - see "Pages, tags, and the archive").
If a fixture email has attachments, they are silently ignored; only the plain-text body is published.
Message-ID, In-Reply-To, and References are parsed, preserved, and rendered
on every page precisely so that a future phase can derive reply threads from the
published HTML files themselves (or from git history) without needing a database.
No thread navigation exists yet.
GitHub publishing today refuses to overwrite by filename only. It does not yet
check whether an email with the same Message-ID was already published under a
different filename (e.g. because the subject was edited between sends) - see the
TODO(idempotency) comment at the top of src/github.js. Solving that properly
means looking posts up by Message-ID before computing a path, still without a
database - that's future work for the email-ingestion adapter, not this phase.
Sending an email publishes a post, with no CLI involved:
you email publish@blog.<yourdomain>
-> Cloudflare Email Routing (a rule on that subdomain's DNS zone)
-> Cloudflare Worker (src/worker.js)
-> isAllowedSender() - drops anything not from you, fails closed
-> parser.js / renderer.js / filename.js (unchanged)
-> github.js (GitHub Contents API: publishToGitHub, create-only)
-> commit
-> publishArchive.js (updateArchive: posts.json + archive.html, create-or-update)
-> GitHub Pages serves it all
src/worker.js is a thin adapter: it reads config from Cloudflare's env bindings
instead of process.env, bundles templates/post.html and templates/archive.html
as text imports (Workers have no filesystem, so renderPost/renderArchive accept
the template as an optional override - see src/renderer.js / src/archive.js),
and calls the exact same parseEmail / renderPost / buildBaseName /
publishToGitHub / updateArchive functions the CLI uses. index.html and
about.html are static, so the Worker never touches them.
Only mail from an address listed in ALLOWED_SENDER gets published; anything
else is silently dropped (see src/allowlist.js). ALLOWED_SENDER holds one
address, or several comma-separated (a@example.com,b@example.com). This fails
closed: if it isn't configured at all, nothing publishes - it does not fall back
to "allow everyone." Without this, the routing address would be an open publish
endpoint for anyone who discovered it.
You don't need to move your whole domain to Cloudflare - only the subdomain you want to receive mail on:
- In Cloudflare: add your publishing subdomain (e.g.
blog.yourdomain.tld) as its own zone. Cloudflare gives you two nameservers for it. - At your registrar (wherever
yourdomain.tld's DNS lives): add an NS delegation record for that subdomain pointing at the two Cloudflare nameservers. This delegates only the subdomain - the rest of your domain is untouched. - Wait for Cloudflare to detect the delegation and activate the zone.
- In that zone: Email → Email Routing → enable it → create a routing rule for
one specific address (e.g.
publish@blog.yourdomain.tld) that sends matching mail to a Worker, not to an inbox. - Deploy the Worker:
npm install -g wrangler # or use `npx wrangler` wrangler login wrangler secret put GITHUB_TOKEN # the same fine-grained token as above wrangler secret put ALLOWED_SENDER # address(es) allowed to publish, comma-separated wrangler deploy
GITHUB_OWNER/GITHUB_REPO/GITHUB_BRANCH/POSTS_DIRare already set inwrangler.tomlunder[vars]- edit them there if they differ, since they're not secret.GITHUB_TOKENandALLOWED_SENDERare deliberately kept out of that file (which is committed to a public repo) and set as Worker secrets instead. - In the Email Routing rule from step 4, point it at the deployed
eml2webWorker.
From then on: emailing publish@blog.yourdomain.tld from your allowed address
publishes it, automatically. Emailing any other address - including an unrelated
mailbox on a different provider that happens to share your name - does nothing;
Cloudflare Email Routing only intercepts mail for zones it actually controls.
Not yet exercised by any test (it needs a live Cloudflare account to run at all);
src/allowlist.js and the rest of the shared pipeline are covered by the regular
test suite.
src/
parser.js - raw email -> normalized post object (uses postal-mime, tags.js)
tags.js - extractTags(): pulls "#word" tags out of a subject
renderer.js - normalized post -> escaped HTML (uses templates/post.html or an injected template)
archive.js - manifest -> the tag-grouped archive.html (uses templates/archive.html)
manifest.js - parse/append/serialize posts.json
publishArchive.js - shared "update posts.json + archive.html on GitHub" step
filename.js - normalized post -> collision-safe "YYYY-MM-DD-slug.html"
github.js - publishToGitHub (create-only) / upsertToGitHub (create-or-update)
allowlist.js - isAllowedSender(): fail-closed sender check for the Worker
config.js - reads GitHub settings from environment variables (CLI only)
cli.js - local command: .eml in, output/{posts/,posts.json,archive.html} out
cli-github.js - GitHub command: .eml in, committed to the repo via github.js
worker.js - Cloudflare Email Worker adapter (see "Email-triggered publishing")
templates/
post.html - the per-post page template; edit this directly, it's plain HTML
archive.html - the archive-page template; {{SECTIONS}} is filled in by archive.js
index.html - the static Home page (repo root); hand-edited, not generated
about.html - the static About page (repo root); hand-edited, not generated
style.css - the entire stylesheet for published pages
test/
fixtures/*.eml
parser.test.js, renderer.test.js, filename.test.js, github.test.js, allowlist.test.js,
tags.test.js, manifest.test.js, archive.test.js
output/ - where the local CLI writes generated pages (gitignored)
.env.example - GITHUB_TOKEN / GITHUB_OWNER / GITHUB_REPO / GITHUB_BRANCH
wrangler.toml - Worker deployment config ([vars], text-import rule for the templates)