Files
iios/packages/iios-service/src/capability/capability.registry.ts
T
maaz519 d28c873d40 feat(mail): email attachments over SMTP
Email can now carry attachments (e.g. an invoice PDF). The kernel already stored
attachments as message parts + the media StoragePort holds the bytes; the gap was
the email envelope.

- EMAIL payload carries attachment REFS ({filename, contentRef, mimeType}), not
  bytes — the ledger + T8 PII redaction stay small; bytes are fetched at send time.
- SmtpProvider takes an AttachmentResolver; resolves each ref via storage and attaches
  (nodemailer). FAILS CLOSED if a declared attachment can't be resolved (or no resolver
  is wired) — never send a receipt/invoice missing its file; a FAILED command retries.
- CapabilityProviderRegistry injects the resolver from STORAGE_PORT (@Optional);
  MediaModule exports STORAGE_PORT, CapabilityModule imports MediaModule. No cycle.
- TemplatedSender + MailService + the /v1/mail/send DTO pass attachments through.

Verified: 6 new unit tests (attach, fail-closed x2, plain-unaffected, resolver,
pass-through) + a REAL Ethereal SMTP send WITH a PDF attachment (SENT). Full suite
294/294, boundary + build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:14:20 +05:30

56 lines
2.6 KiB
TypeScript

import { Inject, Injectable, NotFoundException, Optional } from '@nestjs/common';
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, storageResolver } from './smtp.provider';
import { STORAGE_PORT, type StoragePort } from '../media/storage.port';
const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL'];
/**
* Maps a channelType to the provider that executes egress for it. Sandbox by
* 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 {
private readonly byChannel = new Map<string, CapabilityProvider>();
constructor(@Optional() @Inject(STORAGE_PORT) private readonly storage?: StoragePort) {
for (const ch of DEFAULT_CHANNELS) this.register(new SandboxProvider([ch]));
for (const ch of DEFAULT_CHANNELS) {
const url = process.env[`IIOS_PROVIDER_URL_${ch}`];
if (!url) continue;
// 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). Attachments
// resolve through the media StoragePort when one is bound (else attachments FAIL closed).
const smtp = smtpIdentityFromEnv();
if (smtp) {
const resolver = this.storage ? storageResolver(this.storage) : undefined;
this.register(new SmtpProvider(smtp, smtpFallbackFromEnv() ?? undefined, undefined, resolver));
}
}
register(provider: CapabilityProvider): void {
for (const ch of provider.channelTypes) this.byChannel.set(ch, provider);
}
forChannel(channelType: string): CapabilityProvider {
const p = this.byChannel.get(channelType);
if (!p) throw new NotFoundException(`no egress provider registered for channel: ${channelType}`);
return p;
}
/** Ops view: which provider backs each channel. */
bindings(): Record<string, string> {
return Object.fromEntries([...this.byChannel.entries()].map(([ch, p]) => [ch, p.name]));
}
}