Self-hosted newsletters on Amazon SES. About $0.10 per 1,000 emails.
JavaScript
799
1 commits
updated Oct 10, 2026
Mailcheap is a self-hosted newsletter app on Amazon SES. Write an issue, send it to your list, publish it on the web. About $0.10 per 1,000 emails.

[!IMPORTANT] No support. This is a one-time open-source release. Issues are off, and setup questions go unanswered. Fork it, deploy it, make it yours.
Newsletter platforms charge for every subscriber, every month. With your own SES account you pay for the emails you send: 10,000 readers and 4 issues a month is 40,000 emails, about $4.
Mailcheap is the rest of what a newsletter needs, in one small Next.js app: an editor, a list, signup forms, unsubscribe handling, bounce and complaint handling, stats and a public archive. One admin, no teams, no plans. Your list stays in your database.
Write and send
Grow the list
Keep it healthy
List-Unsubscribe headers) and an unsubscribe page, with signed links.Publish and measure
| Editor | Campaign report |
|---|---|
![]() | ![]() |
| Contacts | Public archive |
![]() | ![]() |

All names and addresses in the screenshots are made up.
| Service | For | Cost |
|---|---|---|
| MongoDB | Contacts, campaigns, stats | Free tier works |
| Amazon SES | Sending | About $0.10 per 1,000 emails |
| Upstash QStash | Background sending and scheduling | Free tier to start, then pay per message. One message sends 50 emails. |
| Vercel or any Node.js host | Hosting | Free tier works |
| Upstash Redis (optional) | Rate limits shared by every server instance | Free tier |
| Vercel Blob (optional) | Image uploads in the editor | Free tier |
Without Redis, Mailcheap counts the same rate limits in each server instance's memory. That still stops a script hammering a form, but on serverless hosts the limits aren't shared between instances and reset on each deploy. Add Redis before you put a signup form on a busy site.
Without Blob, image uploads in the editor are off. HTML and Markdown campaigns can still use images hosted anywhere.
Plan for about an hour, most of it waiting for DNS and for AWS to approve production access.
0.0.0.0/0. Vercel doesn't have fixed IP addresses.?, like ...mongodb.net/mailcheap?retryWrites=true. That's your MONGODB_URI.MONGODB_URI, ADMIN_EMAIL, ADMIN_PASSWORD (at least 12 characters) and SESSION_SECRET (at least 32; generate it with openssl rand -base64 48). With shorter ones, Mailcheap refuses to start and the logs say which one to fix.https://<your-project>.vercel.app/login and log in.Sending is off until you finish the next steps, so nothing can go out by mistake. On any other Node.js host: npm ci && npm run build && npm start, with the same variables, behind HTTPS.
If you plan to use your own domain for the dashboard (like mailcheap.example.com), add it in Vercel now and set APP_URL to it. Links in your emails use APP_URL, so it should be final before your first send.
Use one AWS region for everything below (the examples use us-east-1).
mail.example.com, so your main domain's email is unaffected. Keep Easy DKIM with RSA 2048 and add the 3 CNAME records SES gives you at your DNS provider.bounce.mail.example.com, and add the MX and TXT records SES shows. This aligns SPF with your domain._dmarc.example.com with v=DMARC1; p=none; to start. Gmail and Yahoo require DMARC from bulk senders.mailcheap for example, under Configuration sets. Leave SES open and click tracking off: Mailcheap tracks on your own domain.mailcheap-ses-events, and copy its ARN. Then switch it to signature version 2, which signs messages with SHA256 instead of SHA1: aws sns set-topic-attributes --topic-arn <topic-arn> --attribute-name SignatureVersion --attribute-value 2.{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["ses:SendEmail", "ses:SendRawEmail"],
"Resource": [
"arn:aws:ses:us-east-1:<account-id>:identity/mail.example.com",
"arn:aws:ses:us-east-1:<account-id>:configuration-set/mailcheap"
]
},
{
"Effect": "Allow",
"Action": ["ses:GetAccount", "ses:ListEmailIdentities", "ses:GetEmailIdentity"],
"Resource": "*"
}
]
}
SES_REGION, SES_ACCESS_KEY_ID, SES_SECRET_ACCESS_KEY, SES_CONFIGURATION_SET and SES_SNS_TOPIC_ARN.https://<your-app>/api/webhooks/ses. Leave raw message delivery off. Mailcheap confirms the subscription by itself; refresh and the status turns to Confirmed.The Domains page in Mailcheap now shows your domain with its DKIM and MAIL FROM status. Optional: in SES Account dashboard > Suppression list, turn on the account-level list for bounces and complaints too.
QSTASH_TOKEN, QSTASH_CURRENT_SIGNING_KEY and QSTASH_NEXT_SIGNING_KEY (and QSTASH_URL if you picked a region other than the default).QStash calls APP_URL/api/jobs/... to send each batch, so the app has to be reachable on the internet. Mailcheap checks QStash's signature on every job.
SENDING_ENABLED=true in Vercel and redeploy.Archive on its own domain: add the domain to your Vercel project, then set ARCHIVE_HOSTS=news.example.com=<newsletter-slug> and redeploy. That domain serves only the archive and the unsubscribe page, never the dashboard, and the app domain's /archive/<newsletter-slug> pages redirect there.
This is the safer setup: issue pages show HTML from your editor and from API keys, and on their own domain that HTML never shares the dashboard's origin. Without one, the archive stays on the app domain, where the sanitizer, the sandboxed frame and the Content Security Policy protect it.
Image uploads: in Vercel, Storage > Create > Blob, connect it to the project, and redeploy.
Shared rate limits: create a Redis database in the Upstash console and add UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN.
Alerts: set ALERT_FROM (like Mailcheap <alerts@mail.example.com>) to get alerts by email at ADMIN_EMAIL: spam complaints, paused sends, signup floods and rate limiter outages, plus every login, new API key and send. Otherwise they only show in the logs.
git clone https://github.com/<you>/mailcheap.git && cd mailcheap
npm install
cp .env.example .env.local # set MONGODB_URI, ADMIN_EMAIL, ADMIN_PASSWORD, SESSION_SECRET
npm run dev
A local MongoDB works: docker run -d -p 27017:27017 mongo and MONGODB_URI=mongodb://127.0.0.1:27017/mailcheap.
SENDING_ENABLED=true. When the SES keys are empty, the AWS SDK falls back to your default AWS credentials (~/.aws), so the domains page can show real data.localhost. To try a full send locally, run the QStash dev server (npx @upstash/qstash-cli dev) and use the URL, token and signing keys it prints.npm test runs the unit tests and npm run lint runs ESLint.mail.example.com), so a bad week doesn't affect your main domain's email.| Variable | Required | What it does |
|---|---|---|
MONGODB_URI | Yes | MongoDB connection string with a database name |
ADMIN_EMAIL | Yes | Dashboard login, and where test emails and alerts go |
ADMIN_PASSWORD | Yes | Dashboard password, 12+ characters. Changing it signs out every session. |
SESSION_SECRET | Yes | Signs sessions and email links. 32+ random characters. Changing it breaks links in sent emails. |
APP_URL | On other hosts | Public URL of the app. On Vercel it defaults to the production domain. |
SENDING_ENABLED | To send | Nothing is sent unless it's true |
SES_REGION | To send | SES region, us-east-1 by default |
SES_ACCESS_KEY_ID, SES_SECRET_ACCESS_KEY | To send | IAM user keys |
SES_CONFIGURATION_SET | To send | Configuration set that publishes events to SNS |
SES_SNS_TOPIC_ARN | To send | SNS topic Mailcheap accepts at /api/webhooks/ses |
QSTASH_TOKEN, QSTASH_CURRENT_SIGNING_KEY, QSTASH_NEXT_SIGNING_KEY | To send campaigns | Background sending and scheduling |
QSTASH_URL | No | QStash region other than the default, or a local QStash dev server |
SEND_RATE_PER_SECOND | No | Emails per second, 1 to 14 (default 10). Use 6 on a free Atlas cluster. |
ARCHIVE_HOSTS | No | Archive domains, like news.example.com=weekly,letters.example.org=monthly |
UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN | No | Shared rate limits |
BLOB_READ_WRITE_TOKEN | No | Image uploads in the editor (set by Vercel Blob) |
ALERT_FROM | No | From address for alert emails, on a verified domain |
.env.example has the same list with more detail.
Create a key on the API keys page and send it as Authorization: Bearer <key>.
Add a contact: POST /api/v1/contacts
curl -X POST https://<your-app>/api/v1/contacts \
-H "Authorization: Bearer $MAILCHEAP_API_KEY" \
-H "Content-Type: application/json" \
-d '{"newsletter":"weekly","email":"jane@example.com","source":"website","ip":"203.0.113.7"}'
newsletter (slug) and email are required. source labels where the signup came from.resubscribe: true brings back someone who unsubscribed (never someone who bounced or complained).ip to get the per-visitor limit of 5 signups per 10 minutes. Each key is also limited to 600 signups an hour.201 for a new contact, 200 if it already existed, with { id, status, created }. When the newsletter confirms signups, status is pending and inbox links to the reader's email provider for your "check your inbox" message.Create a draft: POST /api/v1/campaigns with newsletter, subject, previewText (optional), html or markdown, and archiveSlug (optional). Answers 201 with the draft's id and dashboard URL. Sending stays a click in the dashboard.
List sent issues: GET /api/v1/campaigns?newsletter=<slug> returns each issue's id, subject, preview text, archive URL and send date.
SEND_RATE_PER_SECOND and pauses the campaign if bounce or complaint rates pass the limits./api/webhooks/ses. Mailcheap verifies the SNS signature, updates the message, the campaign stats and the contact, and suppresses bounced and complaining addresses for every newsletter./o/... and /c/... on your app's domain. Clicks within 30 seconds of the send, or from known link scanners, don't count.There's none: issues are off and pull requests may not be reviewed. This is a one-time release, with no updates planned. See SECURITY.md for what's built in.
Self-hosted newsletters on Amazon SES. About $0.10 per 1,000 emails.
JavaScript
799
1 commits
updated Oct 10, 2026
Mailcheap is a self-hosted newsletter app on Amazon SES. Write an issue, send it to your list, publish it on the web. About $0.10 per 1,000 emails.

[!IMPORTANT] No support. This is a one-time open-source release. Issues are off, and setup questions go unanswered. Fork it, deploy it, make it yours.
Newsletter platforms charge for every subscriber, every month. With your own SES account you pay for the emails you send: 10,000 readers and 4 issues a month is 40,000 emails, about $4.
Mailcheap is the rest of what a newsletter needs, in one small Next.js app: an editor, a list, signup forms, unsubscribe handling, bounce and complaint handling, stats and a public archive. One admin, no teams, no plans. Your list stays in your database.
Write and send
Grow the list
Keep it healthy
List-Unsubscribe headers) and an unsubscribe page, with signed links.Publish and measure
| Editor | Campaign report |
|---|---|
![]() | ![]() |
| Contacts | Public archive |
![]() | ![]() |

All names and addresses in the screenshots are made up.
| Service | For | Cost |
|---|---|---|
| MongoDB | Contacts, campaigns, stats | Free tier works |
| Amazon SES | Sending | About $0.10 per 1,000 emails |
| Upstash QStash | Background sending and scheduling | Free tier to start, then pay per message. One message sends 50 emails. |
| Vercel or any Node.js host | Hosting | Free tier works |
| Upstash Redis (optional) | Rate limits shared by every server instance | Free tier |
| Vercel Blob (optional) | Image uploads in the editor | Free tier |
Without Redis, Mailcheap counts the same rate limits in each server instance's memory. That still stops a script hammering a form, but on serverless hosts the limits aren't shared between instances and reset on each deploy. Add Redis before you put a signup form on a busy site.
Without Blob, image uploads in the editor are off. HTML and Markdown campaigns can still use images hosted anywhere.
Plan for about an hour, most of it waiting for DNS and for AWS to approve production access.
0.0.0.0/0. Vercel doesn't have fixed IP addresses.?, like ...mongodb.net/mailcheap?retryWrites=true. That's your MONGODB_URI.MONGODB_URI, ADMIN_EMAIL, ADMIN_PASSWORD (at least 12 characters) and SESSION_SECRET (at least 32; generate it with openssl rand -base64 48). With shorter ones, Mailcheap refuses to start and the logs say which one to fix.https://<your-project>.vercel.app/login and log in.Sending is off until you finish the next steps, so nothing can go out by mistake. On any other Node.js host: npm ci && npm run build && npm start, with the same variables, behind HTTPS.
If you plan to use your own domain for the dashboard (like mailcheap.example.com), add it in Vercel now and set APP_URL to it. Links in your emails use APP_URL, so it should be final before your first send.
Use one AWS region for everything below (the examples use us-east-1).
mail.example.com, so your main domain's email is unaffected. Keep Easy DKIM with RSA 2048 and add the 3 CNAME records SES gives you at your DNS provider.bounce.mail.example.com, and add the MX and TXT records SES shows. This aligns SPF with your domain._dmarc.example.com with v=DMARC1; p=none; to start. Gmail and Yahoo require DMARC from bulk senders.mailcheap for example, under Configuration sets. Leave SES open and click tracking off: Mailcheap tracks on your own domain.mailcheap-ses-events, and copy its ARN. Then switch it to signature version 2, which signs messages with SHA256 instead of SHA1: aws sns set-topic-attributes --topic-arn <topic-arn> --attribute-name SignatureVersion --attribute-value 2.{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["ses:SendEmail", "ses:SendRawEmail"],
"Resource": [
"arn:aws:ses:us-east-1:<account-id>:identity/mail.example.com",
"arn:aws:ses:us-east-1:<account-id>:configuration-set/mailcheap"
]
},
{
"Effect": "Allow",
"Action": ["ses:GetAccount", "ses:ListEmailIdentities", "ses:GetEmailIdentity"],
"Resource": "*"
}
]
}
SES_REGION, SES_ACCESS_KEY_ID, SES_SECRET_ACCESS_KEY, SES_CONFIGURATION_SET and SES_SNS_TOPIC_ARN.https://<your-app>/api/webhooks/ses. Leave raw message delivery off. Mailcheap confirms the subscription by itself; refresh and the status turns to Confirmed.The Domains page in Mailcheap now shows your domain with its DKIM and MAIL FROM status. Optional: in SES Account dashboard > Suppression list, turn on the account-level list for bounces and complaints too.
QSTASH_TOKEN, QSTASH_CURRENT_SIGNING_KEY and QSTASH_NEXT_SIGNING_KEY (and QSTASH_URL if you picked a region other than the default).QStash calls APP_URL/api/jobs/... to send each batch, so the app has to be reachable on the internet. Mailcheap checks QStash's signature on every job.
SENDING_ENABLED=true in Vercel and redeploy.Archive on its own domain: add the domain to your Vercel project, then set ARCHIVE_HOSTS=news.example.com=<newsletter-slug> and redeploy. That domain serves only the archive and the unsubscribe page, never the dashboard, and the app domain's /archive/<newsletter-slug> pages redirect there.
This is the safer setup: issue pages show HTML from your editor and from API keys, and on their own domain that HTML never shares the dashboard's origin. Without one, the archive stays on the app domain, where the sanitizer, the sandboxed frame and the Content Security Policy protect it.
Image uploads: in Vercel, Storage > Create > Blob, connect it to the project, and redeploy.
Shared rate limits: create a Redis database in the Upstash console and add UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN.
Alerts: set ALERT_FROM (like Mailcheap <alerts@mail.example.com>) to get alerts by email at ADMIN_EMAIL: spam complaints, paused sends, signup floods and rate limiter outages, plus every login, new API key and send. Otherwise they only show in the logs.
git clone https://github.com/<you>/mailcheap.git && cd mailcheap
npm install
cp .env.example .env.local # set MONGODB_URI, ADMIN_EMAIL, ADMIN_PASSWORD, SESSION_SECRET
npm run dev
A local MongoDB works: docker run -d -p 27017:27017 mongo and MONGODB_URI=mongodb://127.0.0.1:27017/mailcheap.
SENDING_ENABLED=true. When the SES keys are empty, the AWS SDK falls back to your default AWS credentials (~/.aws), so the domains page can show real data.localhost. To try a full send locally, run the QStash dev server (npx @upstash/qstash-cli dev) and use the URL, token and signing keys it prints.npm test runs the unit tests and npm run lint runs ESLint.mail.example.com), so a bad week doesn't affect your main domain's email.| Variable | Required | What it does |
|---|---|---|
MONGODB_URI | Yes | MongoDB connection string with a database name |
ADMIN_EMAIL | Yes | Dashboard login, and where test emails and alerts go |
ADMIN_PASSWORD | Yes | Dashboard password, 12+ characters. Changing it signs out every session. |
SESSION_SECRET | Yes | Signs sessions and email links. 32+ random characters. Changing it breaks links in sent emails. |
APP_URL | On other hosts | Public URL of the app. On Vercel it defaults to the production domain. |
SENDING_ENABLED | To send | Nothing is sent unless it's true |
SES_REGION | To send | SES region, us-east-1 by default |
SES_ACCESS_KEY_ID, SES_SECRET_ACCESS_KEY | To send | IAM user keys |
SES_CONFIGURATION_SET | To send | Configuration set that publishes events to SNS |
SES_SNS_TOPIC_ARN | To send | SNS topic Mailcheap accepts at /api/webhooks/ses |
QSTASH_TOKEN, QSTASH_CURRENT_SIGNING_KEY, QSTASH_NEXT_SIGNING_KEY | To send campaigns | Background sending and scheduling |
QSTASH_URL | No | QStash region other than the default, or a local QStash dev server |
SEND_RATE_PER_SECOND | No | Emails per second, 1 to 14 (default 10). Use 6 on a free Atlas cluster. |
ARCHIVE_HOSTS | No | Archive domains, like news.example.com=weekly,letters.example.org=monthly |
UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN | No | Shared rate limits |
BLOB_READ_WRITE_TOKEN | No | Image uploads in the editor (set by Vercel Blob) |
ALERT_FROM | No | From address for alert emails, on a verified domain |
.env.example has the same list with more detail.
Create a key on the API keys page and send it as Authorization: Bearer <key>.
Add a contact: POST /api/v1/contacts
curl -X POST https://<your-app>/api/v1/contacts \
-H "Authorization: Bearer $MAILCHEAP_API_KEY" \
-H "Content-Type: application/json" \
-d '{"newsletter":"weekly","email":"jane@example.com","source":"website","ip":"203.0.113.7"}'
newsletter (slug) and email are required. source labels where the signup came from.resubscribe: true brings back someone who unsubscribed (never someone who bounced or complained).ip to get the per-visitor limit of 5 signups per 10 minutes. Each key is also limited to 600 signups an hour.201 for a new contact, 200 if it already existed, with { id, status, created }. When the newsletter confirms signups, status is pending and inbox links to the reader's email provider for your "check your inbox" message.Create a draft: POST /api/v1/campaigns with newsletter, subject, previewText (optional), html or markdown, and archiveSlug (optional). Answers 201 with the draft's id and dashboard URL. Sending stays a click in the dashboard.
List sent issues: GET /api/v1/campaigns?newsletter=<slug> returns each issue's id, subject, preview text, archive URL and send date.
SEND_RATE_PER_SECOND and pauses the campaign if bounce or complaint rates pass the limits./api/webhooks/ses. Mailcheap verifies the SNS signature, updates the message, the campaign stats and the contact, and suppresses bounced and complaining addresses for every newsletter./o/... and /c/... on your app's domain. Clicks within 30 seconds of the send, or from known link scanners, don't count.There's none: issues are off and pull requests may not be reviewed. This is a one-time release, with no updates planned. See SECURITY.md for what's built in.