Relay multi-tenant

Entrega de e-mail, DKIM por domínio e reputação.

Relay PHP - Multi-tenancy Spec

Spec detalhada da evolucao do asender-relay-php de single-tenant para multi-tenant.

Status: em planejamento (proxima implementacao)


1. Conceitos

Hierarquia de Auth

┌─────────────────────────────────────────┐
│  MASTER KEY                              │
│  ────────────                            │
│  • Criada no install.php                 │
│  • Formato: sk_master_<48 chars hex>     │
│  • Unica por instancia de relay          │
│  • Usada pelo Asender Core               │
│  • Acesso: admin.* + override de tenant  │
└────────────┬────────────────────────────┘
             │ cria / gerencia
             ▼
┌─────────────────────────────────────────┐
│  TENANT KEYS (N por relay)               │
│  ────────────                            │
│  • Criadas via admin.tenant.create       │
│  • Formato: sk_live_<48 chars hex>       │
│  • 1 key por tenant                      │
│  • Usadas pelos apps finais              │
│  • Acesso: send, list, template, etc     │
│     (sempre scoped ao proprio tenant)    │
└─────────────────────────────────────────┘

Tenant

Unidade de isolamento dentro do relay. Cada tenant tem:

Template

Recurso reutilizavel de email, scoped por tenant:


2. Mudancas de Schema

Nova tabela: tenants

-- SQLite
CREATE TABLE IF NOT EXISTS tenants (
    id                 INTEGER PRIMARY KEY AUTOINCREMENT,
    tenant_id          TEXT UNIQUE NOT NULL,
    name               TEXT NOT NULL,
    api_key_hash       TEXT UNIQUE NOT NULL,
    status             TEXT NOT NULL DEFAULT 'active',
    smtp_config        TEXT,                      -- JSON, nullable
    default_from       TEXT,
    default_from_name  TEXT,
    hourly_limit       INTEGER,                   -- nullable, fallback to global
    daily_limit        INTEGER,
    batch_size         INTEGER,
    send_delay_ms      INTEGER,
    metadata           TEXT DEFAULT '{}',         -- JSON
    created_at         DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at         DATETIME DEFAULT CURRENT_TIMESTAMP,
    suspended_at       DATETIME,
    deleted_at         DATETIME
);
CREATE INDEX idx_tenants_status ON tenants(status);
CREATE INDEX idx_tenants_key ON tenants(api_key_hash);

MySQL equivalente com VARCHAR(64) / VARCHAR(255) / JSON / InnoDB utf8mb4.

Nova tabela: templates

CREATE TABLE IF NOT EXISTS templates (
    id                 INTEGER PRIMARY KEY AUTOINCREMENT,
    template_id        TEXT UNIQUE NOT NULL,
    tenant_id          TEXT NOT NULL,
    name               TEXT NOT NULL,
    description        TEXT,
    subject            TEXT NOT NULL,
    body_text          TEXT,
    body_html          TEXT,
    variables          TEXT DEFAULT '[]',         -- JSON
    metadata           TEXT DEFAULT '{}',
    created_at         DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at         DATETIME DEFAULT CURRENT_TIMESTAMP,
    deleted_at         DATETIME,
    FOREIGN KEY (tenant_id) REFERENCES tenants(tenant_id)
);
CREATE INDEX idx_templates_tenant ON templates(tenant_id);

Alteracoes em emails

ALTER TABLE emails ADD COLUMN tenant_id TEXT NOT NULL DEFAULT '';
CREATE INDEX idx_emails_tenant ON emails(tenant_id);
CREATE INDEX idx_emails_tenant_status ON emails(tenant_id, status);

Alteracoes em logs

ALTER TABLE logs ADD COLUMN tenant_id TEXT;  -- nullable for system logs
CREATE INDEX idx_logs_tenant ON logs(tenant_id);

Migracao de instalacoes existentes

No primeiro run pos-upgrade:

  1. Cria tabelas novas se nao existirem
  2. Se ja tem emails sem tenant_id: cria tenant tnt_default e atribui tudo a ele
  3. Move config.smtp / config.default_from pro tenant default
  4. Marca data/.migrated_v2 pra nao rodar de novo

3. Configuracao

Novo formato do data/config.php

<?php
return [
    // Master key (admin)
    'master_key_hash' => 'sha256-hash',

    // Database (sem mudanca)
    'database' => [
        'driver' => 'sqlite',
        'path' => '/abs/path/relay.db',
    ],

    // SMTP default (opcional - tenants podem sobrescrever)
    'default_smtp' => [
        'host' => 'smtp.example.com',
        'port' => 587,
        'encryption' => 'tls',
        'username' => 'user',
        'password' => 'pass',
    ],

    // Limites default (opcionais - tenants podem sobrescrever)
    'default_limits' => [
        'hourly' => 100,
        'daily' => 1000,
        'batch_size' => 10,
        'send_delay_ms' => 1000,
    ],

    // Settings globais
    'cleanup_days' => 30,
    'cors_origin' => '*',
    'installed_at' => '2026-04-12 15:30:00',
];

4. API - Rotas Admin (Master Key)

Todas as rotas abaixo requerem header Authorization: Bearer sk_master_xxxxx.

4.1 Criar Tenant

POST index.php?action=admin.tenant.create
Content-Type: application/json

{
    "Name": "Acme Corp",
    "Smtp": {
        "Host": "smtp.acme.com",
        "Port": 587,
        "Encryption": "tls",
        "Username": "mailer@acme.com",
        "Password": "secret"
    },
    "DefaultFrom": "noreply@acme.com",
    "DefaultFromName": "Acme",
    "Limits": {
        "Hourly": 500,
        "Daily": 5000,
        "BatchSize": 20,
        "SendDelayMs": 500
    },
    "Metadata": {
        "asender_customer_id": "cust_abc123"
    }
}

Response:

{
    "TenantId": "tnt_a1b2c3d4e5f67890",
    "Name": "Acme Corp",
    "ApiKey": "sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    "Status": "active",
    "CreatedAt": "2026-04-12T15:30:00Z",
    "RequestId": "req_xxx"
}

Importante: ApiKey so eh retornada nessa resposta. Em chamadas subsequentes, so tenant_id + hash.

4.2 Listar Tenants

GET index.php?action=admin.tenant.list&status=active&limit=50
{
    "Tenants": [
        {
            "TenantId": "tnt_xxx",
            "Name": "Acme Corp",
            "Status": "active",
            "DefaultFrom": "noreply@acme.com",
            "Stats": {
                "Queued": 3,
                "Sent": 1247,
                "Failed": 2
            },
            "CreatedAt": "..."
        }
    ],
    "Count": 1,
    "Total": 1
}

4.3 Get Tenant

GET index.php?action=admin.tenant.get&id=tnt_xxx

Response igual ao item acima mas com detalhes completos (Limits, Metadata, etc). NUNCA retorna a API key.

4.4 Update Tenant

PUT index.php?action=admin.tenant.update&id=tnt_xxx

{
    "Name": "Acme Corp Renamed",
    "Limits": {"Hourly": 1000},
    "Smtp": {"Password": "new-secret"}
}

Campos nao enviados permanecem.

4.5 Delete Tenant (soft)

DELETE index.php?action=admin.tenant.delete&id=tnt_xxx

Marca deleted_at, status='deleted'. Emails historicos ficam. Query param ?purge=true faz hard delete (apaga tudo incluindo emails).

4.6 Rotate API Key

POST index.php?action=admin.tenant.rotate-key&id=tnt_xxx
{
    "TenantId": "tnt_xxx",
    "ApiKey": "sk_live_novaxxxxxx",
    "RotatedAt": "..."
}

A key antiga deixa de funcionar imediatamente.

4.7 Suspend / Resume

POST index.php?action=admin.tenant.suspend&id=tnt_xxx
POST index.php?action=admin.tenant.resume&id=tnt_xxx

Tenant suspenso: API key retorna 403 ate ser resumido. Emails na fila dele ficam parados.

4.8 Stats Globais

GET index.php?action=admin.stats
{
    "Tenants": {
        "Total": 42,
        "Active": 40,
        "Suspended": 1,
        "Deleted": 1
    },
    "Stats": {
        "Queued": 145,
        "Processing": 2,
        "Sent": 124782,
        "Failed": 301
    },
    "Last24h": {
        "Sent": 8450,
        "Failed": 12
    },
    "TopTenants": [
        {"TenantId": "tnt_xxx", "Name": "Acme", "Sent24h": 3200}
    ]
}

4.9 Stats por Tenant (via master)

GET index.php?action=admin.tenant.stats&id=tnt_xxx

Retorna estatisticas especificas do tenant.


5. API - Rotas Tenant (Tenant Key)

Todas requerem Authorization: Bearer sk_live_xxxxx. Todas sao auto-scoped ao tenant dono da key.

5.1 Rotas Existentes (ja implementadas, agora tenant-scoped)

5.2 Send com Template

POST index.php?action=send

{
    "Template": {
        "Id": "tpl_xxx",
        "Data": {"name": "Joao", "code": "ABC123"}
    },
    "Destination": {"ToAddresses": ["joao@email.com"]}
}

Quando Template.Id eh fornecido, o relay:

  1. Busca o template no tenant
  2. Substitui variaveis {{name}} e {{code}}
  3. Monta o email com subject/body resultantes

Pode combinar com overrides diretos (Source, ReplyToAddresses, Tags, ScheduledAt).

5.3 Templates

POST   action=template.create
GET    action=template.list
GET    action=template.get&id=tpl_xxx
PUT    action=template.update&id=tpl_xxx
DELETE action=template.delete&id=tpl_xxx

Create:

{
    "Name": "Welcome Email",
    "Description": "Boas-vindas pos-signup",
    "Subject": "Bem-vindo, {{name}}!",
    "BodyHtml": "<h1>Ola {{name}}</h1><p>Seu codigo: {{code}}</p>",
    "BodyText": "Ola {{name}}\n\nSeu codigo: {{code}}",
    "Variables": [
        {"Name": "name", "Required": true},
        {"Name": "code", "Required": true}
    ],
    "Metadata": {}
}

Response:

{
    "TemplateId": "tpl_xxx",
    "Name": "Welcome Email",
    ...
}

5.4 Self-Service (tenant ve proprias infos)

GET action=me  -> detalhes do proprio tenant (sem API key, obvio)

Util pra apps checarem quotas, limites, etc.


6. Implementacao - Auth Refatorado

class Auth {
    private string $masterKeyHash;
    private Database $db;

    public function authenticate(): AuthContext {
        $key = $this->extractKey();
        if (!$key) throw new AuthError('Missing API key');

        $hash = hash('sha256', $key);

        // Master?
        if (hash_equals($this->masterKeyHash, $hash)) {
            return new AuthContext('master', null);
        }

        // Tenant?
        $tenant = $this->db->findTenantByKeyHash($hash);
        if ($tenant && $tenant['status'] === 'active') {
            return new AuthContext('tenant', $tenant['tenant_id'], $tenant);
        }
        if ($tenant && $tenant['status'] === 'suspended') {
            throw new AuthError('Tenant suspended', 403);
        }

        throw new AuthError('Invalid API key', 401);
    }
}

class AuthContext {
    public string $type;        // 'master' | 'tenant'
    public ?string $tenantId;
    public ?array $tenant;

    public function requireMaster(): void {
        if ($this->type !== 'master')
            throw new AuthError('Master key required', 403);
    }

    public function requireTenant(): void {
        if ($this->type !== 'tenant')
            throw new AuthError('Tenant key required', 403);
    }
}

Guardas por rota

$adminActions = ['admin.tenant.create', 'admin.tenant.list', ...];
$tenantActions = ['send', 'send-batch', 'template.create', ...];
$eitherActions = ['process', 'stats', 'health'];

if (in_array($action, $adminActions)) {
    $ctx->requireMaster();
}
if (in_array($action, $tenantActions)) {
    $ctx->requireTenant();
}

7. Implementacao - Send Flow

POST send (tenant key)
   │
   ├─ Auth resolves tenant context
   ├─ Parse body (may contain Template.Id)
   ├─ If Template.Id: fetch template from tenant, apply ReplacementData
   ├─ Build email payload with tenant_id
   ├─ Validate rate limits (tenant-scoped)
   ├─ Insert into emails (tenant_id = ctx.tenantId)
   ├─ Trigger piggyback processing
   └─ Return MessageId

Piggyback Processing (per tenant)

register_shutdown_function(function () use ($db, $tenantId) {
    // Lock por tenant (data/process_{tenant_id}.lock)
    // Fetch pending emails WHERE tenant_id = ?
    // Resolve SMTP config (tenant's or global default)
    // Send via SmtpClient
    // Mark sent/failed
});

SMTP Config Resolution

function resolveTenantSmtp(array $tenant, array $config): array {
    $smtp = json_decode($tenant['smtp_config'], true) ?: [];
    return array_merge($config['default_smtp'] ?? [], $smtp);
}

8. Implementacao - Database Methods (novos)

Tenants

Templates

Updates em Email methods


9. Install.php - Mudancas

Step 4 (Settings) perde SMTP default (fica so limites e cleanup). Step 2 (SMTP) fica OPCIONAL - marca default_smtp se preenchido.

Nova secao na conclusao:

Instalacao concluida.

Master Key: sk_master_xxxxxxxxxxxxxxxxxxxxx
(Use no header Authorization: Bearer pra chamar admin.*)

Proximo passo: crie seu primeiro tenant:
POST /index.php?action=admin.tenant.create
{ "Name": "Default Tenant" }

Ou deixe o Asender Core provisionar automaticamente.

Opcional: auto-criar tenant default

Checkbox no passo 4: "Criar tenant default com as configuracoes SMTP deste wizard" Se marcado, cria tnt_default com o SMTP e mostra ambas as keys no final.


10. INTEGRATION.md - Atualizacao Necessaria


11. Checklist de Implementacao

Schema & Migration

Database.php

Auth.php

Index.php

Install.php

Docs

Testes


12. Riscos e Decisoes

Backward compat

Opcao adotada: migracao automatica. Cria tenant tnt_default no primeiro run pos-upgrade, move tudo pra ele, marca flag.

SMTP config por tenant vs global

Adotado: ambos. Global como default, tenant override opcional. Flexivel.

Rate limiting

Por tenant OU global? Adotado: por tenant + global como fallback. Cada tenant tem seus limites; se null, usa global.

Lock de processamento

Global (um processo por relay) ou por tenant? Por tenant (arquivo data/process_{tnt_id}.lock) pra paralelizar.

Template variables

Simples string replacement {{var}} (igual batch send) ou templating engine (Mustache/Twig)? Simples por enquanto. Engine mais robusto pode vir dps com escape/HTML-safe.

Pre-validacao de variaveis

Se template declara variaveis required e o send nao manda, bloquear? Sim, retornar 400 com lista de variaveis faltantes.