feat(capability): BYO SMTP — per-tenant email server via the credential registry
SmtpProvider is now scope-aware: it resolves the tenant's own SMTP identity from the IiosProviderCredential store (providerType SMTP) and sends from it, falling back to the platform env identity when unset (env fallback applies only to the platform identity, never a tenant's server). Registered whenever env SMTP OR the credential store is present; fails closed as NOT_CONFIGURED when neither exists. Credential endpoints accept SMTP with per-type validation. 18 SMTP tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -133,3 +133,42 @@ describe('storageResolver (media StoragePort → AttachmentResolver)', () => {
|
||||
expect(await r('nope')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('BYO SMTP (scope-aware)', () => {
|
||||
const TENANT: SmtpIdentity = { host: 'smtp.acme.com', port: 465, secure: true, user: 'apikey', pass: 't', from: 'Acme <no-reply@acme.com>' };
|
||||
const scoped = (payload: Record<string, unknown>, scopeId?: string): CapabilityRequest => ({
|
||||
capability: 'channel.send', channelType: 'EMAIL', target: 'dana@acme.com', payload, idempotencyKey: 'k1', ...(scopeId ? { scopeId } : {}),
|
||||
});
|
||||
|
||||
it('sends from the tenant identity when a scope has its own SMTP', async () => {
|
||||
const s = stub();
|
||||
const p = new SmtpProvider(ID, FB, s.make, undefined, async (sid) => (sid === 'scope_1' ? TENANT : null));
|
||||
const res = await p.send(scoped({ subject: 'Hi', text: 'x' }, 'scope_1'));
|
||||
expect(res.outcome).toBe('SENT');
|
||||
expect(s.calls[0]!.id).toEqual(TENANT);
|
||||
expect(s.calls[0]!.mail.from).toBe('Acme <no-reply@acme.com>');
|
||||
});
|
||||
|
||||
it('falls back to the platform identity when the scope has no SMTP', async () => {
|
||||
const s = stub();
|
||||
const p = new SmtpProvider(ID, FB, s.make, undefined, async () => null);
|
||||
await p.send(scoped({ subject: 'Hi', text: 'x' }, 'scope_2'));
|
||||
expect(s.calls[0]!.id).toEqual(ID);
|
||||
});
|
||||
|
||||
it('fails closed (NOT_CONFIGURED) when there is neither a tenant nor a platform identity', async () => {
|
||||
const s = stub();
|
||||
const p = new SmtpProvider(undefined, undefined, s.make, undefined, async () => null);
|
||||
const res = await p.send(scoped({ subject: 'Hi', text: 'x' }, 'scope_3'));
|
||||
expect(res.outcome).toBe('FAILED');
|
||||
expect(res.errorCode).toBe('NOT_CONFIGURED');
|
||||
expect(s.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('smtpIdentityFromConfig maps a stored config (fromName + email → from)', async () => {
|
||||
const { smtpIdentityFromConfig } = await import('./smtp.provider');
|
||||
expect(smtpIdentityFromConfig({ host: 'h', port: 465, secure: true, user: 'u', pass: 'p', fromEmail: 'a@b.com', fromName: 'Acme' }))
|
||||
.toEqual({ host: 'h', port: 465, secure: true, user: 'u', pass: 'p', from: 'Acme <a@b.com>' });
|
||||
expect(smtpIdentityFromConfig({ host: 'h', user: 'u', pass: 'p' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user