Files
iios/docs/email-attachments-plan.md
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

3.8 KiB

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 envelopeSmtpProvider (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.