CLI tool
beilunyang/moemail avatar
beilunyang/moemail

MoeMail's inbound email worker cannot be tested locally, and its Pages deploy hardcodes a branch the repository does not use

A cute temporary email service built with NextJS + Cloudflare technology stack 🎉 | 一个基于 NextJS + Cloudflare 技术栈构建的可爱临时邮箱服务🎉

2,824 stars2,595 forksTypeScriptMIT

At a glance

What is it?
A self-hostable temporary email service on Next.js and Cloudflare, with D1 for storage, Email Workers for inbound mail, Drizzle ORM, a CLI and an MCP server. Three separate deployment targets, two lockfiles, and a documentation section that stops mid sentence.
Who is it for?
This fits a specific job: giving yourself a disposable address for a signup, so your real one stays out of the mailing list, with an API key and an OpenAPI surface so scripts and agents can read the inbox instead of a human. The Cloudflare dependency is not incidental and not optional in practice, because the inbound worker is the part that cannot run on your machine, so a first evaluation means deploying a Worker before you know whether you want the project.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 38 days ago.
What is it written in?
Mainly TypeScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 4, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The inbound email worker cannot be run or tested locally

The development section contains the project's clearest limitation, stated without hedging.

Under Test Email Worker it says the worker currently cannot be run and tested locally, and asks you to use Wrangler to deploy the email worker and test it, which is the `pnpm deploy:email` step.

That matters more than it first appears. The inbound path is the part that makes a temporary mailbox a mailbox rather than an empty table: something has to receive the message and write it into D1. If that path can only be exercised after a deployment, then the first run of the product against a real inbound message happens on Cloudflare infrastructure rather than in a development loop.

The other two pieces are friendlier. The cleanup worker does run locally, with `pnpm dev:cleanup` starting it under `wrangler dev --test-scheduled` and `pnpm test:cleanup` exercising it by requesting `http://localhost:8787/__scheduled` with curl. And you can fill a database without sending anything, since `pnpm generate-test-data` runs the generator through `wrangler dev`.

So the local story is: schema and mock data yes, scheduled cleanup yes, receiving real mail no. Anyone evaluating this should plan on a deployment before they can answer the question they actually care about, which is whether mail arrives.

Three wrangler config files, copied by hand, one per deploy target

Setup step three is three copy commands, and each one creates a config file for a different deployable thing.

bash
cp wrangler.example.json wrangler.json
cp wrangler.email.example.json wrangler.email.json
cp wrangler.cleanup.example.json wrangler.cleanup.json

After copying you are told to set the Cloudflare D1 database name and database id. That step exists because the example files are templates rather than usable configs, and there is nothing in the tree that generates them.

The three files then get used separately, and the scripts make the separation explicit. `deploy:email` runs `wrangler deploy --config wrangler.email.json`, `deploy:cleanup` runs `wrangler deploy --config wrangler.cleanup.json`, and `deploy:pages` builds and hands static output to `wrangler pages deploy`. The local cleanup loop names its config too, with `wrangler dev --config wrangler.cleanup.json --test-scheduled`.

So a self-hoster is running three deployables from one repository, each with its own Wrangler config, and the only place the relationship between them is written down is this list of copy commands. Two of the three examples are named for their role, `wrangler.email.example.json` and `wrangler.cleanup.example.json`, while the third is just `wrangler.example.json`, which is the one holding the database settings the instructions ask you to edit.

The GitHub Actions path sidesteps all of it, because that workflow reads its configuration from repository secrets instead.

The Pages deploy names main while the repository's branch is master

One script in the manifest disagrees with the repository's own branch name, and it is the script that publishes the site.

json
"deploy:pages": "npm run build:pages && wrangler pages deploy .vercel/output/static --branch main"

The default branch of this repository is `master`, and the flag in that script is `--branch main`. Nothing in the scripts or the workflow connects the two names, so the Pages project is deployed under a branch label that is not the branch the code is on.

Three more details in the same command. It runs `npm run` inside a project whose instructions say to use pnpm, which works but means the command you copy is not the one the documentation tells you to type elsewhere. It depends on `build:pages`, which runs `npx @cloudflare/next-on-pages`, so the build adapter is fetched by npx at build time even though `@cloudflare/next-on-pages` is already a listed dependency. And the deployed directory is `.vercel/output/static`, which is the shape the Cloudflare Pages adapter produces rather than a Next.js standalone build, so the adapter is not optional to the deploy path.

None of these are fatal on their own. Together they mean the published site is produced by a command whose branch argument, package manager and adapter invocation are all chosen independently of the rest of the project.

next-auth is pinned to a beta and three core dependencies carry no caret

Almost every dependency in the manifest uses a caret range. Three do not, and they are the ones that decide whether the application runs.

`next` is pinned to 15.1.1, `next-auth` to 5.0.0-beta.25, and `react` to 19.0.0. Everything around them floats: `drizzle-orm` at `^0.36.4`, `nanoid` at `^5.0.6`, `next-intl` at `^4.3.12`, `next-pwa` at `^5.6.0`, `next-themes` at `^0.2.1`, `postal-mime` at `^2.3.2`, `lucide-react` at `^0.468.0`, `@auth/drizzle-adapter` at `^1.7.4`, `@cloudflare/next-on-pages` at `^1.13.6`, plus the individual Radix UI packages and `class-variance-authority` and `clsx`.

So the framework, the authentication library and the renderer are held exactly, and everything else is allowed to move within its major. That is a defensible split for a project whose runtime is a platform adapter, and it also has a cost worth stating: the three pinned packages, including the one that owns sessions, take no patch or minor updates until somebody edits the manifest.

A beta is a sharper version of the same point. `5.0.0-beta.25` is a pre-release of the authentication library, pinned without a range, and it is the component that holds the GitHub and Google OAuth sessions described in the tech stack. Anyone running this in production is running a beta there by choice, not by accident of resolution.

The manifest also sets `"type": "module"`, so the whole project, including its config files and build scripts, is ESM.

package.json says 0.1.0 and private, the tags say v0.15.0 and two v1.0.0 packages

The root package is not the thing that gets released, and its version field says so.

The manifest names the project `moemail`, sets the version to `0.1.0`, and marks the package `private: true`. The release tags tell a different story: `v0.15.0` from 2025-12-07, then `cli-v1.0.0` and `mcp-v1.0.0`, both dated 2026-06-16 and ten seconds apart.

Those last two are prefixed tags, and the release names identify the packages as `@moemail/cli` and `@moemail/mcp`. Both at version 1.0.0, released in the same moment, which is what a two package monorepo publishing step looks like. The `packages/` directory in the tree is where they live, and the root manifest is marked private precisely because it is not one of the things that ships.

So there are three version lines in one repository and none of them is the root version field. A tag like `v0.15.0` describes the application, a tag like `cli-v1.0.0` describes one package under `packages/`, and `0.1.0` in the manifest describes nothing that is published.

The last push is dated 2026-08-29, so code is landing after the June package releases. Nothing in the tree says whether a further application release followed `v0.15.0`, and the deployment instructions give a tag example of `v1.0.0`, which matches neither line.

Three lists of required variables, and Google login is in none of the deploy paths

Count the variables you are told to set and you get three different answers depending on which page you read.

The local setup step says set three: `AUTH_GITHUB_ID`, `AUTH_GITHUB_SECRET` and `AUTH_SECRET`, after copying `.env.example` to `.env.local`. The example file itself carries eleven keys, all with empty string values: those three plus `AUTH_GOOGLE_ID`, `AUTH_GOOGLE_SECRET`, `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`, `DATABASE_NAME`, `KV_NAMESPACE_NAME` and `CUSTOM_DOMAIN`. The GitHub Actions section lists nine secrets, and four of them are optional with documented defaults: `CUSTOM_DOMAIN` falls back to the Cloudflare Pages default domain, `PROJECT_NAME` to `moemail`, `DATABASE_NAME` to `moemail-db`, and `KV_NAMESPACE_NAME` to `moemail-kv`.

So a local run needs three, a deployment needs nine, and the file you copy from has eleven.

The gap worth noticing is Google. The tech stack names NextAuth with GitHub and Google login, and the example environment file reserves two keys for it, but neither the local instruction nor the Actions secret list includes `AUTH_GOOGLE_ID` or `AUTH_GOOGLE_SECRET`. On the documented paths, Google sign-in is configured in the file and never supplied.

The one secret with a security instruction attached is `AUTH_SECRET`, described as the NextAuth secret used to encrypt the session, with the note to set a random string.

The webhook test script needs Bun, and two lockfiles are committed

The tooling story in this repository is assembled from four different sources and they do not fully agree.

The prerequisites are Node.js 18 or newer, Pnpm, the Wrangler CLI and a Cloudflare account. Then there is a script named `webhook-test-server` whose body is `bun run scripts/webhook-test-server.ts`. Bun is not in the prerequisite list, and it is not the runtime any other script uses, so testing webhooks locally needs a second runtime installed and nothing in the documentation says so.

The package manager is ambiguous too. The instructions say pnpm, the root contains `pnpm-lock.yaml`, and the root also contains `package-lock.json`. Both are committed, which means a fresh clone has two dependency graphs describing the same project, and nothing in the README says which one wins. The `deploy:pages` script then calls `npm run` rather than a pnpm equivalent, which is the third data point and points at npm.

Linting is the fourth. The script is `next lint`, and the configuration file at the top level is `.eslintrc.json`, the older single-file format, with no flat config present.

None of these are errors. Each is a place where a contributor or a self-hoster makes a choice that the repository has not made for them, which is why the prerequisite list is worth reading as a summary rather than a specification.

The email domain guide stops at the Cloudflare routing step

Mail only arrives if two things line up, and the second one is where the documentation ends.

The first half is complete. In the user profile page you configure the site's email domains, and multiple domains are supported, separated by commas. So the application side is a profile field and a comma separated list.

The second half is a section titled Cloudflare Email Routing Configuration, and it opens with the sentence "To make email domains effect" and stops there. Everything after that point, which would be the part that has to happen on the Cloudflare side for a domain configured in the profile to actually receive mail, is not in this file.

Given the first section on testing, the shape of the gap is understandable: the inbound path cannot be exercised locally, so the routing setup is the part an installer has to get right on first attempt with the least guidance. The rest of the README is more generous. Deployment has a video tutorial link alongside the command instructions, and there is a separate documentation site at `docs.moemail.app` that the README points to for full usage guides, API documentation and deployment tutorials.

So the missing piece is likely there rather than absent, which is a different problem from an undocumented step. For anyone reading only the repository, the sentence that stops is the one that decides whether mail arrives at all.

Editorial conclusion

This fits a specific job: giving yourself a disposable address for a signup, so your real one stays out of the mailing list, with an API key and an OpenAPI surface so scripts and agents can read the inbox instead of a human. The Cloudflare dependency is not incidental and not optional in practice, because the inbound worker is the part that cannot run on your machine, so a first evaluation means deploying a Worker before you know whether you want the project. Before you commit, settle four things. Which branch your Pages project is actually on, since the deploy script passes `main` while the repository's default branch is `master` and the two are not connected anywhere in the scripts. Which authentication provider you are deploying, because GitHub credentials appear in every documented path and Google credentials appear in the example environment file but in none of them. Which package manager you are standardising on, because both a pnpm lockfile and an npm lockfile are committed while the instructions say pnpm. And what you will use to test webhooks, since that script needs Bun, which is not in the prerequisite list.

Frequently asked questions

What is MoeMail?

A self-hostable temporary email service built on Next.js and Cloudflare, using Cloudflare Pages for the site, D1 for storage, Cloudflare Email Workers for inbound mail and Drizzle ORM for the database, with GitHub or Google sign-in through NextAuth.

How long does a MoeMail mailbox last?

1 hour, 24 hours, 3 days, or permanent validity. Expired mailboxes and emails are cleaned up automatically, and the cleanup worker can be run and tested locally with its own wrangler config.

Can MoeMail send email as well as receive it?

Yes. Sending from a temporary address is supported and is based on the Resend service. Webhook notifications for new mail are also supported, and a webhook test server script is included for local testing.

What do I need to run MoeMail locally?

Node.js 18 or newer, Pnpm, the Wrangler CLI and a Cloudflare account, plus three wrangler config files copied from their examples and three environment variables set. The inbound email worker cannot be run or tested locally and has to be deployed with Wrangler.

How is MoeMail deployed with GitHub Actions?

Add the repository secrets, including CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, the GitHub OAuth credentials and AUTH_SECRET, with four optional ones carrying documented defaults. Then trigger either by pushing a tag that starts with v, or by running the Deploy workflow manually from the Actions page.

Does MoeMail ship a CLI and an MCP server?

Both, published from the packages directory as @moemail/cli and @moemail/mcp, each released at version 1.0.0. The CLI is described as designed for AI agents to automate email workflows, and OpenAPI access is available through an API key.

Official sources

  1. beilunyang/moemail on GitHub
  2. License: MIT
  3. Project website
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/beilunyang-moemail.svg)](https://hysenlabs.com/projects/beilunyang-moemail)