Skip to content

About

A system for emailing a post to a Git-based blog/website like tailwind-nextjs-starter-blog

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

post-inbox

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.

How it works

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

Security

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:

  1. 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.

  2. 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-Results header and ARC-Authentication-Results as arc=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. Setting REQUIRE_AUTH_RESULTS=false drops 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.

Setup

Requires Node 24+ (nvm use).

pnpm install
pnpm test

1. GitHub credentials

For 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.)

2. Configure your sites

Copy the example and edit it:

cp sites.example.jsonc sites.jsonc

sites.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.com

This 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.

3. Set secrets

Two guided commands. Global secrets first:

pnpm configure

That prompts for GITHUB_TOKEN (not echoed) and offers to generate an optional EMAIL_SUBJECT_TOKEN. Then once per site:

pnpm configure:site mysite

That 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_TOKEN

Global versus per-site tokens

GITHUB_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.

4. Deploy

pnpm deploy

This compiles sites.jsonc into the bundle and deploys. A malformed config fails here rather than when an email arrives.

5. Wire up email

In the Cloudflare dashboard, on a domain you control:

  1. Email → Email Routing, and enable it. Cloudflare adds the MX and SPF records for you.
  2. Email Routing → Routing rules → Create address. Pick the address you will mail posts to, e.g. post@yourdomain.com.
  3. 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.

Usage

By email

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].

Tags and summary

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.

Site-specific fields

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:

"extraFields": {
  "section": {
    "type": "enum",
    "values": {
      "howto": ["how-to", "tutorial"],
      "opinion": ["editorial"],
      "notes": []
    }
  },
  "series": { "type": "string" }
}
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": 100

frontmatter 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.

By HTTPS

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.

Markdown, and what MDX does to it

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.

Acceptance test

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.

Drafts and previews

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.

Several authors on one site

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:authors

On 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.

What a bounce tells you

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.

Email signatures

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.

When there is no delimiter

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 ## Links section 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.

Turning it off

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.

Roadmap

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.

License

MIT

About

A system for emailing a post to a Git-based blog/website like tailwind-nextjs-starter-blog

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages