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
@@ -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. */