emailr_
All articles
usecase·10 min

Multi-tenant email: Architecture patterns

saasmulti-tenantarchitecture

Multi-tenant SaaS platforms need email systems that isolate tenants while sharing infrastructure efficiently. Here's how to design email for multi-tenancy.

Multi-tenant challenges

  • Tenant isolation (reputation, data)
  • Custom sending domains per tenant
  • Per-tenant rate limits and quotas
  • Tenant-specific templates and branding
  • Consolidated billing and analytics

Architecture patterns

Tenant isolation

interface TenantEmailConfig {
  tenantId: string;
  sendingDomain: string;
  fromAddress: string;
  replyTo?: string;
  
  // Authentication
  dkim: {
    selector: string;
    privateKey: string;
  };
  
  // Limits
  dailyLimit: number;
  rateLimit: number; // per second
  
  // Branding
  templates: Record<string, string>;
  brandColors: {
    primary: string;
    secondary: string;
  };
}


### Sending with tenant context

```typescript
async function sendTenantEmail(tenantId: string, email: Email) {
  const config = await getTenantEmailConfig(tenantId);
  
  return sendEmail({
    ...email,
    from: config.fromAddress,
    replyTo: config.replyTo,
    headers: {
      'X-Tenant-ID': tenantId
    }
  });
}

Custom sending domains

Domain verification flow

async function setupTenantDomain(tenantId: string, domain: string) {
  // Generate DNS records
  const records = {
    spf: {
      type: 'TXT',
      name: domain,
      value: 'v=spf1 include:spf.emailr.dev ~all'
    },
    dkim: {
      type: 'TXT',
      name: `emailr._domainkey.${domain}`,
      value: await generateDKIMRecord(tenantId)
    },
    dmarc: {
      type: 'TXT',
      name: `_dmarc.${domain}`,
      value: 'v=DMARC1; p=quarantine; rua=mailto:[email protected]'
    }
  };
  
  await savePendingDomain(tenantId, domain, records);
  
  return records;
}

Domain verification

async function verifyTenantDomain(tenantId: string, domain: string) {
  const pending = await getPendingDomain(tenantId, domain);
  
  const results = await Promise.all([
    verifyDNSRecord(pending.records.spf),
    verifyDNSRecord(pending.records.dkim),
    verifyDNSRecord(pending.records.dmarc)
  ]);
  
  if (results.every(r => r.verified)) {
    await activateDomain(tenantId, domain);
    return { status: 'verified' };
  }
  
  return { status: 'pending', failed: results.filter(r => !r.verified) };
}

Per-tenant rate limiting

class TenantRateLimiter {
  async checkLimit(tenantId: string): Promise<boolean> {
    const config = await getTenantConfig(tenantId);
    const usage = await getTenantUsage(tenantId, 'day');
    
    return usage.sent < config.dailyLimit;
  }
  
  async trackSend(tenantId: string, count: number = 1) {
    await redis.hincrby(`tenant:${tenantId}:usage`, 'sent', count);
  }
}

Template management

Tenant template overrides

async function getTemplate(tenantId: string, templateId: string) {
  // Check for tenant override
  const tenantTemplate = await db.query(`
    SELECT * FROM tenant_templates
    WHERE tenant_id = $1 AND template_id = $2
  `, [tenantId, templateId]);
  
  if (tenantTemplate) {
    return tenantTemplate;
  }
  
  // Fall back to default
  return getDefaultTemplate(templateId);
}

Branding injection

async function applyTenantBranding(html: string, tenantId: string) {
  const branding = await getTenantBranding(tenantId);
  
  return html
    .replace('&#123;&#123;logo_url&#125;&#125;', branding.logoUrl)
    .replace('&#123;&#123;primary_color&#125;&#125;', branding.primaryColor)
    .replace('&#123;&#123;company_name&#125;&#125;', branding.companyName);
}

Reputation isolation

Dedicated IPs per tenant

interface TenantIPConfig {
  tenantId: string;
  ipType: 'shared' | 'dedicated';
  dedicatedIPs?: string[];
  warmupStatus?: 'warming' | 'ready';
}

async function getIPForTenant(tenantId: string): Promise<string> {
  const config = await getTenantIPConfig(tenantId);
  
  if (config.ipType === 'dedicated' && config.warmupStatus === 'ready') {
    return selectIP(config.dedicatedIPs);
  }
  
  return getSharedPoolIP();
}

Analytics per tenant

async function getTenantAnalytics(tenantId: string, period: string) {
  return db.query(`
    SELECT 
      COUNT(*) as sent,
      SUM(CASE WHEN delivered THEN 1 ELSE 0 END) as delivered,
      SUM(CASE WHEN opened THEN 1 ELSE 0 END) as opened,
      SUM(CASE WHEN clicked THEN 1 ELSE 0 END) as clicked,
      SUM(CASE WHEN bounced THEN 1 ELSE 0 END) as bounced
    FROM email_events
    WHERE tenant_id = $1
    AND created_at > NOW() - $2::interval
  `, [tenantId, period]);
}

Best practices

  1. Isolate reputation - Bad tenant shouldn't affect others
  2. Enforce limits - Per-tenant quotas and rate limits
  3. Support custom domains - Professional appearance for tenants
  4. Centralize templates - With tenant override capability
  5. Track per-tenant - Analytics, billing, usage
  6. Plan for scale - Design for thousands of tenants

Multi-tenant email is about balance: shared infrastructure for efficiency, isolation for safety and customization.

e_

Written by the emailr team

Building email infrastructure for developers

Ready to start sending?

Get your API key and send your first email in under 5 minutes. No credit card required.