Turn an email into a draft blog post — as a branch, a commit, and a pull request with a live preview. Never published directly.
Send an email (or an HTTPS POST from a Shortcut) and a PR shows up on your blog repo with the post committed on its own branch. You review the preview deploy and merge when you are happy with it.
Status: working on three sites. Multi-site routing, per-site senders and tokens, plaintext bodies, and attachments — images and PDFs, with HEIC converted and location data stripped. See Roadmap.
Email → Cloudflare Email Routing → Worker (email handler) ─┐
├─→ createDraftPost() → GitHub API → branch + commit + PR
Shortcut / curl → HTTPS POST → Worker (fetch handler) ─┘
Two entry points, one shared core function. src/core/ is plain
host-agnostic TypeScript with no Cloudflare in it — the runtime and the
email platform are thin adapters around it, so porting to Vercel Functions
or Postmark means rewriting only src/adapters/.
| Path | Role |
|---|---|
src/core/createDraftPost.ts |
The one function both entry points call |
src/core/markdown.ts |
Slug, frontmatter, bare-URL linkifying |
src/core/senderAuth.ts |
SPF/DKIM verdicts + sender allowlist + subject token |
src/core/github.ts |
The only code that talks to GitHub |
src/adapters/cloudflare/ |
Worker email and fetch handlers |
The HTTPS path takes a bearer token. Straightforward.
The email path is the interesting one, because a From: header is
trivially forgeable. Two independent factors are available:
-
The strong pair — the envelope sender must be on your allowlist, and the receiving mail server's SPF/DKIM verdicts must pass (at least one of SPF or DKIM, with no DMARC failure). A forged sender fails this: an attacker cannot produce your domain's DKIM signature.
-
A shared secret in the subject line, written as
[token]and stripped out before the subject becomes the post title.
The token is only required when the strong pair cannot carry the message — no allowlist configured, verdict checking disabled, or verdicts that did not actually pass. When SPF/DKIM pass and you are on the allowlist, just write a normal subject line. If you do supply a token it must still be correct, so a stale one fails loudly rather than being ignored.
The two can never both be absent. Disabling verdict checking makes the token mandatory; having no token configured means a message without passing verdicts is simply rejected.
Note on check 3: this check is known to be fragile on Cloudflare. cloudflare/workerd#6740 reports mail reaching an Email Worker with no
Authentication-Resultsheader andARC-Authentication-Resultsasarc=none— no verdicts at all. In practice, mail sent from Gmail to a Cloudflare Email Routing address does arrive with passing verdicts, so the check works. If yours does not, this code fails closed and rejects the message rather than waving it through. SettingREQUIRE_AUTH_RESULTS=falsedrops to checks 1 and 2 only — a real downgrade, since the subject token becomes the only thing an attacker who forges your address has to guess. Survivable here because the worst case is an unwanted draft PR, never a published post.
Nothing sensitive is committed: all credentials, the sender allowlist, and
the subject token are Wrangler secrets. See .dev.vars.example.
Requires Node 24+ (nvm use).
pnpm install
pnpm testFor the POC, a fine-grained personal access token is enough. Scope it to the single blog repo with Contents: read/write and Pull requests: read/write. (A GitHub App installed on the org is the better answer for the MVP, when multiple repos are in play.)
Copy the example and edit it:
cp sites.example.jsonc sites.jsoncsites.jsonc is gitignored — it names the addresses that accept mail and
the repos that get written to, and this repo is public. Keep real values out of
anything committed, including docs and tests: use example.com placeholders.
Each site needs a key (lowercase, it becomes an environment-variable prefix),
one or more inboundAddresses, and the repo to write to. For the Tailwind
Nextjs Starter Blog, contentPath is data/blog and extension is .mdx.
Pick an unguessable inbound address. draft@yourdomain.com will be found by
address-harvesting bots, and every junk message wakes the Worker and fills the
logs with rejections:
pnpm generate:address yourdomain.comThis is noise reduction, not access control — the sender allowlist and DKIM checks are what stop a stranger posting, and an address that leaks costs you nothing but a rotation.
Two guided commands. Global secrets first:
pnpm configureThat prompts for GITHUB_TOKEN (not echoed) and offers to generate an optional
EMAIL_SUBJECT_TOKEN. Then once per site:
pnpm configure:site mysiteThat sets MYSITE_ALLOWED_SENDERS — who may post, a real access control — and
offers to generate MYSITE_API_TOKEN for the HTTPS path. Both commands ask
before replacing a secret that is already set.
Generated tokens are piped straight into wrangler secret put, so they never
reach your clipboard or shell history. The two that you have to use elsewhere —
the subject token and the API token — are printed once; save them then, because
Cloudflare cannot read a secret back.
To do it by hand instead:
npx wrangler secret put GITHUB_TOKEN
npx wrangler secret put MYSITE_ALLOWED_SENDERS # comma- or space-separated
npx wrangler secret put MYSITE_API_TOKEN # HTTPS path, optional
npx wrangler secret put EMAIL_SUBJECT_TOKEN # optional fallback
# Optional per-site overrides of the two globals
npx wrangler secret put MYSITE_GITHUB_TOKEN
npx wrangler secret put MYSITE_EMAIL_SUBJECT_TOKENGITHUB_TOKEN and EMAIL_SUBJECT_TOKEN are defaults. Any site may override
either with its own:
| Secret | Effect |
|---|---|
MYSITE_GITHUB_TOKEN |
This site writes with its own credential |
MYSITE_EMAIL_SUBJECT_TOKEN |
This site accepts its own subject secret |
A per-site GitHub token is the better setup once you have more than one site: a credential scoped to one repo cannot reach the others, so a leak is contained. The global token has to reach every repo it is the default for — and its resource owner must be the org, not a personal account, or the other repos answer 404 rather than 403.
Both fall back to the global value, so this is incremental: an existing
deployment with only the globals keeps working, and sites can move across one
at a time. pnpm configure:site <key> offers both.
pnpm deployThis compiles sites.jsonc into the bundle and deploys. A malformed config
fails here rather than when an email arrives.
In the Cloudflare dashboard, on a domain you control:
- Email → Email Routing, and enable it. Cloudflare adds the MX and SPF records for you.
- Email Routing → Routing rules → Create address. Pick the address
you will mail posts to, e.g.
post@yourdomain.com. - Set its action to Send to a Worker and choose
post-inbox.
Repeat per site. The address the mail was sent to is what selects the site, so each site needs its own address.
Mail the routing address with the subject as your post title:
Subject: My New Post
The body is markdown, passed through as you wrote it.
Bare URLs like https://example.com get wrapped into links.
If your mail does not carry passing SPF/DKIM verdicts, add the shared secret
in brackets — Subject: My New Post [your-subject-token].
Set frontmatter the subject line cannot carry with a Key: value block at the
very top of the body:
Tags: Coding, iOS, job application
Summary: What I learned shipping this.
The post body starts here.
Tags and Summary are recognised everywhere, plus any extra fields the site
declares (below). Only a contiguous block at the start counts — the first line
that is not a recognised key ends the block, so prose containing a colon is
never mistaken for metadata. An unrecognised key is left in the body rather
than silently dropped.
Tags split on commas only, so a tag may contain spaces (job application), and
case is preserved (IKEA, SwiftLint) to match the tags a site already uses.
Sites built on the same template still differ: one may categorise posts and
group them into a series, another neither. A site declares what its schema
accepts in sites.jsonc, and those names then work in the header block.
The field names below are an illustration — use whatever your site's schema declares:
Tags: Coding
Summary: What I learned shipping this.
Section: Tutorial
Series: Building a CLI
The post body starts here.
That stores section: 'howto' and series: 'Building a CLI'.
An enum maps the value that reaches the frontmatter to the other spellings a
sender may type. A stored value always accepts itself, and matching ignores
case, so howto, How-To and Tutorial all store howto. Aliases exist
because a site's labels often differ from what it stores — a page headed
"Editorial" for a stored opinion — and someone writing an email types what
they read on the site. A value with no alternatives lists [].
An unrecognised value is refused in the bounce, naming what is accepted, rather than written as frontmatter that breaks the site build or quietly takes the schema's default.
A string field is stored as written. Use it where the value is open-ended, or where the site itself warns about unknown values at build time.
Declaring a field is the only way a header line becomes frontmatter. A site
that declares nothing behaves exactly as it did before this existed: Section: Tutorial at the top of an email is part of the post.
Two related settings:
"frontmatter": { "layout": "WideLayout" },
"summaryMinLength": 100frontmatter is added to every post for that site, and a header-block value for
the same key overrides it. summaryMinLength notes a missing or short summary
in the pull request — advisory only, never a rejection, since a summary is what
link previews and search results show.
curl -X POST https://post-inbox.<subdomain>.workers.dev \
-H "Authorization: Bearer $MYSITE_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"site":"mysite","title":"My New Post","body":"Hello.","tags":["Personal"]}'title and body are required. site names which site to post to, and may be
omitted when only one is configured. date, tags, summary and draft are
optional. The bearer token is the one for that site.
Any site-specific field is a top-level key too, and is
validated the same way — the same aliases, and a 400 naming the accepted
values rather than a bounce:
-d '{"title":"My New Post","body":"Hello.","section":"Tutorial","series":"Building a CLI"}'A key the site did not declare is ignored, as an undeclared header line is left as prose.
Posts are committed as .mdx, but what you write in an email is markdown.
MDX reads < as the start of a JSX tag and { as a JavaScript expression, so
prose like Array<int>, x <5, or {maybe} is a build error, not text —
a <https://example.com> autolink is enough to fail the site build.
So <, { and } are escaped before committing, and <url> / <user@host>
autolinks are converted to real markdown links. Code — fenced blocks and
inline backticks — is left alone, and so is ordinary prose: headings, lists,
tables, emphasis, task lists, footnotes, quotes and unicode all pass through
untouched.
Two consequences worth knowing:
- Raw HTML in the body will not render.
<b>bold</b>comes out as visible angle brackets. Use**bold**. - An unbalanced backtick can still break the build. An odd number of backticks makes the code-span detection read the rest of the line as code and skip escaping it. Balance your backticks.
examples/acceptance-test/ is the canonical check: send that email, get a
draft PR that builds. Its body.md exercises every supported feature, and
expected.mdx records exactly what the pipeline should produce — pnpm test
diffs against it, so most regressions are caught without sending anything.
Run the email version after changing the transformation, upgrading the site
template, or setting this up for a new site. See
examples/acceptance-test/README.md.
The site repo should also validate this in CI, since a post can reach it without going through post-inbox at all — the web editor, or a direct push.
Posts are committed with draft: false, and the pull request is what stops
them going live. That is deliberate: the Tailwind Nextjs Starter Blog excludes
drafts from production builds, and a Vercel preview is a production build, so
a draft: true post returns 404 on the very preview meant for reviewing it.
Nothing is published by the post existing — it is on a branch, nothing links to it, and merging is an explicit act.
Set POST_AS_DRAFT=true if you would rather merge posts to your main branch
unpublished and flip the flag in a separate commit later. Expect the preview to
404 in that case; check the diff instead. The HTTPS path can also pass
"draft": true per request.
A site with more than one writer can attribute posts by sender. Map each
envelope address to a file in that site's data/authors directory:
"authorsBySender": {
"someone@example.com": "someone",
"someone-else@example.com": "someone-else"
}The value is a bare basename — no directory, no .mdx. Anyone unlisted posts
under the site's own default author, because no authors field is emitted at
all.
The file has to exist. Frontmatter naming an author with no matching file
breaks the site build, so a typo here is a broken deploy rather than a cosmetic
mistake. sites.jsonc is validated for shape on every build; to check the files
themselves:
pnpm check:authorsOn the email path the address is the DKIM-verified envelope sender, so
attribution is as trustworthy as the authentication. On the HTTPS path the
author field is a claim — the bearer token authorises the request, and nothing
verifies who is named.
A rejected message comes back as an SMTP rejection, which your mail server turns into a bounce. What it says depends on why:
- Authentication failures stay generic — just
Message rejected. Naming the check that failed would tell someone probing the system whether an address is allowlisted, or whether a guessed token was close. The real reason is in the Worker logs (npx wrangler tail). - Everything after authentication explains itself. A sender who got past the
allowlist and DKIM is not an attacker, so a message that cannot become a post
says why: no plain-text body, a subject that left no title, a body that was
only a
Tags:block. A failure writing to GitHub says the post did not land, with the detail kept in the logs.
Put -- on a line of its own above your signature. That is the standard
delimiter (RFC 3676), it is matched exactly, and everything below it is removed
with no guessing involved. It is the reliable option, and worth setting up once
in your mail client.
Two hyphens and a space. A bare -- also works, since relays trim trailing
whitespace. Three or more hyphens is a markdown horizontal rule and is left
alone.
Some clients — iOS Gmail among them — append a signature as plain trailing lines with no delimiter at all. Since a post becomes a pull request on a repo that may be public, leaving an email address in it is a privacy problem, so a narrow fallback removes a trailing block that is unmistakably contact details.
It only fires when all of these hold:
- the block is last, after a blank line, with content above it
- at most 5 lines, none longer than 60 characters (per line, not total)
- no markdown in it — a heading, list, quote, code fence or image means it is part of your post
- it is not directly under a heading, so a
## Linkssection survives - one of the last two lines is nothing but an email address or a URL
That last condition is what does the work. A signature ends with contact details, where prose mentions a link mid-thought and carries on — so a closing paragraph, a list of links, or a recipe with a URL in it are all left alone.
What this deliberately will not strip: a signature of only a name (it reads
exactly like a closing line), a long corporate block of more than 5 lines, or
anything with markdown in it. Use -- for those.
To keep every signature and trim them yourself:
npx wrangler secret put STRIP_SIGNATURE # set to "false"That disables both the delimiter rule and the fallback. To change the
thresholds instead, they are named constants at the top of
src/core/signature.ts — MAX_BLOCK_LINES,
MAX_LINE_LENGTH and CONTACT_WITHIN_LAST — each with the reasoning for its
value, and the tests in test/signature.test.ts cover both what must be
stripped and what must survive.
If the fallback ever eats part of a post, that is a bug worth reporting: add the case to those tests.
POC (here): one site, one sender, plaintext body, email + HTTPS paths.
Known gap: there is no way to set tags from an email. See STATUS.md.
MVP: multi-site and multi-user config, GitHub App instead of a PAT, Cloudflare rate limiting, and per-user allowlists.
Done since: attachments — images and PDFs committed into the repo behind a MIME allowlist and a size cap, with location and device metadata stripped on the way in, and HEIC converted to JPEG by the Cloudflare Images binding.
Conversion happens in the Worker after all, not in a GitHub Action. sharp
does need native binaries a Worker cannot run — but prebuilt sharp cannot
decode HEIC anywhere, because HEVC is patent-encumbered and excluded from its
binaries, so the Action route would have needed a hand-built libheif and a
second metadata-stripping pass. The Images binding decodes HEIC directly, and
strips EXIF and bakes in rotation as a side effect.
MIT