# Saasmail

> Self-hosted email server for SaaS teams on Cloudflare Workers with WebMCP support

- **Type:** MCP server
- **Install:** `agentstack add mcp-choyiny-saasmail`
- **Verified:** Yes — security-reviewed for prompt injection and unsafe behavior
- **Seller:** [choyiny](https://agentstack.voostack.com/s/choyiny)
- **Installs:** 0
- **Category:** [Cloud & Infrastructure](https://agentstack.voostack.com/c/cloud-infrastructure)
- **Latest version:** 0.1.0
- **License:** Apache-2.0
- **Upstream author:** [choyiny](https://github.com/choyiny)
- **Source:** https://github.com/choyiny/saasmail
- **Website:** https://xyspace.dev/saasmail

## Install

```sh
agentstack add mcp-choyiny-saasmail
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## 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](https://nodejs.org/) 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:

```bash
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_email` binding (`EMAIL`) in `wrangler.jsonc` and onboard your domain at [Email Service](https://dash.cloudflare.com/?to=/:account/email-service).
- **Resend** — set `RESEND_API_KEY` as a secret.
- **Bavimail** — set `BAVIMAIL_API_KEY` and `BAVIMAIL_ALIAS_ID` as secrets. The alias ID identifies the sending alias configured in your Bavimail dashboard.
- **Postmark** — set `POSTMARK_API_KEY` as 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@` and `newsletters@`, 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` / `Cc` and 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 `Cc` recipients 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            |

```html
{{#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-Unsubscribe` and `List-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/unsubscribe` on JavaScript mount (so URL-preview crawlers don't trigger it) and offers a "Re-subscribe" button. One-click unsubscribe (RFC 8058) also works via `POST /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: true` in the `POST /api/send` body. 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](https://modelcontextprotocol.io) 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**

```bash
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](https://github.com/choyiny)
- **Source:** [choyiny/saasmail](https://github.com/choyiny/saasmail)
- **License:** Apache-2.0
- **Homepage:** https://xyspace.dev/saasmail

Install and usage instructions live in the source repository linked above.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** yes
- **Filesystem access:** no
- **Shell / process execution:** no
- **Environment & secrets:** no
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: passed — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/mcp-choyiny-saasmail
- Seller: https://agentstack.voostack.com/s/choyiny
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
