# 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.