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:
@@ -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.
|
||||
Reference in New Issue
Block a user