# Custom SMTP & Email White-Labeling

**Date:** 2026-05-31  
**Status:** Draft  
**Depends on:** `foundation-auth-rbac`, `system-communications-notifications`, `settings-module`, `zync-subscription`  
**Referenced by:** `notification-center`, `invoices-core`, `contractor-payouts`

---

## Overview

By default, Zync sends all outbound emails from `noreply@zync.is` via the Resend API. Business-tier tenants can configure a custom From address (e.g. `invoices@theircompany.co.il`), backed by Zync's Resend account with domain-level DKIM verification. Enterprise tenants can additionally route all outbound email through their own SMTP relay, giving them full control over deliverability and branding.

---

## Tier Matrix

| Capability | Free/Starter | Business | Enterprise |
|------------|-------------|----------|------------|
| Custom From name | No | Yes | Yes |
| Custom From email + DKIM | No | Yes | Yes |
| Custom SMTP relay | No | No | Yes |
| Fallback to Zync SMTP if relay fails | N/A | N/A | Configurable |

---

## Email Delivery Modes

### Mode 1: Shared Zync SMTP (default, all tiers)
- Provider: Resend API
- From: `{Tenant Business Name} <noreply@zync.is>`
- No tenant configuration required

### Mode 2: Custom From Address (Business+)
- Tenant provides a custom From name and email address
- Zync sends via its own Resend account, with the tenant's domain set as the sender
- Resend issues DKIM keys for the tenant's domain; tenant must add DNS TXT records
- SPF: tenant adds `include:spf.resend.com` to their domain's SPF record
- Once DNS propagates and Resend verifies, all outbound emails use the custom From

### Mode 3: Custom SMTP Relay (Enterprise)
- Tenant provides SMTP credentials (host, port, username, password)
- Zync routes all outbound emails for that tenant through the provided relay
- Supports TLS and STARTTLS
- Optional fallback: if relay fails (connection timeout, auth error), fall back to Zync shared SMTP

---

## Data Model

```sql
CREATE TABLE tenant_email_config (
  id                       UUID    PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id                UUID    NOT NULL UNIQUE REFERENCES tenants(id) ON DELETE CASCADE,
  -- Custom From (Business+)
  from_name                TEXT,   -- e.g. "Acme Ltd"
  from_email               TEXT,   -- e.g. "invoices@acme.co.il"
  domain_verified          BOOLEAN NOT NULL DEFAULT false,
  domain_verification_token TEXT,  -- Resend domain ID for DNS check polling
  domain_verified_at       TIMESTAMPTZ,
  -- Custom SMTP (Enterprise)
  smtp_host                TEXT,
  smtp_port                INTEGER DEFAULT 587,
  smtp_username            TEXT,
  smtp_password_encrypted  TEXT,   -- AES-256-GCM encrypted, key from Worker secret
  smtp_encryption          TEXT    NOT NULL DEFAULT 'starttls' CHECK (smtp_encryption IN ('tls', 'starttls', 'none')),
  smtp_enabled             BOOLEAN NOT NULL DEFAULT false,
  smtp_fallback_enabled    BOOLEAN NOT NULL DEFAULT true,
  -- Meta
  updated_at               TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_by               UUID    REFERENCES users(id)
);
```

**SMTP password encryption:** encrypted with AES-256-GCM using the `SMTP_ENCRYPTION_KEY` Worker secret before insert. Decrypted only within the Worker at send time. Never returned in API responses.

---

## Features & Screens

### Location: `/settings/communications` → "Email" tab

> This tab is a section within the broader Communications settings page defined in `system-communications-notifications`. It only appears for Business+ tenants.

---

### Section A: From Address (Business+)

**Fields:**
- **From name** — text input, default: tenant business name, max 50 chars
- **From email** — email input, e.g. `invoices@acme.co.il`
  - Validated: must be a well-formed email address
  - Domain must not be a free provider (gmail, yahoo, hotmail, etc.) — shown as inline error

**DNS Verification panel** (shown after From email is saved):

| Record Type | Host | Value |
|-------------|------|-------|
| TXT | `resend._domainkey.{domain}` | `{dkim_value_from_resend}` |
| TXT | `@` / `{domain}` | `v=spf1 include:spf.resend.com ~all` |

- Status badge: `Pending DNS propagation` / `Verified` / `Failed`
- "Verify now" button → POST /api/settings/email/verify-domain → calls Resend domain verification API
- Verification may take up to 48h for DNS propagation; polling every 10 min via Cron Trigger
- Once verified, badge turns green; From address is activated

**Fallback behavior while unverified:** emails continue sending from `noreply@zync.is`.

---

### Section B: Custom SMTP Relay (Enterprise only)

> Hidden entirely for non-Enterprise tenants. Show upgrade prompt instead.

**Fields:**
- SMTP Host — text input (e.g. `smtp.sendgrid.net`)
- Port — number input (common: 25, 465, 587)
- Username — text input
- Password — password input (masked, never echoed back in GET response)
- Encryption — radio: TLS | STARTTLS | None (default: STARTTLS)
- From address override — optional; overrides the custom From email for relay sends
- Fallback to Zync SMTP if relay fails — toggle (default: on)

**"Send test email" button:**
- Sends a test email to the currently logged-in user's email address
- Shows success toast or error message with the SMTP error reason
- POST /api/settings/email/test

**Enable/Disable toggle:** "Use my SMTP relay" master toggle. When disabled, reverts to Mode 1 or Mode 2.

---

## Communications Adapter Integration

The `system-communications-notifications` spec defines a `EmailAdapter` interface:

```typescript
interface EmailAdapter {
  send(message: EmailMessage): Promise<{ success: boolean; error?: string }>
}
```

This spec adds `TenantEmailAdapter` which is resolved at send time per tenant:

```typescript
async function resolveEmailAdapter(tenantId: string, db: D1Database): Promise<EmailAdapter> {
  const config = await db.prepare(
    'SELECT * FROM tenant_email_config WHERE tenant_id = ?'
  ).bind(tenantId).first()

  // Enterprise with SMTP enabled and credentials set
  if (config?.smtp_enabled && config.smtp_host && config.smtp_username) {
    return new TenantSMTPAdapter(config) // wraps nodemailer-compatible SMTP client
  }

  // Business+ with verified custom From
  if (config?.domain_verified && config.from_email) {
    return new ResendAdapter({ fromEmail: config.from_email, fromName: config.from_name })
  }

  // Default: Zync shared Resend
  return new ResendAdapter({ fromEmail: 'noreply@zync.is', fromName: 'Zync' })
}
```

`TenantSMTPAdapter` decrypts `smtp_password_encrypted` using the Worker secret and establishes an SMTP connection. On connection failure, if `smtp_fallback_enabled=1`, falls back to `ResendAdapter`. Failure is logged to `tenant_audit_log` as `smtp.relay_failed`.

---

## Permissions

| Role | Can View | Can Edit |
|------|----------|----------|
| OWNER | Yes | Yes |
| ADMIN | Yes | Yes |
| MEMBER | No | No |
| CONTRACTOR | No | No |

---

## API Endpoints

### `GET /api/settings/email`
Returns current email config (password field omitted).

Response:
```json
{
  "from_name": "Acme Ltd",
  "from_email": "invoices@acme.co.il",
  "domain_verified": true,
  "domain_verified_at": 1748649600,
  "smtp_host": "smtp.sendgrid.net",
  "smtp_port": 587,
  "smtp_username": "apikey",
  "smtp_encryption": "starttls",
  "smtp_enabled": false,
  "smtp_fallback_enabled": true
}
```

### `PATCH /api/settings/email`
Update email config. Body: any subset of fields (except `smtp_password_encrypted` — use the `smtp_password` field which is encrypted server-side before storage). Triggers audit log entry `settings.updated`.

### `POST /api/settings/email/verify-domain`
Calls Resend API to check DNS verification status for the configured domain. Updates `domain_verified` and `domain_verified_at`. Returns:
```json
{ "verified": true, "records": [...] }
```

### `POST /api/settings/email/test`
Sends a test email to the authenticated user's email address using the current SMTP configuration. Returns:
```json
{ "success": true }
// or
{ "success": false, "error": "Connection refused: smtp.example.com:587" }
```

---

## Architecture Decisions

### Password encryption at rest
SMTP passwords are encrypted with AES-256-GCM using a `SMTP_ENCRYPTION_KEY` Workers secret (256-bit key). The IV is stored alongside the ciphertext (base64: `{iv}:{ciphertext}`). Passwords are never returned in API responses — only a boolean `smtp_password_set` flag is returned.

### Domain verification polling
A Cron Trigger fires every 10 minutes and queries Resend's domain status API for any tenants with `domain_verified=0 AND domain_verification_token IS NOT NULL`. Once verified, `domain_verified=1` is set and a notification is sent to the tenant OWNER.

### Free provider blocklist
The following domains are blocked as From email addresses (non-exhaustive list checked server-side): `gmail.com`, `yahoo.com`, `hotmail.com`, `outlook.com`, `walla.co.il`, `013.net`, `bezeqint.net`. Tenants must use a domain they own.

### SMTP client in Workers
Cloudflare Workers support TCP socket connections via `connect()` (available on paid plans). SMTP client is implemented using the Workers TCP API. Alternatively, Workers can proxy SMTP through a dedicated SMTP microservice on Cloudflare Workers for Platforms if socket support is insufficient.
