DEV Community

Cover image for One domain, no mailbox seats: email for a one-person SaaS
Phuc Nguyen
Phuc Nguyen

Posted on Edited on

One domain, no mailbox seats: email for a one-person SaaS

You shipped your app over a few weekends with Cursor or Claude Code. Receipts and password resets already go out through some email API. Then the boring part shows up: you want hello@, team@, sales@ and support@ on your own domain. The usual answer is a mailbox per person in Google Workspace or Microsoft 365, plus a shared inbox tool for support, plus a forwarding rule that breaks every few months.

For a one-person (or three-person) product, most of those mailboxes only ever receive mail. You don't need a seat for that. This post shows a setup where one domain does four jobs:

  1. Sends transactional email from your app (a Resend-compatible API, or SMTP).
  2. Receives mail for the whole domain and hands it to your code as a webhook.
  3. Forwards team@, sales@, billing@ to everyone's personal inbox with email groups.
  4. Puts support@ straight into a support inbox, next to live chat, WhatsApp and Messenger, where replies go out from support@ itself.

The examples use Ha Tang (I work on it). Every step comes with the prompt to give your coding agent, the code it should produce, and what to double-check.

The setup in one picture

                ┌── POST /emails (API or SMTP) ── receipts, resets, invoices
yourapp.com ────┤
                └── MX → Ha Tang ─┬── support@   → Support inbox (chat, WhatsApp, Messenger, email in one place)
                                  ├── team@, sales@, billing@ → email groups → members' own Gmail/Outlook
                                  └── anything else → email.received webhook → your code
Enter fullscreen mode Exit fullscreen mode

Step 0: give your agent the real docs

Agents get email integrations wrong mostly by guessing field names. Point them at the docs instead:

# Claude Code: an MCP server that searches and reads the docs (no key needed for that)
claude mcp add hatang -- npx -y @hatang/mcp

# Any agent that reads AGENTS.md (Codex, Copilot, Cursor, Claude Code via CLAUDE.md)
curl -s https://hatang.dev/docs/agents/AGENTS.md >> AGENTS.md
Enter fullscreen mode Exit fullscreen mode

In Cursor, add the same server to .cursor/mcp.json, or paste https://hatang.dev/docs/llms.txt into the prompt.

Step 1: verify the domain

Add the domain under Email → Domains (or hatang.domains.create({ name: "yourapp.com" })) and publish the records it lists: DKIM, SPF and the bounce MX on a send. subdomain, plus DMARC. Nothing here touches your existing mail.

The domain page listing the DKIM, SPF, return-path MX and DMARC records to publish, with their status

Step 2: send transactional email

Prompt: "Send an order confirmation with @hatang/sdk when an order is paid. Read the Ha Tang sending guide first. Use the order id as the idempotency key. The key is in HATANG_API_KEY."

// lib/email.ts (server only)
import { Hatang } from "@hatang/sdk";

const hatang = new Hatang(); // reads HATANG_API_KEY

export async function sendOrderConfirmation(order: { id: string; email: string; total: string }) {
  const { data, error } = await hatang.emails.send(
    {
      from: "Acme <orders@yourapp.com>",
      to: order.email,
      reply_to: "support@yourapp.com", // replies land in the support inbox (step 5)
      subject: `Order ${order.id} is confirmed`,
      html: `<p>Thanks! Your order <strong>${order.id}</strong> (${order.total}) is confirmed.</p>`,
      text: `Thanks! Your order ${order.id} (${order.total}) is confirmed.`,
    },
    { idempotencyKey: `order-confirmation-${order.id}` }, // a retry never sends twice
  );
  if (error) throw new Error(`${error.name}: ${error.message}`);
  return data.id;
}
Enter fullscreen mode Exit fullscreen mode

The API follows Resend's shapes, so react: <OrderEmail /> works with React Email, and moving existing Resend code is mostly a new base URL and key (migration notes). Frameworks that only speak SMTP (Laravel, Django, WordPress, Nodemailer) can use smtp.hatang.dev:5870 with STARTTLS, user hatang, password = your API key.

Double-check what the agent wrote:

  • The SDK is only imported in server code (route handlers, server actions, jobs), never in a client component.
  • Every send that can be retried has an idempotency key derived from your own ids.
  • The SDK returns { data, error } and does not throw for API errors: the agent must check error.

Step 3: receive mail for the domain

Turn Receiving on for the domain. This points the domain's MX at Ha Tang, so do it on a domain (or subdomain) that doesn't already receive mail at Google or Microsoft. If your root domain stays on Workspace for now, use a subdomain such as in.yourapp.com.

Every message to any address on the domain is stored and announced with an email.received webhook. Your app can react to the ones it cares about, for example invoices sent to bills@, or "reply to this email" threads from your users:

Prompt: "Add app/api/hatang/webhooks/route.ts: verify the Ha Tang webhook signature on the raw body with constructWebhookEvent, and for email.received sent to bills@yourapp.com, fetch the message and its attachments and store them."

// app/api/hatang/webhooks/route.ts
import { Hatang, constructWebhookEvent, HatangSignatureError, type EmailReceivedEvent } from "@hatang/sdk";

const hatang = new Hatang();

export async function POST(req: Request) {
  const raw = await req.text(); // verify the raw body, before any JSON parsing
  let event;
  try {
    event = await constructWebhookEvent<EmailReceivedEvent>(raw, req.headers, process.env.HATANG_WEBHOOK_SECRET!);
  } catch (err) {
    if (err instanceof HatangSignatureError) return new Response(null, { status: 400 });
    throw err;
  }

  if (event.type === "email.received" && event.data.to.includes("bills@yourapp.com")) {
    const { data: email } = await hatang.emails.receiving.get(event.data.email_id);
    const { data: files } = await hatang.emails.receiving.attachments.list(event.data.email_id);
    await saveBill({ subject: email?.subject, text: email?.text, files: files?.data ?? [] }); // your code
  }
  return new Response(null, { status: 200 });
}
Enter fullscreen mode Exit fullscreen mode

Double-check: agents love await req.json() before verifying. The signature is computed over the exact bytes, so parse after constructWebhookEvent, never before. Attachment download_urls expire after an hour: copy the file if you need it later.

Step 4: team@ and sales@ without mailbox seats

Most shared addresses of a small team only need to reach people. An email group is an address on your receiving domain whose mail is forwarded to a list of members, wherever their mailbox is:

The Email groups page with team@, sales@ and billing@ on one domain, and the Create a group form

Create them in the dashboard (Email → Email groups), or let your agent script it:

const { data: domains } = await hatang.domains.list();
const domain = domains!.data.find((d) => d.name === "yourapp.com")!;

await hatang.emailGroups.create({
  domain_id: domain.id,
  local_part: "sales",
  name: "Sales",
  members: ["you@gmail.com", "cofounder@gmail.com"],
});
Enter fullscreen mode Exit fullscreen mode

What members get:

  • Each member gets their own copy. The sender shows as "Alice via Sales" at sales@yourapp.com, and Reply goes straight to Alice.
  • Members don't see each other's addresses. The original stays under Email → Received and still fires email.received.
  • Loops, spam, auto-replies and bounces are not forwarded, and one sender can't flood a group (30 messages an hour).

The honest trade-off: when a member replies, the reply comes from their own address, not from sales@. That's fine for vendors, partners, internal mail and most early sales conversations. When the conversation is with a customer and should come from your domain, that's what the support inbox in the next step is for. Also note that every forwarded copy is a send: a message to a group of 3 uses 3 emails of your daily quota.

Step 5: support@ goes straight into the support inbox

Connect support@yourapp.com as an email channel under Customer Support → Channels → Add channel → Email. Because the domain already receives mail at Ha Tang, there is nothing else to configure: no forwarding rule, no extra MX.

A support mailbox on a domain that already receives at Ha Tang: no forwarding needed, replies sent from the support address

What you get compared to a shared Gmail label:

  • One inbox for every channel. Email threads sit next to website chat, the mobile SDKs, WhatsApp, Messenger, Instagram, Telegram, LINE, X, TikTok and Zalo, and the same customer is one profile across all of them.
  • Replies come from support@yourapp.com, in the same thread, with delivery status on each reply (sent, delivered, opened, bounced).
  • support+billing@ works too: the part after + becomes a tag you can route on.
  • The AI assistant answers email as well, from the knowledge you give it, and hands over to you when it can't help.
  • Acknowledgement emails, routing rules, tickets with SLA in business hours, and satisfaction ratings by email come with it.

Only support@ itself goes to the inbox. team@, sales@ and every other address keep working as in steps 3 and 4.

What changes on the bill

I won't quote prices because plans change, but here is what this setup removes for a small team:

Before After
A paid mailbox per person, mostly for shared addresses Members keep their personal inbox; shared addresses are groups
A shared-inbox or helpdesk tool for support@ support@ is a channel of the support inbox you already use for chat
Forwarding rules and a third-party parser for inbound mail email.received webhook on the same domain
Separate vendors for transactional email and inbound One domain, one API key, one dashboard

You may still want a real mailbox for yourself (for signing up to services, calendars, etc.). The point is that the shared addresses stop costing a seat each.

Watch it work

Every API call your keys make is logged with status, client and timing, which helps a lot when an agent wrote the integration:

The API request log: endpoint, status, method, API key, SDK and time for each call

Checklist before going live

  • [ ] DKIM, SPF and the bounce MX verified; DMARC published (start with p=none).
  • [ ] Receiving only on a domain or subdomain whose MX you control; not on a root domain still served by Workspace.
  • [ ] No API key or webhook secret in client code or in a variable with a public prefix (NEXT_PUBLIC_, VITE_).
  • [ ] Webhooks verified on the raw body; handlers return 200 quickly and are idempotent.
  • [ ] Group members double-checked: everyone listed really should get every message to that address.

FAQ

I'm on Resend / SendGrid / Postmark. Sending code moves with a new base URL and key; the migration guide maps the fields.

Can my agent do this without me? With npx -y @hatang/mcp and an API key, an agent can send a test email, check whether your domain's DNS records are verified and read the docs as Markdown. Add --read-only if you only want it to look things up.

Where does the mail go out from? Amazon SES, from your own verified domain, wherever your users are. Nothing in the setup above is tied to a country: any domain, any registrar, any DNS provider.

Docs


I work on Ha Tang.

Top comments (0)