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>
This commit is contained in:
2026-07-18 14:14:20 +05:30
parent 9b075f46f9
commit d28c873d40
11 changed files with 199 additions and 10 deletions
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { MediaModule } from '../media/media.module';
import { CapabilityProviderRegistry } from './capability.registry';
import { CapabilityBroker } from './capability.broker';
@@ -8,6 +9,7 @@ import { CapabilityBroker } from './capability.broker';
* provider. PLATFORM_PORTS (for the opa gate) comes from the @Global PlatformModule.
*/
@Module({
imports: [MediaModule],
providers: [CapabilityProviderRegistry, CapabilityBroker],
exports: [CapabilityBroker, CapabilityProviderRegistry],
})
@@ -1,9 +1,10 @@
import { Injectable, NotFoundException } from '@nestjs/common';
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 } from './smtp.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'];
@@ -20,7 +21,7 @@ const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL'];
export class CapabilityProviderRegistry {
private readonly byChannel = new Map<string, CapabilityProvider>();
constructor() {
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}`];
@@ -28,9 +29,13 @@ 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).
// 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) this.register(new SmtpProvider(smtp, smtpFallbackFromEnv() ?? undefined));
if (smtp) {
const resolver = this.storage ? storageResolver(this.storage) : undefined;
this.register(new SmtpProvider(smtp, smtpFallbackFromEnv() ?? undefined, undefined, resolver));
}
}
register(provider: CapabilityProvider): void {
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { SmtpProvider, smtpIdentityFromEnv, smtpFallbackFromEnv, type MailTransport, type SmtpIdentity } from './smtp.provider';
import { SmtpProvider, smtpIdentityFromEnv, smtpFallbackFromEnv, storageResolver, type MailTransport, type SmtpIdentity } from './smtp.provider';
import type { CapabilityRequest } from '@insignia/iios-contracts';
const ID: SmtpIdentity = { host: 'smtp.test', port: 587, secure: false, user: 'accounts@lynkeduppro.com', pass: 'p', from: 'accounts@lynkeduppro.com' };
@@ -88,3 +88,48 @@ describe('SmtpProvider.send — failure + fallback', () => {
expect(res.errorCode).toBe('ETIMEDOUT');
});
});
describe('SmtpProvider.send — attachments', () => {
const resolver = (map: Record<string, { content: Buffer; contentType?: string; filename?: string }>) =>
async (ref: string) => map[ref] ?? null;
it('resolves attachment refs to bytes and attaches them', async () => {
const t = stub();
const res = await new SmtpProvider(ID, undefined, t.make, resolver({ 'obj/1': { content: Buffer.from('PDFDATA'), contentType: 'application/pdf' } }))
.send(req({ subject: 'Invoice', attachments: [{ filename: 'invoice.pdf', contentRef: 'obj/1', mimeType: 'application/pdf' }] }));
expect(res.outcome).toBe('SENT');
expect(t.calls[0].mail.attachments).toEqual([{ filename: 'invoice.pdf', content: Buffer.from('PDFDATA'), contentType: 'application/pdf' }]);
});
it('FAILS closed when a declared attachment cannot be resolved (never sends without it)', async () => {
const t = stub();
const res = await new SmtpProvider(ID, undefined, t.make, resolver({}))
.send(req({ subject: 'Invoice', attachments: [{ contentRef: 'missing' }] }));
expect(res.outcome).toBe('FAILED');
expect(res.errorCode).toBe('ATTACHMENT_UNRESOLVED');
expect(t.calls).toHaveLength(0); // nothing sent
});
it('FAILS when attachments are requested but no resolver is wired', async () => {
const t = stub();
const res = await new SmtpProvider(ID, undefined, t.make) // no resolver
.send(req({ subject: 'x', attachments: [{ contentRef: 'obj/1' }] }));
expect(res).toMatchObject({ outcome: 'FAILED', errorCode: 'NO_ATTACHMENT_RESOLVER' });
});
it('a plain send with no attachments is unaffected', async () => {
const t = stub();
const res = await new SmtpProvider(ID, undefined, t.make, resolver({})).send(req({ subject: 'x', text: 'y' }));
expect(res.outcome).toBe('SENT');
expect(t.calls[0].mail.attachments).toBeUndefined();
});
});
describe('storageResolver (media StoragePort → AttachmentResolver)', () => {
it('maps storage bytes into an attachment; missing ref → null', async () => {
const storage = { get: async (k: string) => (k === 'obj/1' ? { data: Buffer.from('X'), mime: 'application/pdf' } : null) };
const r = storageResolver(storage);
expect(await r('obj/1')).toEqual({ content: Buffer.from('X'), contentType: 'application/pdf' });
expect(await r('nope')).toBeNull();
});
});
@@ -12,6 +12,23 @@ export interface SmtpIdentity {
from: string;
}
export interface MailAttachment {
filename: string;
content: Buffer;
contentType?: string;
}
/** Fetch an attachment's bytes by its opaque contentRef (backed by the media StoragePort). */
export type AttachmentResolver = (contentRef: string) => Promise<{ filename?: string; content: Buffer; contentType?: string } | null>;
/** Build an AttachmentResolver over the media StoragePort (contentRef = the storage object key). */
export function storageResolver(storage: { get(key: string): Promise<{ data: Buffer; mime: string } | null> }): AttachmentResolver {
return async (contentRef) => {
const o = await storage.get(contentRef);
return o ? { content: o.data, contentType: o.mime } : null;
};
}
/** The subset of a mail transport this provider needs — lets tests inject a stub (no live server). */
export interface MailTransport {
sendMail(mail: {
@@ -22,6 +39,7 @@ export interface MailTransport {
html?: string;
inReplyTo?: string;
references?: string;
attachments?: MailAttachment[];
}): Promise<{ messageId: string; accepted?: unknown[] }>;
}
@@ -71,6 +89,8 @@ interface EmailPayload {
text?: string;
html?: string;
inReplyTo?: string;
/** Attachment REFS (not bytes) — resolved to bytes at send time via the injected resolver. */
attachments?: Array<{ filename?: string; contentRef: string; mimeType?: string }>;
}
/**
@@ -90,6 +110,7 @@ export class SmtpProvider implements CapabilityProvider {
private readonly primary: SmtpIdentity,
private readonly fallback?: SmtpIdentity,
makeTransport?: (id: SmtpIdentity) => MailTransport,
private readonly resolveAttachment?: AttachmentResolver,
) {
this.makeTransport = makeTransport ?? defaultTransport;
}
@@ -98,6 +119,20 @@ export class SmtpProvider implements CapabilityProvider {
const started = Date.now();
const p = (req.payload ?? {}) as EmailPayload;
// Resolve attachment bytes up front. Fail CLOSED — never send an invoice/receipt email missing
// its file; a FAILED command retries instead. Resolved once so a fallback retry doesn't re-fetch.
let attachments: MailAttachment[] | undefined;
if (p.attachments && p.attachments.length > 0) {
if (!this.resolveAttachment) return this.failed('NO_ATTACHMENT_RESOLVER', started);
const out: MailAttachment[] = [];
for (const a of p.attachments) {
const r = await this.resolveAttachment(a.contentRef).catch(() => null);
if (!r) return this.failed('ATTACHMENT_UNRESOLVED', started);
out.push({ filename: a.filename ?? r.filename ?? 'attachment', content: r.content, ...(a.mimeType ?? r.contentType ? { contentType: a.mimeType ?? r.contentType } : {}) });
}
attachments = out;
}
const attempt = async (id: SmtpIdentity): Promise<{ messageId: string }> =>
this.makeTransport(id).sendMail({
from: id.from,
@@ -106,6 +141,7 @@ export class SmtpProvider implements CapabilityProvider {
text: p.text,
html: p.html,
...(p.inReplyTo ? { inReplyTo: p.inReplyTo, references: p.inReplyTo } : {}),
...(attachments ? { attachments } : {}),
});
try {
@@ -124,6 +160,10 @@ export class SmtpProvider implements CapabilityProvider {
return { providerRef: `smtp-error-${randomUUID().slice(0, 8)}`, outcome: 'FAILED', errorCode: codeOf(err), latencyMs: Date.now() - started };
}
}
private failed(errorCode: string, started: number): ProviderResult {
return { providerRef: `smtp-error-${randomUUID().slice(0, 8)}`, outcome: 'FAILED', errorCode, latencyMs: Date.now() - started };
}
}
/** True for connect/auth/timeout errors (server never accepted); false once the server responded 2xx. */
@@ -36,6 +36,7 @@ export class MailController {
...(body.locale ? { locale: body.locale } : {}),
idempotencyKey: body.idempotencyKey,
...(body.purpose ? { purpose: body.purpose } : {}),
...(body.attachments && body.attachments.length > 0 ? { attachments: body.attachments } : {}),
...(body.mirrorToUserId ? { mirrorToUserId: body.mirrorToUserId } : {}),
});
}
+9 -1
View File
@@ -1,7 +1,14 @@
import { Type } from 'class-transformer';
import { IsInt, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
import { IsArray, IsInt, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
import { InlineTemplateDto } from '../templates/template.dto';
/** An email attachment reference — bytes live in the media store under `contentRef`. */
export class AttachmentDto {
@IsOptional() @IsString() filename?: string;
@IsString() @IsNotEmpty() contentRef!: string;
@IsOptional() @IsString() mimeType?: string;
}
/** POST /v1/mail/internal — app-to-app mail (no SMTP). Provide EITHER `key` OR `inline`. */
export class MailInternalDto {
@IsOptional() @IsString() @IsNotEmpty() key?: string;
@@ -25,6 +32,7 @@ export class MailSendDto {
@IsOptional() @IsString() locale?: string;
@IsString() @IsNotEmpty() idempotencyKey!: string;
@IsOptional() @IsString() purpose?: string;
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => AttachmentDto) attachments?: AttachmentDto[];
/** The registered recipient to mirror to; omit for a pre-registration send (email only). */
@IsOptional() @IsString() mirrorToUserId?: string;
}
@@ -33,6 +33,8 @@ export interface SendExternalInput {
locale?: string;
idempotencyKey: string;
purpose?: string;
/** Attachment refs (bytes resolved at send time). */
attachments?: Array<{ filename?: string; contentRef: string; mimeType?: string }>;
/** The registered recipient to mirror to; omit for a pre-registration send (email only, no mirror). */
mirrorToUserId?: string;
}
@@ -64,6 +66,7 @@ export class MailService {
const command = await this.sender.sendTemplated({
source: input.source, channel: 'EMAIL', target: input.target, vars: input.vars ?? {},
...(input.locale ? { locale: input.locale } : {}), idempotencyKey: input.idempotencyKey, ...(input.purpose ? { purpose: input.purpose } : {}),
...(input.attachments && input.attachments.length > 0 ? { attachments: input.attachments } : {}),
});
// Mirror only for a registered recipient (timing rule: no inbox exists pre-registration).
@@ -11,5 +11,7 @@ import { STORAGE_PORT } from './storage.port';
controllers: [MediaController],
// Dev binds local disk; prod swaps STORAGE_PORT to an S3/Supabase adapter.
providers: [MediaService, { provide: STORAGE_PORT, useClass: LocalDiskStorage }],
// Exported so the capability layer can resolve email attachment bytes at send time.
exports: [STORAGE_PORT],
})
export class MediaModule {}
@@ -67,6 +67,16 @@ describe('TemplatedSender.sendTemplated', () => {
expect(cmd.renderedHash).toMatch(/^[a-f0-9]{64}$/);
});
it('carries attachment refs into the EMAIL command payload', async () => {
await seedReceipt();
const cmd = await sender().sendTemplated({
source: { key: 'payment.receipt' }, channel: 'EMAIL', target: 'dana@acme.com',
vars: { firstName: 'Dana', amount: '$1' }, idempotencyKey: 'att:1',
attachments: [{ filename: 'invoice.pdf', contentRef: 'obj/1', mimeType: 'application/pdf' }],
});
expect((cmd.payload as { attachments: unknown[] }).attachments).toEqual([{ filename: 'invoice.pdf', contentRef: 'obj/1', mimeType: 'application/pdf' }]);
});
it('shapes an SMS send as text only', async () => {
await prisma.iiosMessageTemplate.create({
data: { key: 'payment.receipt', channel: 'SMS', locale: 'en', version: 1, bodyText: 'Paid {{amount}}', variables: ['amount'] },
@@ -10,6 +10,9 @@ import { isInlineSource, type TemplateSource } from './template.model';
/** External-egress channels only. INTERNAL (in-app, no SMTP) delivery is the messaging module's job. */
export type ExternalChannel = 'EMAIL' | 'SMS';
/** An email attachment REF (bytes resolved by the provider at send time). */
export interface AttachmentRef { filename?: string; contentRef: string; mimeType?: string }
export interface SendTemplatedInput {
source: TemplateSource;
channel: ExternalChannel;
@@ -19,6 +22,7 @@ export interface SendTemplatedInput {
locale?: string;
idempotencyKey: string; // REQUIRED — e.g. "receipt:<stripe_session_id>"
purpose?: string;
attachments?: AttachmentRef[]; // EMAIL only
}
/**
@@ -48,7 +52,7 @@ export class TemplatedSender {
return this.outbound.send(
input.channel,
input.target,
this.toPayload(input.channel, content),
this.toPayload(input.channel, content, input.attachments),
input.idempotencyKey,
input.scopeId,
input.purpose,
@@ -57,8 +61,10 @@ export class TemplatedSender {
}
/** Shape rendered content into the channel's egress envelope (EmailProvider reads subject/text/html). */
private toPayload(channel: ExternalChannel, content: RenderedContent): Record<string, unknown> {
if (channel === 'EMAIL') return { subject: content.subject, html: content.html, text: content.text };
private toPayload(channel: ExternalChannel, content: RenderedContent, attachments?: AttachmentRef[]): Record<string, unknown> {
if (channel === 'EMAIL') {
return { subject: content.subject, html: content.html, text: content.text, ...(attachments && attachments.length > 0 ? { attachments } : {}) };
}
return { text: content.text ?? content.subject ?? '' }; // SMS: text only
}
}