From d28c873d4069cf249097d96a499d64ed80813c40 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 18 Jul 2026 14:14:20 +0530 Subject: [PATCH] feat(mail): email attachments over SMTP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/email-attachments-plan.md | 67 +++++++++++++++++++ .../src/capability/capability.module.ts | 2 + .../src/capability/capability.registry.ts | 15 +++-- .../src/capability/smtp.provider.spec.ts | 47 ++++++++++++- .../src/capability/smtp.provider.ts | 40 +++++++++++ .../iios-service/src/mail/mail.controller.ts | 1 + packages/iios-service/src/mail/mail.dto.ts | 10 ++- .../iios-service/src/mail/mail.service.ts | 3 + .../iios-service/src/media/media.module.ts | 2 + .../src/templates/templated-sender.spec.ts | 10 +++ .../src/templates/templated-sender.ts | 12 +++- 11 files changed, 199 insertions(+), 10 deletions(-) create mode 100644 docs/email-attachments-plan.md diff --git a/docs/email-attachments-plan.md b/docs/email-attachments-plan.md new file mode 100644 index 0000000..855d304 --- /dev/null +++ b/docs/email-attachments-plan.md @@ -0,0 +1,67 @@ +# Email Attachments — Implementation Plan + +**Owner:** Maaz · **Repo:** `iios` (`packages/iios-service`) · Extends the SMTP provider + mail plumbing. + +## Goal + +Let an external email carry attachments (e.g. an invoice PDF). The kernel already STORES attachments +as message parts (`contentRef` + mime + size); the media `StoragePort` holds the bytes. The one gap is +the **email envelope** — `SmtpProvider` (and the payload) don't carry attachments. Close that. + +## Design (locked) + +- **Payload carries REFS, not bytes:** the EMAIL payload gains + `attachments?: [{ filename, contentRef, mimeType? }]`. Refs keep the outbound-command ledger small + (and keep T8's PII redaction cheap) — bytes are fetched at send time. +- **Resolver seam:** `AttachmentResolver = (contentRef) => Promise<{ filename?; content: Buffer; contentType? } | null>`. + `SmtpProvider` takes an optional resolver; on send it resolves each ref and attaches + (nodemailer `attachments: [{ filename, content, contentType }]`). +- **Fail closed on a missing attachment:** if a declared attachment can't be resolved, the send is + `FAILED` (so it retries) — NOT sent without it. A receipt/invoice missing its file is worse than a + retry. (No resolver wired at all + attachments present → also FAILED, same reasoning.) +- **Wiring:** `MediaModule` exports `STORAGE_PORT`; `CapabilityModule` imports `MediaModule`; + `CapabilityProviderRegistry` `@Optional() @Inject(STORAGE_PORT)` → builds the resolver from + `storage.get(contentRef)` → passes it to `SmtpProvider`. `@Optional` so contexts without storage + still boot (attachments simply can't resolve → FAILED if any are declared). +- **Scope:** SMTP path only. The HTTP relay `EmailProvider` attachment support is a separate follow-up + (it would base64 the bytes into the relay POST). INTERNAL/mirror attachments already work via message + parts and are not this plan. + +## Files + +``` +src/capability/smtp.provider.ts # attachments in EmailPayload + resolve+attach in send() +src/capability/smtp.provider.spec.ts # attach resolved bytes; missing → FAILED +src/capability/capability.registry.ts # inject STORAGE_PORT → resolver → SmtpProvider +src/capability/capability.module.ts # import MediaModule +src/media/media.module.ts # export STORAGE_PORT +src/templates/templated-sender.ts # accept + pass `attachments` +src/mail/mail.service.ts # accept + pass `attachments` (external send) +``` + +## Tasks (TDD) + +**T1 — SmtpProvider attaches / fails closed** +- Inject a stub resolver. Tests: two refs → nodemailer `attachments` has both (filename + content + + contentType); a ref the resolver returns `null` for → outcome `FAILED`, nothing sent; no attachments + in payload → unchanged (plain send still SENT). + +**T2 — registry wires the resolver from STORAGE_PORT** +- `MediaModule` exports `STORAGE_PORT`; `CapabilityModule` imports `MediaModule`; registry injects it + `@Optional`. Test: with a fake storage bound, `forChannel('EMAIL')` SMTP resolves an attachment; + without storage, the registry still constructs (attachments would FAIL, but boot is fine). + +**T3 — pass-through: TemplatedSender + MailService** +- `sendTemplated`/`sendExternalWithMirror` accept `attachments` and place them in the EMAIL payload. + Tests: the outbound command's payload carries the attachment refs. + +**T4 — gate + real send** +- Full suite + boundary + build. Manual: a real Ethereal send with a small attachment → SENT, and the + Ethereal message shows the attachment. + +## Risks +- **Fail-closed is deliberate** — don't silently send an invoice email without the invoice. +- **Payload holds refs, not bytes** — so the ledger and T8 redaction stay small; the resolver reads + bytes only at send time. +- **`@Optional` storage** — a context without `STORAGE_PORT` boots fine but can't send attachments; + that's correct (fail-closed), not a silent drop. diff --git a/packages/iios-service/src/capability/capability.module.ts b/packages/iios-service/src/capability/capability.module.ts index 2809916..6eac20c 100644 --- a/packages/iios-service/src/capability/capability.module.ts +++ b/packages/iios-service/src/capability/capability.module.ts @@ -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], }) diff --git a/packages/iios-service/src/capability/capability.registry.ts b/packages/iios-service/src/capability/capability.registry.ts index fa5cd98..4728e7a 100644 --- a/packages/iios-service/src/capability/capability.registry.ts +++ b/packages/iios-service/src/capability/capability.registry.ts @@ -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(); - 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 { diff --git a/packages/iios-service/src/capability/smtp.provider.spec.ts b/packages/iios-service/src/capability/smtp.provider.spec.ts index eb99c47..4aed5b6 100644 --- a/packages/iios-service/src/capability/smtp.provider.spec.ts +++ b/packages/iios-service/src/capability/smtp.provider.spec.ts @@ -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) => + 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(); + }); +}); diff --git a/packages/iios-service/src/capability/smtp.provider.ts b/packages/iios-service/src/capability/smtp.provider.ts index 675a5c6..2485b3a 100644 --- a/packages/iios-service/src/capability/smtp.provider.ts +++ b/packages/iios-service/src/capability/smtp.provider.ts @@ -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. */ diff --git a/packages/iios-service/src/mail/mail.controller.ts b/packages/iios-service/src/mail/mail.controller.ts index 968b1ff..8cc2b1c 100644 --- a/packages/iios-service/src/mail/mail.controller.ts +++ b/packages/iios-service/src/mail/mail.controller.ts @@ -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 } : {}), }); } diff --git a/packages/iios-service/src/mail/mail.dto.ts b/packages/iios-service/src/mail/mail.dto.ts index 92844aa..0847751 100644 --- a/packages/iios-service/src/mail/mail.dto.ts +++ b/packages/iios-service/src/mail/mail.dto.ts @@ -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; } diff --git a/packages/iios-service/src/mail/mail.service.ts b/packages/iios-service/src/mail/mail.service.ts index 7d696f5..f0ed3a3 100644 --- a/packages/iios-service/src/mail/mail.service.ts +++ b/packages/iios-service/src/mail/mail.service.ts @@ -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). diff --git a/packages/iios-service/src/media/media.module.ts b/packages/iios-service/src/media/media.module.ts index 7f1df2a..a9c00ff 100644 --- a/packages/iios-service/src/media/media.module.ts +++ b/packages/iios-service/src/media/media.module.ts @@ -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 {} diff --git a/packages/iios-service/src/templates/templated-sender.spec.ts b/packages/iios-service/src/templates/templated-sender.spec.ts index b1f52e2..fc2d6b5 100644 --- a/packages/iios-service/src/templates/templated-sender.spec.ts +++ b/packages/iios-service/src/templates/templated-sender.spec.ts @@ -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'] }, diff --git a/packages/iios-service/src/templates/templated-sender.ts b/packages/iios-service/src/templates/templated-sender.ts index b7a96b6..3d8acaf 100644 --- a/packages/iios-service/src/templates/templated-sender.ts +++ b/packages/iios-service/src/templates/templated-sender.ts @@ -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:" 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 { - if (channel === 'EMAIL') return { subject: content.subject, html: content.html, text: content.text }; + private toPayload(channel: ExternalChannel, content: RenderedContent, attachments?: AttachmentRef[]): Record { + 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 } }