feat(smtp): SMTP egress provider (nodemailer) for the EMAIL channel

Makes external email actually leave the building (was sandbox-only). SmtpProvider
implements CapabilityProvider; env-activated (accounts@ primary, ceo@ fallback);
transporter injected for tests.

- send() maps target+payload -> {from,to,subject,html,text,inReplyTo,references};
  providerRef = nodemailer's real Message-ID (so replies thread via In-Reply-To).
- Fallback ONLY on pre-acceptance failures (connect/auth/timeout) — a post-acceptance
  error is terminal, so a message the server already took can't be double-delivered.
- Never throws — transport failure -> FAILED, per the adapter doctrine.
- Registry precedence via registration order: SMTP > HTTP relay > sandbox for EMAIL.

Verified: 13 unit tests (config/envelope/fallback/precedence) + a REAL SMTP round-trip
against nodemailer Ethereal (SENT, genuine Message-ID). Full suite 281/281, boundary+build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 13:31:04 +05:30
parent 7d7c75915a
commit 74cca2d534
6 changed files with 297 additions and 0 deletions
@@ -3,6 +3,7 @@ import type { CapabilityProvider } from '@insignia/iios-contracts';
import { SandboxProvider } from './sandbox.provider';
import { HttpProvider } from './http.provider';
import { EmailProvider } from './email.provider';
import { SmtpProvider, smtpFallbackFromEnv, smtpIdentityFromEnv } from './smtp.provider';
const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL'];
@@ -11,6 +12,9 @@ const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL'];
* default; if `IIOS_PROVIDER_URL_<CHANNELTYPE>` is set, a real HttpProvider
* overrides the sandbox for that channel (the "flip the binding" swap). Unknown
* channels fail closed — no silent egress path.
*
* Precedence is registration ORDER (register() does Map.set → last wins). For EMAIL:
* sandbox → HTTP EmailProvider (if URL set) → SMTP (if SMTP env set), so SMTP > HTTP > sandbox.
*/
@Injectable()
export class CapabilityProviderRegistry {
@@ -24,6 +28,9 @@ export class CapabilityProviderRegistry {
// EMAIL gets an email-shaped envelope provider; other channels use the generic HTTP one.
this.register(ch === 'EMAIL' ? new EmailProvider(url) : new HttpProvider(ch, url));
}
// Real SMTP for EMAIL wins over the HTTP relay when configured (registered last).
const smtp = smtpIdentityFromEnv();
if (smtp) this.register(new SmtpProvider(smtp, smtpFallbackFromEnv() ?? undefined));
}
register(provider: CapabilityProvider): void {