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
@@ -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'] },
@@ -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:<stripe_session_id>"
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<string, unknown> {
if (channel === 'EMAIL') return { subject: content.subject, html: content.html, text: content.text };
private toPayload(channel: ExternalChannel, content: RenderedContent, attachments?: AttachmentRef[]): Record<string, unknown> {
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
}
}