Skip to content

Security: marclou/mailcheap

SECURITY.md

Security

Reporting a vulnerability

Report it privately on GitHub: open the Security tab and click Report a vulnerability. Please don't open a public issue for a security report.

Accounts and data

  • Every customer document carries its accountId, and a Mongoose plugin rejects any query, aggregation or bulk write on customer models without one. Cross-account lookups are explicit ({ crossAccount: true }) and limited to signed links, tracking links, public archive slugs, the API key lookup, an admin taking an address off the never-email list, which finds the contacts it stopped in every account, the campaigns and newsletters Gmail's feedback loop names, whose account it reads, and on MailCheap Cloud the daily totals of its public deliverability page, which keep only sums and counts across accounts.
  • Integration tests (tests/isolation.test.mjs, tests/api.test.mjs, tests/mcp.test.mjs) sign in two accounts and check that neither can list, read, change or delete the other's newsletters, contacts, campaigns, API keys or uploads, or sign contacts up into the other's newsletter, through server actions, data functions, the REST API and MCP.
  • The dashboard shows one newsletter at a time, named by a cookie that is never trusted: every read checks it against the account and falls back to the account's first newsletter, and the newsletter menu and /newsletters/<id> only set it to one of the account's newsletters. Server actions that take a newsletter (new campaigns, imports, exports) check the ID against the account too (tests/newsletters.test.mjs).
  • Uploads are stored under accounts/<accountId>/, a path that comes from the account, never from the request. On S3 the bucket is private: readers get images through the CDN, and the app's IAM user can only put, get and delete objects under accounts/ (deletes only for a deleted account's purge and for an upload whose record wasn't saved) and create invalidations on the CDN's distribution, with keys of its own, never default AWS credentials.

API keys, REST and MCP

  • The dashboard, the REST API and the MCP server run the same actions (lib/actions), so the same permission, plan, rate limit and input checks apply everywhere. A test fails if a dashboard server action does anything but run a registered action, apart from the newsletter menu's, which only sets which of the account's newsletters the dashboard shows.
  • Only the dashboard's Add contact skips confirmation: the contact is active at once when the signed-in user ticks that the person agreed, and the server refuses it otherwise. The REST API and MCP don't take that box, so their signups confirm when the newsletter asks.
  • API keys are 32 random bytes, stored as SHA-256 hashes and shown once. They belong to one account and only reach its data. Revoking one takes effect on the next request.
  • Two levels: full access, and signups only, which can only add contacts. Keys made before levels count as signups only. Only a full access key can create keys, and an account has up to 20 active ones.
  • Each key gets 300 requests a minute across REST and MCP, plus an hourly limit on actions that send, import, upload, add domains, create keys or call Stripe. The account gets each hourly limit once too, shared by all its keys and connected apps, so more keys or apps don't raise it.
  • The dashboard's calls count against the account's hourly limits on what costs something each time: test emails and sends, imports (a file counts once, whatever its chunks), image uploads, adding and checking domains, and Stripe. Test emails only go to the account owner's own address.
  • Signups, which send the welcome or confirmation email to any address, take 600 an hour per key, and 600 an hour per account across its keys, connected apps and contacts added by hand. Sites that pass their visitor's IP get 5 per visitor in 10 minutes.
  • A newsletter's slug changes 3 times a day per account, on every channel: each change moves its shared address at the email provider and in DNS, out of budgets every account shares.
  • Only the signup endpoint answers browsers (CORS), so a full access key in a web page doesn't work from it.
  • Errors outside the documented codes answer internal_error without detail; the detail goes to the server log.
  • The MCP send tools are annotated destructive and say sending can't be undone. Sending a campaign that is already sending or sent answers conflict.
  • OAuth (ChatGPT, Claude): Better Auth issues access tokens signed RS256 with a key it keeps encrypted with BETTER_AUTH_SECRET. /mcp accepts a token only with MailCheap's issuer, /mcp as its audience, the at+jwt type, an unexpired exp, the mailcheap:full scope, a valid DPoP proof when it's bound to a key, and the id of a consent that still exists for its user and app. Tokens last an hour; refresh tokens are stored hashed, rotate on use, and expire 90 days after the last use.
  • An app gets the same access as a full access key, in its user's account only, after the user signs in and allows it on the consent page, which names the app's host. PKCE (S256) is required, redirect URIs must match the app's client metadata document or registration exactly (loopback ports aside), and responses carry the issuer (RFC 9207). Disconnecting an app on the API keys page deletes its tokens and consent; its access tokens answer 401 from the next request.
  • Client metadata documents are fetched over https with the address resolved once and pinned, private and reserved addresses refused, redirects refused, 5 KB at most. Better Auth's client and consent management routes are closed.
  • Dynamic client registration (RFC 7591, for Grok Bot on Cursor's agents) takes public clients only (token_endpoint_auth_method none) whose redirect URIs are all on the allowlist in lib/oauth-rules.js, without a back-channel logout URI, 10 an hour per IP. Registered clients are bound to /mcp and go through the same consent page.
  • Contacts exports are signed links (a key derived from BETTER_AUTH_SECRET) that name the account and the filters and expire after an hour. Anyone holding a link can download the file until then.
  • Images uploaded by URL are downloaded over https on port 443 only, and the connection is refused when the host resolves to a loopback, private, link-local, carrier-grade NAT, documentation, multicast or reserved address, checked at connect time and after each of up to 3 redirects. Size is capped at 4 MB while reading.
  • A from address is either the newsletter's shared address (hello@<slug>.SHARED_DOMAIN) or on a domain the account verified; it's checked when a newsletter is saved, before every send and again for each email. A domain and a shared sending subdomain belong to one account, and at the provider to that account's tenant only.
  • A newsletter's website domain is live on one newsletter at a time, and only goes live once its CNAME and a TXT record derived from the account and the host (an HMAC keyed from BETTER_AUTH_SECRET) are found, so a CNAME someone left pointing at MailCheap isn't enough to claim their subdomain. A sending domain never moves a website. A live domain stays live until it's removed: its next emails' links don't change on a DNS hiccup.
  • Provider events are recorded only in the account they name: SES's account_id tag; PostShiba's account_id unique arg, which has to match the PostShiba tenant on the event, or, without unique args, that tenant's account. An event whose tenant isn't the named account's is dropped. CSV imports and contacts added by hand record the customer's consent, who gave it and when.

Sign-in

  • Better Auth sessions in httpOnly cookies, stored in the database, so signing out or revoking a session takes effect at once.
  • Session cookies are host-only (no Domain), and Better Auth trusts APP_URL's origin only. When the shared domain is the app's own (MailCheap Cloud: archives on <slug>.mailcheap.ai), archive hosts never get the cookie, but browsers still send it with an archive page's requests to the app, the same site. Better Auth refuses those from any other origin, server actions check the origin against the host, uploads take same-origin requests only, and archive pages run no customer script and post forms only to themselves. Slugs can't take www or MailCheap's other subdomains (lib/reserved-slugs.js), and those hosts never open an archive.
  • Sign-in codes are 6 digits, expire after 5 minutes, die after 3 wrong tries and are stored hashed. Sending them is rate limited per IP and per inbox (an address's dots and +tags count as one where its provider ignores them), checking them per IP. Better Auth's own code and password routes are closed.
  • A Google sign-in opens a new account, or joins an existing user, only when Google says it verified that email: Google isn't a trusted provider for account linking.
  • MailCheap Cloud takes one account per inbox: a new user whose address reaches an existing user's inbox (Gmail ignores dots and +tags; Outlook, Hotmail, Live, iCloud, Fastmail and Proton +tags; Yahoo -tags, in lib/email-aliases.js) is refused once the code or Google proved the address, so the refusal tells nobody else anything. Throwaway domains get no code and no new account, and an IP address gets 3 new accounts a day. Accounts from before keep signing in.
  • Without RESEND_API_KEY or an email provider, development prints codes in the server console; production never does.
  • After sign-in, the app only goes on to a path on its own site with at most a plain query string (/billing/checkout?plan=growth&interval=year): never another host or a protocol-relative path.
  • /billing/checkout (the plan buttons) only opens Checkout for a signed-in account without a plan. A paying account gets the billing page, where Stripe confirms any change.
  • An account without a plan can set up its newsletter and write campaigns, nothing that reaches outside MailCheap: no emails (tests and signup emails included), imports, domains or API keys, nothing at the email provider or in DNS until its plan starts, and 20 image uploads and 3 contacts added by hand a day.
  • /admin and its actions answer 404 to anyone whose verified email isn't in ADMIN_EMAILS.
  • The server refuses to start with a BETTER_AUTH_SECRET under 32 characters.

Deleting an account

  • Only its owner deletes it, on the account page: it isn't an action, so no API key or connected app can, and a test fails if one appears. Better Auth's user update route is closed too: the page changes the name, and a photo only to one of the account's uploads.
  • It's refused while Stripe bills a plan (active, trialing or past due), which the billing portal cancels, and while a campaign sends or is scheduled.
  • It takes a code sent for it, of a kind no sign-in uses (Better Auth's email OTP, stored hashed, 5 minutes, one use, 3 tries, 3 codes per 15 minutes), and the account's email typed out. The email says what the code is for, so an owner who didn't ask knows.
  • At once, every session of the owner ends, API keys are revoked, connected apps lose their consents and tokens, and the account counts as unpaid everywhere, self-hosted too: no emails, signups or archive, and the REST API, MCP and the dashboard refuse it. Its tenant is suspended at the provider.
  • The purge runs 30 days later as a signed QStash job. Signing in before then offers to restore the account; once the purge starts, it can't be. Every query it runs names the account, so it can't reach another one. It deletes the user with its sessions, sign-in methods and codes, everything with the account's id, its domains and shared subdomains at the provider with the DNS records published for them, its tenant, and its images on S3 and Vercel Blob, then invalidates the account's folder on CloudFront so the CDN stops serving them.
  • What stays: Stripe's own billing records, and a deletion record with an HMAC of the email (a key derived from BETTER_AUTH_SECRET), the account's id and the dates, nothing else. A new account with that email alerts the admins. The never-email list keeps the entries its emails added: they name no account.

Self-hosted servers

Without MAILCHEAP_CLOUD=true (only that exact value counts), a server is self-hosted:

  • Only ALLOWED_EMAILS (addresses, and @domain for every address of exactly that domain) and ADMIN_EMAILS sign in. With both empty, nobody does: it's never open by default.
  • The list is checked when a code is asked for and when it's checked, and Better Auth's user and session hooks refuse anyone off it whatever the route (codes, Google, the auth API), so no new user or session can exist for them. The email also has to be verified (by the code, or by Google).
  • Someone taken off the list is signed out at their next request, and their API keys stop working on the REST API and MCP, until they're back on it.
  • There's no billing: no paywall, no plan limits, and the billing actions, the billing page and the Stripe webhook answer that billing is off. The automatic blocks on spam signals stay on.
  • MailCheap Cloud's marketing pages, which the open source doesn't have, answer 404. The public docs footer keeps a link to the source code, which the AGPL asks a modified server to offer its users.

MailCheap's own emails

  • Sign-in codes and alerts go through Resend, apart from customers' emails. On MailCheap Cloud they never go any other way. A self-hosted server without RESEND_API_KEY sends them through its email provider from EMAIL_FROM: on SES outside every tenant, on PostShiba through a tenant of their own (mc-platform), so a customer's tenant never carries them.
  • SENDING_ENABLED holds readers' emails only: sign-in codes and alerts don't wait for it.
  • Admin alerts that need someone (lib/alert-rules.js) also go to Telegram when TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID are both set: in Telegram's HTML with everything but the app's name escaped, each at most once every 6 hours, and never in the way of the request that raised them. The token is part of the Bot API's address, so no error or log line names it.

Billing

  • MailCheap Cloud refuses to start without STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET and RESEND_API_KEY, and /api/health answers 503 with which one is missing (names, never values), so the cloud can't run free or without sign-in emails by mistake.
  • Stripe webhooks are verified with STRIPE_WEBHOOK_SECRET, and every subscription event re-reads the subscription from Stripe instead of trusting the payload.
  • The checkout return only syncs a session whose client_reference_id is the signed-in account.
  • Checkout refuses a Stripe price whose amount or interval doesn't match lib/plans.js, and a plan isn't offered or accepted until Stripe has both of its prices at those amounts.
  • Every email counts toward the plan's emails for the month: issues, last-chance, tests, welcome and confirmation emails. Campaign and welcome emails take their room in one atomic update before they go out (claimPlanEmails in lib/usage.js), with the plan's numbers, a free plan's or an admin's overrides, so signups (600 an hour per account) can't send welcome emails past the plan. Without room, a welcome email is skipped for good, its Message records why, and the owner is emailed once that month; welcome emails start again when the month resets or the plan grows. Confirmation emails always go out, so signups never break, and still count. A self-hosted server has no limits.

Sending

  • SES only runs with MailCheap's own keys (SES_ACCESS_KEY_ID, SES_SECRET_ACCESS_KEY), never with default AWS credentials from the machine. EMAIL_PROVIDER with an unknown value stops sending instead of picking a provider.
  • Each account has its own tenant at the provider. PostShiba's SMTP password for a tenant (shown once) is stored AES-256-GCM encrypted with CREDENTIALS_ENCRYPTION_KEY, a random IV per value and the account and cluster authenticated with it, so a value copied onto another account doesn't decrypt. It's left out of every read of the account but the PostShiba adapter's (which decrypts it for campaign sends), never sent to a browser and never logged; a separate transactional cluster's password isn't kept at all.
  • Bounce and complaint rates have two levels (LIMITS in lib/config.js, judged out of emails sent in lib/rate-levels.js), each with a minimum count, so one complaint never stops anything. Over 2% hard bounces with at least 5, or 0.08% complaints with at least 2, a campaign pauses and its owner is emailed; they can resume it, and from then on only the block level stops that campaign. A first send's slice at that level holds the rest of its campaign the same way; the last 7 days at that level only warn the owner, once a day at most. Gmail's spam rate for an account at 0.08% in Google Postmaster Tools pauses its sending campaigns the same way, or warns its owner when none sends.
  • Clear abuse blocks the whole account (lib/account-blocks.js): over 5% hard bounces with at least 10, or 0.3% complaints with at least 3, on a campaign, a first send's slice or the last 7 days; Gmail's spam rate for the account at 0.3% in Google Postmaster Tools; a hard bounce that names a spam trap; SES disabling the tenant or a high-impact finding; PostShiba's operator_hold; and an admin. A blocked account sends nothing, welcome and confirmation emails included, on any plan; its tenant is suspended at the provider, its archive takes no signups, and the dashboard, REST API and MCP refuse every change with account_blocked except pausing, unscheduling, discarding, revoking keys and apps, and billing. Its emails' tracked links stop redirecting: they answer 403 with a page saying the link is disabled, without the destination, noindex and no-store, so unblocking brings them back on the next click. Its opens and clicks aren't counted, and unsubscribe links, one-click unsubscribe and pending readers' confirmation links keep working. Every block emails the admins at once. Nothing lifts a block on its own, a provider lifting its pause included: only an admin, from /admin, once the provider resumed the tenant.
  • Gmail reports no single complaint, only spam rates, in Google Postmaster Tools, which MailCheap watches. Every email carries a Feedback-ID of its campaign (or, for signup emails, its newsletter), its account as mc-<accountId>, its kind and mailcheap: no address, nothing unique to one email. A daily job (/api/cron/postmaster, only with CRON_SECRET) reads the domain's spam rate and the feedback IDs Gmail flags, counted by DKIM signer, maps each to its account and acts on each day of data once: from 0.08% the account's sending campaigns pause, at 0.3% it's blocked, never twice and never for the emails of the days before an admin unblocked it. The domain at 0.1%, or an ID that names no account, alerts the admins.
  • Postmaster Tools is connected from /admin only (404 for anyone else), through Google's OAuth with the read-only traffic scope (postmaster.traffic.readonly), PKCE, and a state in a __Host- cookie only the admin's browser holds, for 10 minutes. The refresh token is stored AES-256-GCM encrypted with CREDENTIALS_ENCRYPTION_KEY, left out of every read but the job's, never sent to a browser or logged. Google refusing it alerts the admins once until it works again. Without Google's client, the key or a connection, the job does nothing.
  • On MailCheap Cloud, how fast a send goes out is earned by clean volume, not by a number of campaigns or time alone (WARMUP in lib/config.js, lib/send-spread.js). An account's proven volume is its largest campaign of the last 60 days, steps and last-chance emails included, whose bounces and complaints stayed under the pause level, counted only once its last email is a day old. A send, counted with what the account's other sends still have to send or sent in the last 24 hours (so a send split into small ones, or sent beside others, gets the same pace), goes at full speed up to 200 emails, or up to its proven volume times 2 on the first day of the account's 30-day warm-up (from its first campaign), rising each day to 5 on its last and after it. A bigger send goes out over 4 hours plus 4 for each time it doubles that, 72 at most, its batches spread evenly to the end. A resume keeps its send's pace, and each step of a send in steps gets its own. Pacing never refuses a send: plan limits, pauses and blocks apply as before. An admin can end the warm-up early, which leaves the 5 times rule, or start it again.
  • Contacts the account never emailed go in a slice of 5% (200 to 2,000), and the rest waits 3 hours for the slice's bounces and complaints (FIRST_SEND in lib/config.js, lib/first-send.js), during the warm-up and after it. In a paced send, the held rest goes out over the rest of its hours.
  • Every address takes one syntax: a dot-atom, no doubled dots, a domain ending of letters or xn--. Imports drop misspelled provider domains (lib/email-typos.js) and spam trap addresses; the archive's form, add_contact and MCP refuse them, and domains that can't receive email, with the address a misspelling was likely meant to be.
  • Every account sends through the same providers, so one never-email list covers all of a server's accounts (lib/global-suppressions.js). On it: a hard bounce PostShiba classifies Invalid Address (the mailbox or its domain doesn't exist, or it moved; never a full or disabled mailbox, a refusal, a 4.x.x code or an expired message), SES's NoEmail and Suppressed hard bounces, and a hard bounce that names a spam trap, each only for the address an email of MailCheap's went to, so an account's events can't list an address it never emailed. Hard bounces leave after 180 days through a TTL index, spam traps never. Complaints, soft bounces and imported statuses stay their account's own.
  • The list keeps an HMAC-SHA256 of each address, keyed from SUPPRESSION_HASH_KEY alone (never BETTER_AUTH_SECRET), of the address as delivered: lowercased, a +tag dropped where the provider delivers it to the base address, Gmail's dots and Yahoo's -tags kept. Next to it only the reason, the provider's type and status code (never its words, which can quote the address) and the dates; nothing names an account or a newsletter. The server refuses to start without a key of 32 characters or more.
  • Imports leave listed addresses out as undeliverable; add_contact, MCP, Add contact and the archive's form refuse them with "This address can't receive email", whichever account's email listed them, its own included. Each send batch checks its addresses, so a contact added before its address was listed is skipped too: its email is suppressed, and the contact becomes bounced, marked undeliverable on the Contacts page, without touching the account's own suppression list. An admin looks an address up on /admin and can take it off, which makes the contacts it stopped with that exact address active again.
  • Unsubscribe, confirmation, "keep sending it" and tracking links are signed with keys derived from BETTER_AUTH_SECRET. Rotating it signs everyone out and breaks those links in emails already sent.
  • SES webhooks only accept messages signed by AWS SNS for the topics in SES_SNS_TOPIC_ARN and SES_TENANT_SNS_TOPIC_ARN. The PostShiba webhook only accepts requests whose X-Capsule-Signature is the HMAC-SHA256 of {timestamp}.{raw body} with POSTSHIBA_WEBHOOK_SECRET, compared in constant time, with a timestamp within 5 minutes; redelivered events are recorded once. Background jobs only run when QStash signed the request with the server's own signing keys, whatever QSTASH_DEV says (except in next dev); any other request answers 403. The hourly check (/api/cron/hourly) only runs with Authorization: Bearer CRON_SECRET, compared in constant time, and answers 503 without a CRON_SECRET.
  • The app never holds a Vercel token. On MailCheap Cloud, shared subdomains' records go through a DNS helper (dns-broker/), a Vercel project of its own and the only place with the token. It takes requests signed with DNS_BROKER_SECRET (HMAC-SHA256 of {timestamp}.{body}, compared in constant time, 5 minutes at most), has mailcheap.ai, the team and the list, create and delete record endpoints fixed in its code, and only writes a shared address's records under <slug>.mailcheap.ai for a slug that isn't MailCheap's: the archive's CNAME to cname.vercel-dns.com, PostShiba's DKIM CNAME to *.customers.postshiba.com and return path CNAME to rp.postshiba.com. It tags each record for its slug, deletes only those, never writes in a slug that has a record it didn't tag, stops at 900 new records a day, and logs every create, delete and refusal without the token, the secret or headers. When it refuses or fails to publish a shared address's records, the app takes the address's domain back at the provider, so nothing stays there that the newsletter doesn't name; the next save or send tries again. It also puts website domains on the app's project (mailcheap, fixed in its code) and takes them off: only a subdomain that isn't MailCheap's or Vercel's, never one another project has, 100 new ones a day at most, and the app only asks once the domain's TXT record is found. A host a newsletter left stays on the project, so the links in the emails sent on it keep working there (its pages answer 404); the account's purge takes it off unless another account's newsletter has it.
  • Public endpoints (signup form, signup API, unsubscribe, views, sign-in) are rate limited. With Upstash Redis the limits are shared across server instances; without it, each instance counts in memory.
  • Issue HTML shown on the archive goes through an allowlist cleaner and sanitize-html on every render; HTML issues render in a sandboxed iframe without scripts.
  • Every response carries a Content Security Policy (no framing, no plugins, no inline event handlers, forms and <base> limited to the app's origin) and HSTS. Click tracking only redirects to links signed by the app, and never for a blocked account.

Known gaps

  • The first account to add a domain holds it until it removes it, verified or not; its real owner has to ask support.
  • On PostShiba, Gmail sees PostShiba's own Feedback-ID (mc-<accountId>:…:postshiba) in place of MailCheap's, so its feedback loop reports accounts, not campaigns.
  • Gmail's data comes a day or two late, only for domains that send to at least 50 Gmail users a day, and only for feedback IDs with enough emails and spam reports: a small account's complaints at Gmail stay out of sight.
  • An API key can't be limited to read-only or to one newsletter yet; a full access key can do anything the owner can, billing links included.
  • Invalid keys aren't rate limited by IP before their database lookup (a unique index on the hash, so each costs one indexed read).
  • Tenants separate suppression lists and provider pauses, but capacity and IP reputation are still shared: SES's account quota and reputation, and on PostShiba each cluster's shared IP and hourly cap, used by every account on it.
  • Vercel tokens can't be limited to DNS (a project-scoped token can't edit it): the helper's token can do anything in the team, and only the helper's code and its own project keep it to mailcheap.ai's records and the app project's domains. Anyone with DNS_BROKER_SECRET can create or delete shared address records within those rules, up to the daily cap, and put other subdomains on the app's project, 100 a day at most, or take a customer's off.
  • On a self-hosted server, website domains aren't put on the Vercel project for them: that's done by hand, before they go live.
  • A website domain stays with the newsletter it went live on until it's removed there, even once its domain changes hands; its new owner has to ask support.
  • When a slug changes, or a new slug's records can't be published, a sending subdomain the provider fails to remove stays in the account's tenant (an admin alert says so) and blocks the slug's next owner from its shared address until it's removed.
  • The DNS helper's 900 new records a day are shared by every account: about 300 new shared addresses a day. Past them, new ones are refused until the oldest records are a day old, then set up at their newsletter's next save or send (an admin alert names each one).
  • On a self-hosted server without a shared domain or a website domain, the archive is served from the app's own domain, where only the sanitizer, the sandboxed frame and the Content Security Policy keep issue HTML away from the dashboard.
  • Sign-in tells whether an email is on a self-hosted server's list. The per-IP and per-email limits slow down anyone trying addresses.
  • Signups tell whether an address is on the never-email list, in the same words whichever account's email put it there and whether it bounced or hit a trap. The signup limits per key, account and visitor slow down anyone trying addresses.
  • A hard bounce the provider misclassifies stops its address on every account for up to 180 days, until an admin takes it off; contacts it stopped under another +tag of the address stay bounced.
  • Deleted contacts' hashes (lib/contacts.js) are still keyed from BETTER_AUTH_SECRET: moving them to a key of their own needs the addresses, which aren't kept.

There aren't any published security advisories