Install
$ agentstack add mcp-choyiny-saasmail ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ● Network access Used
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
The centralized inbox for SaaS teams. One unified timeline per customer — marketing, notifications, and support emails collapsed into a single view, per person.
Every interaction with a customer matters, and context compounds. saasmail pulls the promo blast, the billing receipt, and the support thread into the same conversation, so anyone on your team can respond with the full history already in hand.
Self-hosted on Cloudflare Workers. Receive with Cloudflare Email Workers. Send with Cloudflare Email Sending, Resend, Bavimail, or Postmark.
Who this is for
SaaS teams that want a self-hosted email stack on Cloudflare Workers — one shared, per-customer inbox for marketing, transactional, and support mail — without renting a VM or operating a traditional mail server. If you have a domain, a Cloudflare account, and want to own your customer email data for [~$5/month](#how-much-does-it-cost), this is for you.
Quickstart
Prerequisites: a domain on Cloudflare with Email Routing available, the Workers Paid plan, and Node.js v18+.
The fastest path is the Claude Code onboarding skill — it provisions every Cloudflare resource, fills out your config, runs migrations, and deploys for you:
git clone https://github.com/choyiny/saasmail.git
cd saasmail
claude # then run /saasmail-onboarding
First successful result: your worker is live at your domain, and visiting it prompts you to create the first admin account. Name an inbox, send yourself a test email, and watch it land on a customer timeline.
Prefer to wire it up by hand? See [Full setup](#full-setup) below (~8 steps).
Architecture at a glance
Inbound customer ─▶ Cloudflare Email Routing ─▶ saasmail Worker ─▶ D1 · R2 · Queue
Outbound saasmail Worker ─▶ Email Sending / Resend / Bavimail / Postmark ─▶ customer
Everything runs inside a single Cloudflare Worker — no separate mail server to operate. See [Architecture](#architecture) for the full diagram and the component-by-component breakdown.
Sponsors
GiveFeedback.dev uses AI to turn client screen recordings into actionable tasks and prevent scope creep.
Demo Video
https://github.com/user-attachments/assets/fe3a3811-1902-4b0b-8b94-f8c72f1afab4
Provider Matrix
| | Cloudflare | Resend | Bavimail | Postmark | | ------------- | ---------- | ------ | -------- | -------- | | Sending | ✅ | ✅ | ✅ | ✅ | | Receiving | ✅ | ❌ | ❌ | ❌ |
Pick one outbound provider at deploy time:
- Cloudflare Email Sending — add a
send_emailbinding (EMAIL) inwrangler.jsoncand onboard your domain at Email Service. - Resend — set
RESEND_API_KEYas a secret. - Bavimail — set
BAVIMAIL_API_KEYandBAVIMAIL_ALIAS_IDas secrets. The alias ID identifies the sending alias configured in your Bavimail dashboard. - Postmark — set
POSTMARK_API_KEYas a secret (your Postmark server's API token). Verify each send-from domain in the Postmark dashboard.
Selection precedence at runtime: Bavimail (when both env vars are set) > Postmark (when POSTMARK_API_KEY is set) > Resend (when RESEND_API_KEY is set) > Cloudflare Email Sending (when the EMAIL binding exists). If none are configured, send attempts return a "No email provider configured" error.
How much does it cost?
$5/month for the Cloudflare Workers Paid plan, which includes 3,000 emails per month of Cloudflare Email Sending at no extra cost. That's it.
No VM to rent. No sprawling cloud console to learn. Just a domain, a Cloudflare account, and the Workers Paid plan.
Features
One Timeline Per Customer
Every email from a given person — marketing campaigns, transactional notifications, support replies — lands on a single timeline. People are sorted by recency with unread counts, so the customer who needs attention is always on top. Click in to see the latest message, and open the thread sidebar to replay the full history. Messages render as sanitized HTML with a Slack-style reply composer.
Multi-Inbox with Team Permissions
Run multiple inbound addresses from a single deployment. Admins configure display names per inbox (support@, sales@, etc.) and assign members to specific inboxes. Members only see email, templates, and sequences scoped to the inboxes they're allowed to access.
Thread or Chat, Per Inbox
Different inboxes call for different UX. Set each inbox to render as Thread or Chat:
- Thread — traditional email threading with subject lines, quoted history, and formatted HTML. The right fit for
marketing@andnewsletters@, where context lives inside the message. - Chat — bubble-style conversation view that strips away subjects and signatures so replies feel like iMessage. The right fit for
support@, where customers expect a back-and-forth, not a formal thread.
One deployment, one person timeline, but the interaction model matches the channel.
Per-Inbox Forwarding
Give any inbox a Forward to address and every message it receives is re-sent to that address. Configured per inbox on the Inboxes page, right next to display name, signature, mode, and member permissions. Off by default.
Why not just use a Cloudflare Email Routing forwarding rule? Because Email Routing relays forwarded mail from a shared IP pool that Outlook, Hotmail, and Live blocklist. Forwards to a Microsoft-hosted mailbox come back as:
permanent error (550): 5.7.1 Unfortunately, messages from [104.30.10.66] weren't
sent. Please contact your Internet service provider since part of their network is
on our block list (S3150).
That IP belongs to Cloudflare, not to you, so there is no delisting path. saasmail sidesteps it by sending the copy itself through your configured outbound provider — different IPs, and DKIM-signed for your own domain, so it authenticates cleanly.
How the forwarded copy looks:
- From the inbox address, with the original sender named in the display name
("Jane Customer (via Acme Support)" ). It cannot keep the original From: — sending as jane@example.com from your infrastructure would fail SPF and DMARC and get filtered harder than the block being avoided.
- Reply-To the original sender, so replying reaches the customer.
- Original
From/Date/Subject/Ccand the SPF/DKIM/DMARC verdicts are
restated in a header block at the top of the body.
- Attachments are included, up to your provider's size ceiling; anything too large
is named in the body rather than silently dropped. Inline images arrive as regular attachments.
- The original
Ccrecipients are not re-sent to — only the destination is.
Forwarding is best-effort and never blocks inbound mail: it runs after the message is safely stored, and after the blocklist and duplicate checks, so blocked senders and duplicate deliveries are never forwarded. There is no retry — failures are logged. Loops are prevented three ways: an inbox can't forward to itself, can't forward to another inbox on the same instance, and any message already carrying the X-SaaSMail-Forwarded-For header is never forwarded again.
Email Templates
Create reusable HTML email templates with {{variable}} interpolation. Edit templates with a live HTML editor, preview rendered output, and send them via the API or the UI. Top-level variables are automatically extracted and validated before sending — a send that omits one is rejected with 400 rather than mailing a half-rendered template. Templates are scoped to allowed inboxes.
Validation covers top-level names only. Names used inside a {{#section}} body are not validated, because they resolve against the current item at render time rather than against what the caller passed. An unresolved name inside a section renders empty; only the section's own name is required.
GET /api/email-templates/{slug}/variables returns three lists:
variables— top-level names the caller must supply, or the send fails.
This is the send contract; its shape and meaning are unchanged.
optional— names that render empty when absent:{{key?}}tags and
inverted ({{^key}}) section names.
sections— each section's name, whether it's inverted, and the names its
body references. Those body names resolve per item at render time and are never part of variables, even though the response now surfaces them for the editor and API callers building a form around a template.
Template syntax
| Tag | Behavior | | -------------------- | ---------------------------------------------------------- | | {{key}} | Value, HTML-escaped in the body; plain text in the subject | | {{{key}}} | Value, raw — for pre-rendered HTML | | {{key?}} | Optional; renders empty instead of failing the send | | {{key\|nl2br}} | Escaped, then newlines become ` | | {{#key}}…{{/key}} | Renders if truthy; iterates arrays | | {{#key?}}…{{/key}} | Same as {{#key}}, but doesn't fail the send if missing | | {{^key}}…{{/key}} | Renders if falsy or empty | | {{.}}` | Current item inside an array-of-strings section |
{{#items}}
{{name}}
{{currency}}{{price}}
{{/items}} {{^items}}
Nothing to show yet.
{{/items}}
Names inside a section resolve against the current item first, then fall back to the top level — so {{currency}} above can live outside items. A name a section body cannot resolve renders empty; it is not reported as missing, because only the section's own name (items) is a caller contract.
A tag name is a run of word characters (or a bare .), with no spaces inside the braces. Anything else — {{ spaced }}, {{user.name}}, {{not-a-var}} — is left alone as literal text, exactly as before the rewrite, so prose that happens to contain braces is never mistaken for a variable. Sections may nest up to 64 levels.
An unbalanced or mismatched section tag is a parse error: the request fails with 400 and a diagnostic naming the offending tag, rather than sending something half-formed. This affects POST /api/email-templates/{slug}/send, GET /api/email-templates/{slug}/variables, and POST /api/send/reply/{id}. A sequence step whose template does not parse is marked failed.
The variables payload itself — the JSON you POST, not the template markup — may nest objects and arrays up to 32 levels deep. A payload nested deeper than that is rejected with 400 naming the limit, rather than risking a stack overflow while validating it. This is independent of the 64-level cap on section nesting above: one bounds the data you send, the other bounds the template you write.
The template editor's UI understands this grammar too — grouping detected variables into Required, Optional, and Sections, and rendering a live preview with sample values in place of the raw tokens — so what you see while editing matches what a real send does.
Upgrading: escaping is now the default
Variables were previously substituted raw. They are now HTML-escaped in the body, so a value containing markup renders as text rather than as HTML. The subject line is a plain-text header, not HTML, so values substituted there are passed through unchanged — as they always have been.
If any of your templates deliberately pass HTML through a variable, change those tags from {{key}} to {{{key}}} before upgrading. Templates whose variables carry plain text need no change.
One related consequence: because {{{key}}} now means raw output, any run of three or more consecutive braces is read differently than before. {{{name}}} used to render as { followed by the substituted value followed by }; it is now an unescaped substitution. This only affects templates that stack braces against a tag — ordinary {{key}} tags in ordinary text are untouched.
This also applies to sequence sends, which share the same renderer. Multi-line values still collapse in HTML — use {{key|nl2br}}, or wrap the block in style="white-space: pre-line".
Email Sequencing
Build multi-step drip campaigns. Enroll a contact into a sequence and saasmail sends templated emails on a schedule. Supports step skipping, delay overrides, custom variables, and automatic cancellation when the contact replies. Enrollment is enforced against the member's allowed inboxes.
Suppressions and Unsubscribe
saasmail tracks unsubscribed and manually-suppressed recipients in a suppressions table. Suppression checks run on every outbound dispatch path: POST /api/send, scheduled sequence steps, and admin template test-sends. Admins manage the list at /admin/suppressions (CRUD also exposed at /api/suppressions).
- List-Unsubscribe headers: marketing sends automatically include
List-UnsubscribeandList-Unsubscribe-Post: List-Unsubscribe=One-Click(RFC 8058) headers so Gmail/Yahoo bulk-sender rules and major mail clients render native unsubscribe affordances. - Unsubscribe footer: templates can use
{{unsubscribe_url}}in HTML or plaintext bodies. If the rendered output doesn't include the URL, saasmail auto-appends a minimal unsubscribe footer. - Unsubscribe page: recipients land on
/unsubscribe?token=…. The page POSTs to/api/unsubscribeon JavaScript mount (so URL-preview crawlers don't trigger it) and offers a "Re-subscribe" button. One-click unsubscribe (RFC 8058) also works viaPOST /api/unsubscribe?token=…directly — no session, no UI; the token signs the recipient's email. - Transactional sends: account-critical mail (password resets, OTPs, system notifications) should pass
transactional: truein thePOST /api/sendbody. This bypasses the suppression check, skips the unsubscribe headers, and skips the footer auto-append. Anyone you genuinely need to email will still get the message.
> Behavior shift for API integrators: POST /api/send now adds List-Unsubscribe headers and (if the body lacks the URL) appends an unsubscribe footer to every send UNLESS the caller passes transactional: true. If your integration sends password resets, OTPs, or other account-critical mail through /api/send, set the flag explicitly on those calls to preserve the previous behavior.
The Worker signs unsubscribe tokens with UNSUBSCRIBE_SECRET (see [Configuration](#devvars)) and builds absolute URLs from the existing BASE_URL setting.
User Management
Admin-controlled onboarding via one-time invite links. New members sign up with email + password, and can register a passkey for passwordless login on subsequent sessions. Roles: admin (full access + user management) and member (scoped by inbox assignment).
API Keys
Issue scoped API keys for programmatic access to send email, manage templates, enroll contacts in sequences, and query inbox data. Keys are hashed at rest and follow the sk_… format.
MCP Server (AI assistant access)
Connect Claude, or any other MCP client, directly to your inbox. saasmail exposes a Model Context Protocol endpoint at /mcp over streamable HTTP, secured with OAuth 2.1.
There is nothing to pre-register. The client discovers the authorization server, registers itself (RFC 7591), and sends you through a normal browser login plus a consent screen listing exactly what it is asking for. Approve it and the client gets a scoped token.
Connecting a client
Claude Code
claude mcp add --transport http saasmail https://your-domain.com/mcp
Then run /mcp inside Claude Code and choose saasmail to finish the browser login. Add --scope user to the command to make the connection avail
…
Source & license
This open-source MCP server is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: choyiny
- Source: choyiny/saasmail
- License: Apache-2.0
- Homepage: https://xyspace.dev/saasmail
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.