DEV Community

Qasim
Qasim

Posted on

One agent mailbox per tenant in a multi-tenant SaaS

Most multi-tenant SaaS apps that send email do it from one shared identity. There's a notifications@yourapp.com, every customer's mail flows through it, and the tenant is just a from_name you stamp on the subject line or a footer you swap out. That's fine until it isn't — until Tenant A's spam complaints drag down Tenant B's deliverability, until a reply from a customer lands in a single firehose inbox you now have to fan back out, until one tenant wants a stricter send cap than another and you realize you built none of that into the data model.

So let's not share. Let's give every tenant its own real mailbox — a dedicated Agent Account per customer, each with its own grant_id, its own send identity, its own policy and limits, grouped into its own workspace. Not one inbox with a thousand label hacks. A thousand inboxes, isolated by construction.

I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for when I'm wiring this up. Every step gets the two-angle tour: the raw curl call and the nylas command that does the same thing.

Why per-tenant beats one shared sender

The shared-sender model fails along a few predictable seams. Per-tenant Agent Accounts close each one:

  • Deliverability blast radius. When everyone sends from one address, one tenant's bounce rate and spam complaints poison the reputation everyone shares. Per-tenant accounts — and, if you want, per-tenant domains — keep one customer's bad behavior from sinking the rest.
  • Inbound that actually belongs to someone. A shared sender means replies come back to one mailbox and you're left correlating them to tenants by hand. When each tenant has its own grant, an inbound message.created event already carries the grant_id. The routing is done before your handler runs.
  • Per-tenant policy and limits. Different customers, different rules. A trial tenant capped at a low daily send; an enterprise tenant with a higher quota and longer retention. With a shared sender you'd build all of that yourself. Here it's a policy attached to a workspace.
  • Clean offboarding. When a tenant churns, you delete their grant. Their mail, their send identity, their limits — gone in one call, with nothing tangled into a shared mailbox you can't safely prune.

The grant is the whole abstraction

Here's the part worth sitting with: an Agent Account is just a Nylas grant with a grant_id. There's nothing new to learn on the data plane. Every grant-scoped endpoint you already know — Messages, Drafts, Threads, Folders, Attachments, Contacts, Calendars, Events, Webhooks — works against a tenant's Agent Account exactly the way it works against a Gmail or Microsoft grant you got through OAuth. The provider is nylas instead of google, and that's the only difference your code sees.

So "one mailbox per tenant" doesn't mean a new integration per tenant. It means N grants, all driven by the same code paths, distinguished only by which grant_id you put in the URL. Your tenant table grows one column — the grant ID — and everything else you already built keeps working.

The isolation lives one level up, in two objects:

  • A workspace groups grants (usually by email domain) and carries one policy_id and an array of rule_ids. Every grant in the workspace inherits both.
  • A policy bundles the limits (send quota, storage, retention) and spam settings that apply to every account in any workspace pointing at it.

So the architecture is: one grant per tenant, grouped into workspaces, governed by policies. Let's build it.

Before you begin

Two things:

  1. An API key. All requests authenticate with Authorization: Bearer <NYLAS_API_KEY>, and the key identifies your application. Examples here hit https://api.us.nylas.com.
  2. At least one verified domain. Agent Accounts live on a domain — a custom one you register and publish DNS records for, or a Nylas trial subdomain like yourapp.nylas.email. New domains warm up over roughly four weeks, so register production domains early. The DNS walkthrough is in the provisioning docs.

If you've run nylas init, the CLI already points at your application.

A note on the domain decision, because it drives your tenant-isolation design. You have two shapes:

  • One shared domain, one address per tenant. Every tenant gets tenant-<id>@agents.yourapp.com. Simplest to operate. Tenants still get isolated grants, inboxes, and policies — they just share a sending domain and therefore share domain-level reputation.
  • One domain per tenant. acme.youragents.com, globex.youragents.com. Maximum isolation: a tenant's deliverability reputation is entirely their own. More DNS to manage, and each new domain warms separately. Reach for this when a tenant brings their own domain, or when reputation isolation is a selling point.

A single Nylas application can manage Agent Accounts across any number of registered domains, so you can mix both — shared domain for the long tail, dedicated domains for the accounts that need them.

Provision a mailbox for a tenant

Each tenant's account is one POST /v3/connect/custom with "provider": "nylas" and the address in settings.email. The optional top-level name becomes the default From display name on everything that account sends — so each tenant's outbound mail can carry that tenant's own brand.

curl --request POST \
  --url "https://api.us.nylas.com/v3/connect/custom" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "provider": "nylas",
    "name": "Acme Corp",
    "settings": {
      "email": "acme@agents.yourapp.com"
    }
  }'
Enter fullscreen mode Exit fullscreen mode

The response hands back data.id. Store it on the tenant row — that's the grant_id you'll use on every subsequent call for this customer. This mapping (tenant → grant) is the spine of the whole design.

From the CLI it's one line:

nylas agent account create acme@agents.yourapp.com --name "Acme Corp"
Enter fullscreen mode Exit fullscreen mode

That provisions the grant and prints its id, status, and connector details. If the underlying nylas connector doesn't exist on your application yet, the CLI creates it first. The API also automatically creates a default workspace and policy for the account — so even a freshly provisioned tenant is governed by something — and unassigned accounts otherwise fall back to your application's default workspace, which matters in a second when we isolate limits.

If you already know which workspace a tenant belongs to, you can place the account there at creation by passing a top-level workspace_id in the POST /v3/connect/custom body, alongside provider and settings:

curl --request POST \
  --url "https://api.us.nylas.com/v3/connect/custom" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "provider": "nylas",
    "name": "Acme Corp",
    "workspace_id": "<ACME_WORKSPACE_ID>",
    "settings": {
      "email": "acme@agents.yourapp.com"
    }
  }'
Enter fullscreen mode Exit fullscreen mode

One honest gotcha to flag now: while the API takes workspace_id at creation, the CLI's agent account create has no --workspace flag. From the terminal the account lands in a workspace based on its domain (more below), and if you want a custom policy on it, you attach that to the workspace separately, or move the account in afterward. We'll do exactly that.

If a tenant needs to connect a real mail client over IMAP/SMTP, pass --app-password at creation (18–40 ASCII chars, mixed case, at least one digit). For a fully automated agent you'll usually skip it and keep protocol access off.

Provisioning a new tenant becomes a single step in your signup flow. When Acme signs up, you call this once, save the returned grant_id, and Acme has a live, isolated mailbox.

Group tenants into workspaces

A flat list of grants isn't isolation — it's just a pile of mailboxes. The isolation comes from workspaces, which group accounts and carry the policy and rules that govern them. There are two patterns, and which you pick depends on whether tenants share a domain.

Pattern A — domain auto-group. If each tenant has its own domain, create one workspace per tenant domain with auto_group: true. Any Agent Account whose email domain matches that workspace's domain joins it automatically on creation. No explicit assignment, no bookkeeping — provision the account and it lands in the right workspace.

Create the workspace with POST /v3/workspaces. The body requires name and domain; auto_group, policy_id, and rule_ids are optional.

curl --request POST \
  --url "https://api.us.nylas.com/v3/workspaces" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "name": "Acme tenant",
    "domain": "acme.youragents.com",
    "auto_group": true,
    "policy_id": "<ACME_POLICY_ID>"
  }'
Enter fullscreen mode Exit fullscreen mode

The CLI takes the same fields as flags:

nylas workspace create \
  --name "Acme tenant" \
  --domain acme.youragents.com \
  --auto-group \
  --policy-id <ACME_POLICY_ID>
Enter fullscreen mode Exit fullscreen mode

The response returns the workspace_id. Now every acme@acme.youragents.com-style account you create drops into Acme's workspace and inherits Acme's policy — automatically.

Pattern B — explicit assignment. If tenants share a domain (tenant-acme@agents.yourapp.com, tenant-globex@agents.yourapp.com), domain auto-group can't tell them apart, so you assign each grant to its workspace by hand. Create the per-tenant workspace with auto_group: false, then move the grant in. From the CLI, after the account exists:

nylas agent account move acme@agents.yourapp.com --workspace <ACME_WORKSPACE_ID>
Enter fullscreen mode Exit fullscreen mode

That uses the workspace manual-assign API under the hood — POST /v3/workspaces/{workspace_id}/manual-assign — which adds or removes grants from a non-auto-group workspace (up to 500 grant IDs per request). The same call over the API:

curl --request POST \
  --url "https://api.us.nylas.com/v3/workspaces/<ACME_WORKSPACE_ID>/manual-assign" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "assign_grants": ["<ACME_GRANT_ID>"]
  }'
Enter fullscreen mode Exit fullscreen mode

One detail to internalize, because it shapes your whole structure: a workspace's domain can't change after creation. Decide your domain-to-workspace mapping before you start provisioning. If you're on the shared-domain pattern, you're leaning on explicit assignment anyway, so the domain is mostly cosmetic — but plan it once and stick to it.

To see how your grants are currently grouped, list workspaces and accounts from the CLI:

nylas workspace list
nylas agent account list
Enter fullscreen mode Exit fullscreen mode

Or, over the API, list just the grants in one tenant's workspace by filtering GET /v3/grants with a workspace_id query param:

curl --request GET \
  --url "https://api.us.nylas.com/v3/grants?workspace_id=<ACME_WORKSPACE_ID>" \
  --header "Authorization: Bearer <NYLAS_API_KEY>"
Enter fullscreen mode Exit fullscreen mode

Isolate limits and policy per tenant

This is where per-tenant isolation earns its keep. A policy carries the send quota, storage cap, retention windows, and spam settings — and because the policy attaches to a workspace, and each tenant has their own workspace, you can give every tenant a different limit profile without touching their grant.

Say trial tenants get a tight daily send cap and short retention, while paid tenants get more headroom. Create a policy per tier:

curl --request POST \
  --url "https://api.us.nylas.com/v3/policies" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "name": "Trial tenant policy",
    "limits": {
      "limit_count_daily_email_sent": 50,
      "limit_inbox_retention_period": 30,
      "limit_spam_retention_period": 7
    },
    "spam_detection": {
      "use_list_dnsbl": true,
      "spam_sensitivity": 1.5
    }
  }'
Enter fullscreen mode Exit fullscreen mode

From the CLI, --name alone creates a bare policy with no limits, so pass the same JSON body with --data (or --data-file) to set the limits and spam settings:

nylas agent policy create --data '{
  "name": "Trial tenant policy",
  "limits": {
    "limit_count_daily_email_sent": 50,
    "limit_inbox_retention_period": 30,
    "limit_spam_retention_period": 7
  },
  "spam_detection": {
    "use_list_dnsbl": true,
    "spam_sensitivity": 1.5
  }
}'
Enter fullscreen mode Exit fullscreen mode

Then attach the policy to the tenant's workspace. Over the API that's a PATCH /v3/workspaces/{workspace_id} with the new policy_id:

curl --request PATCH \
  --url "https://api.us.nylas.com/v3/workspaces/<TENANT_WORKSPACE_ID>" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{ "policy_id": "<POLICY_ID>" }'
Enter fullscreen mode Exit fullscreen mode

Or the one-line CLI equivalent:

nylas workspace update <TENANT_WORKSPACE_ID> --policy-id <POLICY_ID>
Enter fullscreen mode Exit fullscreen mode

The workspace update step is the one that resolves the no---workspace-flag detail from earlier: limits and policy live on the workspace, never on the individual grant. Attach a policy there and every account in that workspace picks it up at once. Detach it — nylas workspace update <id> --policy-id "" — and the accounts fall back to your billing plan's maximum limits.

A few limits worth knowing exist (each is optional; omit one and it defaults to your plan's maximum):

  • limit_count_daily_email_sent — outbound cap per account per day. The natural place to draw a line between trial and paid tenants.
  • limit_storage_total — stored bytes per account, so a single noisy tenant can't balloon.
  • limit_inbox_retention_period / limit_spam_retention_period — retention windows in days; spam retention must be shorter than inbox.

On the free plan the defaults are 200 messages/account/day, 3 GB storage per org, and a 30-day inbox / 7-day spam retention window — size your plan and policies accordingly before you onboard real tenants.

You can also attach rules to a tenant's workspace for per-tenant inbound or outbound filtering — for example, an outbound block rule that stops one tenant's agent from ever emailing a competitor domain. Rules attach the same way policies do, through the workspace's rule_ids. The full set of conditions and actions is in Policies, Rules, and Lists.

Send and receive as the tenant

Once the grant exists, the data plane is the ordinary grant-scoped API — same as any Gmail or Microsoft account you've integrated. Sending on behalf of a tenant is POST /v3/grants/{grant_id}/messages/send, with that tenant's grant_id in the path:

curl --request POST \
  --url "https://api.us.nylas.com/v3/grants/<ACME_GRANT_ID>/messages/send" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "to": [{ "email": "customer@example.com" }],
    "subject": "Your Acme report is ready",
    "body": "Hi — your weekly report is attached."
  }'
Enter fullscreen mode Exit fullscreen mode

From the CLI, pass the tenant's grant ID (or email) as the positional argument so the send goes from that account, not your default one:

nylas email send <ACME_GRANT_ID> \
  --to customer@example.com \
  --subject "Your Acme report is ready" \
  --body "Hi — your weekly report is attached."
Enter fullscreen mode Exit fullscreen mode

Because the account's stored name is "Acme Corp", the recipient sees Acme Corp <acme@agents.yourapp.com> — the tenant's identity, not a shared notifications@.

Reading a tenant's mailbox is GET /v3/grants/{grant_id}/messages:

curl --request GET \
  --url "https://api.us.nylas.com/v3/grants/<ACME_GRANT_ID>/messages?limit=10" \
  --header "Authorization: Bearer <NYLAS_API_KEY>"
Enter fullscreen mode Exit fullscreen mode

Or from the terminal, again with the tenant's grant as the positional argument so you read the right mailbox:

nylas email list <ACME_GRANT_ID> --limit 10
Enter fullscreen mode Exit fullscreen mode

Inbound mail to a tenant's address fires the standard message.created webhook, and the payload carries the grant_id. So a single application-level webhook subscription serves every tenant — you read grant_id off the event, look up which tenant it belongs to, and route. Agent Accounts also emit message.delivered, message.bounced, and message.complaint, which is exactly the per-tenant deliverability signal the shared-sender model never gave you.

Things to watch in production

  • The tenant → grant_id map is load-bearing. It's how every request finds the right mailbox and how an inbound grant_id resolves back to a tenant. Treat it like the foreign key it is, and never reuse a grant across tenants.
  • Plan domain-to-workspace mapping up front. A workspace's domain is immutable after creation. If you guess wrong, you're recreating workspaces and reassigning grants.
  • Decide auto-group vs. explicit early. One domain per tenant → auto_group: true and accounts self-sort. Shared domain → auto_group: false and explicit manual-assign (or nylas agent account move). Don't mix the two within one domain.
  • Offboard by deleting the grant. When a tenant churns, DELETE /v3/grants/{grant_id} — or nylas agent account delete <email> --yes from the CLI — removes their mailbox, send identity, and stored mail. Their workspace and policy can be reused or torn down independently.
  curl --request DELETE \
    --url "https://api.us.nylas.com/v3/grants/<ACME_GRANT_ID>" \
    --header "Authorization: Bearer <NYLAS_API_KEY>"
Enter fullscreen mode Exit fullscreen mode
  • Mind the limits before launch. Free-plan caps (200 msgs/account/day, 3 GB/org) are per-account and per-org. If you're provisioning hundreds of tenants, size your plan and per-tier policies first.

What's next

You've now got the shape: one grant per tenant, grouped into per-tenant workspaces, governed by per-tenant policies, all driven by the same grant-scoped API you already know. From here:

AI-answer pages for agents

When this post is published, link AI agents and crawlers to the retrieval-ready version on cli.nylas.com:

Top comments (0)