Multi-tenant transactional email: domains, branding and action links without duplicating services

Transactional email looks small until you have more than one product.

With one product, configuration is usually global:

Bloque de codigo - text
from = Product <no-reply@mail.product.example>
appUrl = https://app.product.example
tenantId = product-slug

As soon as the same auth system creates users for another product, that global setup starts to fail.

The concrete case was Ultimate Porra.

If I invite someone to a World Cup pool and they receive an email from the wrong product, with a link that lands on a shared portal instead of the product they expect, the flow may be technically valid and still wrong. The user does not know where they are, does not recognize the brand and loses trust in the link.

Important

In transactional email, branding is not decoration. It is part of the perceived security of the flow.

The real problem

The initial flow had this risk:

flowchart LR admin["Admin creates product user"] --> auth["auth service"] auth --> global["shared tenant"] global --> notif["notification service"] notif --> email["wrong product email"] email --> portal["shared portal"]

The bug was not in Resend or in the template. It was in the contract between producers and the notification service.

The notification service already knew how to send per tenant. But the auth service needed to say which tenant belonged to each action.

The correct architecture

The separation I want is:

flowchart LR producer["auth service"] --> product["product metadata<br/>tenant + product code"] product --> queue["notification service<br/>email queue"] queue --> tenant["tenant config<br/>sender + app URL + key reference"] tenant --> resend["Resend<br/>verified domain"] resend --> inbox["user"]

Each piece has one responsibility:

Piece Responsibility
Auth service Know which product an auth action belongs to
Action token Store product metadata for password setup/recovery
Notification service Render and enqueue by tenant
Resend API key Authorize sending for a domain
Domain DNS Prove SPF, DKIM and DMARC
appUrl Take the user to the right product

The key is not duplicating the entire service per product if only the brand changes.

Minimum contract per product

Each product needs an explicit configuration entry:

Bloque de codigo - json
{
  "tenantId": "product-slug",
  "productCode": "product-slug",
  "brandName": "Product Name",
  "fromAddress": "Product Name <no-reply@mail.product.example>",
  "replyTo": "support@product.example",
  "appUrl": "https://product.example",
  "authActionBaseUrl": "https://auth.example",
  "sendingKeySecretRef": "domain-scoped sending key"
}

The important detail: authActionBaseUrl can remain shared.

I do not need one auth service per product if the action page knows what product the token represents.

Action tokens with metadata

The good flow becomes:

Bloque de codigo - text
create-user action
  productCode = product-slug

auth service
  -> creates pending user
  -> creates password_setup token
  -> stores metadata:
       tenantId = product-slug
       productCode = product-slug
       brandName = Product Name
       appUrl = product URL
  -> enqueues email in the notification service with the product tenant

Then, when the user opens the link:

Bloque de codigo - text
read action token
  -> reads token metadata
  -> renders page with product branding

complete action
  -> consumes token
  -> activates password
  -> returns the product redirect URL

This solves two problems at once:

  • the email is sent from the correct tenant;
  • the password page and final redirect belong to the correct product.

Changing the sender is not enough

Only changing fromAddress leaves the flow inconsistent.

Element If it stays global What breaks
fromAddress Notipad The user does not recognize the invitation
template Notipad The product promise is wrong
action page Notipad The user thinks they are creating another account
final redirect shared portal The user lands outside the app
support URL Notipad Support and trust are mixed

Multi-tenant email does not mean "several senders". It means a complete product flow.

Sending domains

For production, I prefer one sending subdomain per product:

Bloque de codigo - text
mail.product-a.example
mail.product-b.example

That isolates reputation, intent and DNS.

Minimum verification:

Record Purpose
SPF Authorizes servers to send for the domain
DKIM Signs the email and proves it was not altered
DMARC Defines policy and reporting if SPF/DKIM fail

Resend recommends verifying owned domains and using subdomains to isolate sending reputation. Once SPF and DKIM are verified, DMARC adds another layer of trust.

Free vs Pro is not only volume

For development or a very small private beta, Free can be enough.

But once there are two real products, real users and password/recovery emails, the cost of emails not being sent or hitting uncomfortable limits matters more than the subscription price.

My rule:

Bloque de codigo - text
Free -> development, tests, very small closed beta
Pro  -> production with real users and more than one domain/product

Not because Pro is magically safer. Because production needs less friction: more monthly margin, no Free-plan daily sending limit and room to grow without changing architecture during launch.

The exact numbers can change; the operational decision should always check the live pricing page before buying.

n8n and the email queue

If notifications-api works in queue mode, n8n can act as a simple worker:

flowchart LR producer["auth-api"] --> enqueue["enqueue email job"] enqueue --> db["email_jobs"] n8n["n8n scheduled worker"] --> next["process next job"] next --> resend["Resend"] resend --> status["sent / failed / retry"]

The advantage is separating user experience from delivery:

  • user creation does not wait on Resend;
  • failures can be retried;
  • jobs can be audited;
  • internal tokens never reach the browser.

The rule: n8n can operate jobs, but it should not know more secrets than necessary.

Checklist for adding a new product

Multi-tenant email checklist
  • Create sending subdomain: mail.<product>.com.
  • Verify the domain in Resend.
  • Add SPF and DKIM in Cloudflare DNS.
  • Add DMARC, even with a gradual policy.
  • Create a sending API key scoped to the domain.
  • Store the sending key in the server-side secret store.
  • Add tenant configuration to server-side settings.
  • Add the product to the allowed auth-action configuration.
  • Make the producer send productCode or tenantId.
  • Test auth_password_setup.
  • Test password_recovery.
  • Confirm the password link shows the correct brand.
  • Confirm complete redirects to the correct appUrl.
  • Review queue logs and Resend status.

The remaining pattern

I do not want one email service per product.

I want one shared service with clear boundaries:

Bloque de codigo - text
product selects tenant
tenant selects domain, brand and provider key
token keeps context
email and redirect preserve continuity

That keeps the architecture small without making it confusing.

The signal that the design works is simple: a user of Ultimate Porra never has to wonder why Notipad is asking them to create a password.

Technical sources

  • Resend documents that domain verification requires SPF and DKIM, and recommends subdomains to isolate reputation and sending intent: Managing Domains.
  • Resend allows API keys with Sending access and domain restriction: API Keys.
  • Resend explains DMARC as a layer on top of SPF and DKIM to protect against spoofing and improve trust: Implementing DMARC.
  • Resend keeps current pricing information in its documentation and points to the public pricing page for live figures: What is Resend Pricing and Pricing.

This article is part of my architecture and operations notes. If you are interested in this work, follow the blog or contact me.