Relay multi-tenant
Entrega de e-mail, DKIM por domínio e reputação.
- 1. Conceitos
- 2. Mudancas de Schema
- 3. Configuracao
- 4. API - Rotas Admin (Master Key)
- 5. API - Rotas Tenant (Tenant Key)
- 6. Implementacao - Auth Refatorado
- 7. Implementacao - Send Flow
- 8. Implementacao - Database Methods (novos)
- 9. Install.php - Mudancas
- 10. INTEGRATION.md - Atualizacao Necessaria
- 11. Checklist de Implementacao
- 12. Riscos e Decisoes
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:
- ID publico:
tnt_<16 hex> - Nome (human-readable)
- API key propria
- Config SMTP proprio (host, port, user, pass, encryption) - ou usa o default global
- Limites (hourly, daily, batch size, send delay) - ou usa defaults globais
- From padrao (email + nome)
- Status:
active|suspended|deleted(soft delete) - Metadata (JSON livre pra Asender Core guardar refs)
Template
Recurso reutilizavel de email, scoped por tenant:
- ID publico:
tpl_<16 hex> - Nome
- Subject
- Body (text + html)
- Variables declaradas (array de
{name, required, default}) - Metadata
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:
- Cria tabelas novas se nao existirem
- Se ja tem emails sem tenant_id: cria tenant
tnt_defaulte atribui tudo a ele - Move
config.smtp/config.default_frompro tenant default - Marca
data/.migrated_v2pra 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)
POST action=send- usa SMTP do tenantPOST action=send-batch- idemGET action=status&id=msg_xxx- so emails do proprio tenantGET action=list- lista so do tenantDELETE action=cancel&id=msg_xxx- so propriosGET action=stats- stats do tenantPOST action=process- processa so emails do tenant (util pra webcron por tenant)POST action=flush- idem
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:
- Busca o template no tenant
- Substitui variaveis
{{name}}e{{code}} - 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
createTenant(array $data): arraygetTenant(string $tenantId): ?arraygetTenantByKeyHash(string $hash): ?arraylistTenants(array $filters): arrayupdateTenant(string $tenantId, array $data): booldeleteTenant(string $tenantId, bool $purge = false): boolsuspendTenant(string $tenantId): boolresumeTenant(string $tenantId): boolrotateTenantKey(string $tenantId): string// returns new plaintext keycountTenants(?string $status = null): int
Templates
createTemplate(string $tenantId, array $data): arraygetTemplate(string $tenantId, string $templateId): ?arraylistTemplates(string $tenantId, array $filters): arrayupdateTemplate(string $tenantId, string $templateId, array $data): booldeleteTemplate(string $tenantId, string $templateId): bool
Updates em Email methods
- Todos os metodos de email ganham parametro
$tenantIdpra scoping: queueEmail(string $tenantId, array $email): arraygetByMessageId(string $tenantId, string $messageId): ?arraylistEmails(string $tenantId, array $filters): arraygetPending(int $limit, ?string $tenantId = null): array// null = all tenantsmarkSent/markFailed/lockEmail- ja trabalham por id, sem mudanca- Novos scope helpers:
getTenantStats(string $tenantId): array
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
- Dividir em duas secoes: "Admin API" e "Tenant API"
- Adicionar secao "Multi-Tenancy" explicando o modelo
- Documentar todas as rotas admin
- Documentar rotas de template
- Atualizar exemplos pra usar tenant keys
- Adicionar secao sobre como o Asender Core consome a API admin
11. Checklist de Implementacao
Schema & Migration
- [ ] Tabela
tenants(SQLite + MySQL) - [ ] Tabela
templates(SQLite + MySQL) - [ ] Coluna
tenant_idememails+ indices - [ ] Coluna
tenant_idemlogs+ indices - [ ] Migracao automatica pra instalacoes existentes (cria tnt_default)
Database.php
- [ ] Metodos CRUD de tenants (9 metodos)
- [ ] Metodos CRUD de templates (5 metodos)
- [ ] Update de queueEmail, listEmails, getStats, getByMessageId pra receber tenant_id
- [ ] getTenantByKeyHash (indice usado pelo auth)
- [ ] getTenantStats
- [ ] Template rendering helper (substituicao {{var}})
Auth.php
- [ ] Refatorar pra retornar AuthContext (type + tenantId + tenant)
- [ ] Suporte a master key
- [ ] Suporte a tenant key lookup no DB
- [ ] requireMaster() / requireTenant() helpers
- [ ] Tratar status suspended
Index.php
- [ ] Mapa de actions -> guards (admin/tenant/public)
- [ ] Handlers admin.tenant.* (9 rotas)
- [ ] Handlers template.* (5 rotas)
- [ ] Handler admin.stats
- [ ] Handler action=me
- [ ] Atualizar handleSend pra aceitar Template.Id
- [ ] Atualizar handleSend pra usar SMTP do tenant
- [ ] Atualizar todos os handlers existentes pra scope por tenant_id
- [ ] Piggyback processing por tenant
Install.php
- [ ] Gerar master_key_hash em vez de api_key_hash
- [ ] Tornar SMTP default opcional
- [ ] Checkbox "criar tenant default"
- [ ] Mostrar master key + (opcional) tenant key na conclusao
Docs
- [ ] Atualizar INTEGRATION.md com novas rotas
- [ ] Nova secao "Admin API" vs "Tenant API"
- [ ] Exemplos de template usage
- [ ] Diagrama de hierarquia de keys
Testes
- [ ] Teste manual: fluxo install → create tenant → send com tenant key
- [ ] Teste: tenant A nao ve emails de tenant B
- [ ] Teste: template rendering correto
- [ ] Teste: rotate key invalida key antiga
- [ ] Teste: tenant suspenso bloqueia send
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.