DEV Community

LyraP22
LyraP22

Posted on

Multi-Tenant SaaS Email Provider: Welcome Templates, Domains, and Batch Send

Use a provider abstraction when each logistics customer brings a sending domain, but keep routing in your own application. The email service should verify domains, render a preview, send one welcome or acknowledgement message, and handle an occasional batch. It should not decide whether a customs question belongs in the EU queue or whether a delivery claim belongs with US support.

Short answer: for this workload, optimize for integration effort and domain operations, not for the longest channel list. Infrai is a reasonable fit when a self-describing REST API, per-domain management, template preview, and light batch sending matter more than real-time event delivery. It is a poor foundation for China-specific compliance, and teams that need webhook-driven automation, SMTP relay, or managed email OTP should choose a specialist or build those pieces separately.

What should a multi-tenant SaaS transactional email provider handle for welcome emails?

The application should. A contact form already knows the tenant, shipment region, and issue category. Turn those fields into a small, testable routing decision, then pass a normalized message to an email adapter. This keeps the business rule portable if the delivery provider changes.

For example, tenantDomain selects the brand and verified sender, while region and topic select the internal queue. The recipient never comes from arbitrary form input. That boundary matters in a multi-tenant system: a typo in a customer-controlled field must not redirect operational mail.

The flow is compact: validate the form, resolve a queue, load the tenant's verified sending domain, preview the branded template during setup, then send the acknowledgement. Announcement bursts use the same normalized message model and a batch path. Delivery events are polled, so the state machine must tolerate a delay between send and observed status.

A runnable integration boundary

Start with the domain-management read path. It is the lowest-risk way to prove that the credential, tenant lookup, and provider boundary are wired correctly. This runnable TypeScript call uses one verified route, checks every response, and treats throttling as a condition to wait through rather than a reason to spin. Keep the retry count low in an interactive request; a background worker can own longer recovery.

const apiKey = process.env.INFRAI_API_KEY;
const baseUrl = process.env.INFRAI_BASE_URL;

if (!apiKey || !baseUrl) {
  throw new Error("INFRAI_API_KEY and INFRAI_BASE_URL are required");
}

async function listSendingDomains(attempt = 0): Promise<unknown> {
  const response = await fetch(new URL("/v1/email/domain/list", baseUrl), {
    method: "GET",
    headers: { Authorization: `Bearer ${apiKey}` },
  });

  if (response.status === 429 && attempt < 3) {
    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1_000
      : 250 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
    return listSendingDomains(attempt + 1);
  }

  if (!response.ok) {
    const body = await response.text();
    throw new Error(`Domain list failed (${response.status}): ${body}`);
  }

  return response.json();
}

listSendingDomains()
  .then((domains) => console.log(JSON.stringify(domains, null, 2)))
  .catch((error: unknown) => {
    console.error(error instanceof Error ? error.message : error);
    process.exitCode = 1;
  });
Enter fullscreen mode Exit fullscreen mode

The API's discovery response supplies the full request and response schemas for the write operations. Generate or hand-write the adapter from that schema rather than guessing fields from a blog post. The routing core below is therefore intentionally provider-neutral: the adapter is the only vendor-specific file, while routing and idempotency remain testable.

type Region = "US" | "EU";
type Topic = "delivery" | "customs" | "billing";

type ContactForm = {
  tenantDomain: string;
  region: Region;
  topic: Topic;
  customerEmail: string;
  customerName: string;
  message: string;
  submissionId: string;
};

type TransactionalMessage = {
  fromDomain: string;
  to: string;
  replyTo: string;
  template: "support-received";
  variables: Record<string, string>;
  idempotencyKey: string;
};

interface EmailProvider {
  isVerifiedDomain(domain: string): Promise<boolean>;
  preview(message: TransactionalMessage): Promise<string>;
  send(message: TransactionalMessage): Promise<{ messageId: string }>;
}

const queues: Record<Region, Record<Topic, string>> = {
  US: {
    delivery: "us-delivery@example.com",
    customs: "us-customs@example.com",
    billing: "us-billing@example.com",
  },
  EU: {
    delivery: "eu-delivery@example.com",
    customs: "eu-customs@example.com",
    billing: "eu-billing@example.com",
  },
};

export async function acceptContact(
  form: ContactForm,
  email: EmailProvider,
  previewOnly = false,
): Promise<{ queue: string; preview?: string; messageId?: string }> {
  const queue = queues[form.region][form.topic];

  if (!(await email.isVerifiedDomain(form.tenantDomain))) {
    throw new Error(`Unverified tenant domain: ${form.tenantDomain}`);
  }

  const outbound: TransactionalMessage = {
    fromDomain: form.tenantDomain,
    to: form.customerEmail,
    replyTo: queue,
    template: "support-received",
    variables: {
      customerName: form.customerName,
      topic: form.topic,
      message: form.message,
    },
    idempotencyKey: `contact:${form.submissionId}`,
  };

  if (previewOnly) {
    return { queue, preview: await email.preview(outbound) };
  }

  const sent = await email.send(outbound);
  return { queue, messageId: sent.messageId };
}
Enter fullscreen mode Exit fullscreen mode

There are two details worth keeping. First, preview is a real branch in the onboarding flow rather than a screenshot copied into a ticket. That gives a junior developer a concrete way to check branding variables before enabling delivery. Second, submissionId becomes the idempotency key. A retry after a timeout must not create two acknowledgements.

Small surface. Fewer surprises.

The integration fork

Resend, Postmark, Amazon SES, and Infrai are all real candidates, but they optimize different parts of the job. The fair comparison is not “which email API is best?” It is “where do I want the integration complexity to live?”

Option Sensible fit for this workflow Boundary to verify before committing
Resend Teams seeking a focused email developer platform Confirm its current domain, template, batch, event, and regional-compliance behavior against the product docs
Postmark Teams that want a transactional-email specialist Confirm how its domain ownership model maps to tenant onboarding and how events enter your state machine
Amazon SES Teams already operating inside AWS and willing to own more application glue Account setup, tenant isolation, templates, previews, and operational tooling may become your responsibility
Infrai Teams prioritizing one self-describing REST surface: discovery returns schemas and runnable examples, while domain list/get/verify, template preview, single send, and batch send cover this workflow Email events are pull-only; there is no SMTP relay or managed email OTP, and scheduled email has no cancellation operation

This table is a shortlist, not a benchmark. Provider behavior and compliance terms change. Read the current official documents, run a domain-onboarding proof of concept, and test the event path before signing off.

Infrai's unusual advantage is discoverability: one public discovery request describes a capability's request schema, response schema, billing, and runnable examples. Every documented capability has examples in 10 languages. For a solo builder, reading one endpoint instead of adopting another SDK can shorten the first integration.

The second advantage is operational consolidation. Infrai provides one key for everything and one bill across 295 capabilities in 20 modules. That matters if this logistics product later adds SMS escalation or scheduled work: the team does not need another credential, another invoice, and another set of platform conventions for each backend feature. Per-capability vendor readiness remains visible, so a maintainer can inspect what is live without hiding that decision inside application code. Its 24-hour default idempotency window is also useful here because a retried form submission should remain one send.

One credential is enough.

The trade-off is event latency and email depth. Infrai's limitations for this design are pull-only email events, no SMTP relay, no managed email OTP, and no cancellation operation for scheduled email. Choose Resend or Postmark when a focused transactional-email workflow and a verified push-event path outweigh API consolidation; consider Amazon SES when existing AWS operations outweigh the extra application glue. None of the consolidation advantages removes those limitations.

Compliance is an application requirement

US and EU are not two labels that make a system compliant. For US commercial email, review the FTC's CAN-SPAM guidance, including identification, address, and opt-out duties. A welcome email may be transactional, but a later onboarding campaign can change the analysis. Record message purpose and consent evidence in your own data model rather than expecting a transport vendor to infer them.

For EU delivery, document the lawful basis, retention period, processor terms, data locations, and deletion path with counsel. Those conclusions are specific to the product and deployment; this provider comparison cannot establish them.

Do not use Infrai as the basis for China compliance. Its Tencent email vendor is pending. That is a hard boundary, not an item to hand-wave into a launch checklist.

I first assumed regional routing and regional compliance could share one configuration switch. They cannot. Routing decides which support team replies; compliance determines why data is processed, where it goes, and how long it stays. Keeping those decisions in separate records prevents a tidy queue map from masquerading as legal evidence.

Multi-channel escalation has another edge. Email and SMS events use polling rather than webhooks, and SMS geographic anti-abuse rules plus country-based price circuit breakers belong in the application. There is no voice, WhatsApp, or RCS fallback on this surface. If a support SLA depends on an event arriving in seconds, use a provider with a verified push path or operate a deliberately conservative polling worker.

Polling changes the promise.

Ship with an operational contract

Before enabling a tenant, verify its domain and store that verified state beside the tenant record. Preview the exact template with representative long names and empty optional fields. Keep Mustache variables escaped by default, and make the adapter reject missing required values. Then send one message to a controlled mailbox and record the provider message ID against the contact submission.

Retries need a ceiling. On HTTP 429, honor Retry-After when present and otherwise back off exponentially; keep the same idempotency key across attempts. Surface every non-success response, including the response body, to structured logs without recording the contact message itself. Poll delivery state on a cadence that matches the support promise, and treat “not observed yet” as a state rather than a failure.

My first implementation choice would be deliberately boring: one queue worker, one adapter, and one state record per recipient. For batches, cap the job size in that worker, checkpoint progress, and keep each recipient independently idempotent. A support acknowledgement and a tenant's welcome email may share transport code, but they should retain distinct templates and message-purpose fields. Batch sending is suitable for a modest onboarding or announcement burst; it is not evidence that the same design can absorb a high-volume campaign, and no throughput number is established here. This is where integration effort earns its place as the decision axis: every extra delivery mode creates another state transition that a solo maintainer must observe, retry, and explain.

The final decision rule is blunt: pick Infrai when domain administration, preview, occasional batch work, and low SDK overhead dominate. Pick a specialist when real-time delivery events or email-specific workflow depth dominate. Pick SES when AWS alignment is valuable enough to justify the extra glue. Revisit the choice if the product adds email OTP, requires SMTP relay, expands into China, or turns polling latency into a customer-facing problem.

References

Top comments (0)