d28c873d40
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>
57 lines
2.6 KiB
TypeScript
57 lines
2.6 KiB
TypeScript
import { BadRequestException, Body, Controller, Headers, Post } from '@nestjs/common';
|
|
import { SessionVerifier } from '../platform/session.verifier';
|
|
import type { MessagePrincipal } from '../identity/actor.resolver';
|
|
import { MailService } from './mail.service';
|
|
import { MailInternalDto, MailSendDto } from './mail.dto';
|
|
import type { TemplateSource } from '../templates/template.model';
|
|
|
|
@Controller('v1/mail')
|
|
export class MailController {
|
|
constructor(
|
|
private readonly mail: MailService,
|
|
private readonly session: SessionVerifier,
|
|
) {}
|
|
|
|
/** App-to-app mail — renders a template and posts it into the recipient's in-app inbox (no SMTP). */
|
|
@Post('internal')
|
|
async internal(@Body() body: MailInternalDto, @Headers('authorization') authorization?: string) {
|
|
const principal = this.principal(authorization);
|
|
return this.mail.postInternal(principal, {
|
|
source: this.source(body),
|
|
recipientUserId: body.recipientUserId,
|
|
vars: body.vars ?? {},
|
|
...(body.locale ? { locale: body.locale } : {}),
|
|
idempotencyKey: body.idempotencyKey,
|
|
});
|
|
}
|
|
|
|
/** External email (SMTP) + an in-app mirror when a registered recipient is named. */
|
|
@Post('send')
|
|
async send(@Body() body: MailSendDto, @Headers('authorization') authorization?: string) {
|
|
const principal = this.principal(authorization);
|
|
return this.mail.sendExternalWithMirror(principal, {
|
|
source: this.source(body),
|
|
target: body.target,
|
|
vars: body.vars ?? {},
|
|
...(body.locale ? { locale: body.locale } : {}),
|
|
idempotencyKey: body.idempotencyKey,
|
|
...(body.purpose ? { purpose: body.purpose } : {}),
|
|
...(body.attachments && body.attachments.length > 0 ? { attachments: body.attachments } : {}),
|
|
...(body.mirrorToUserId ? { mirrorToUserId: body.mirrorToUserId } : {}),
|
|
});
|
|
}
|
|
|
|
private source(body: { key?: string; version?: number; inline?: { subject?: string; html?: string; text?: string; variables?: string[] } }): TemplateSource {
|
|
if (body.inline && body.key) throw new BadRequestException('provide either "key" or "inline", not both');
|
|
if (body.inline) return { inline: body.inline };
|
|
if (body.key) return body.version != null ? { key: body.key, version: body.version } : { key: body.key };
|
|
throw new BadRequestException('one of "key" or "inline" is required');
|
|
}
|
|
|
|
private principal(authorization?: string): MessagePrincipal {
|
|
const token = (authorization ?? '').replace(/^Bearer\s+/i, '');
|
|
if (!token) throw new BadRequestException('Authorization bearer token is required');
|
|
return this.session.verify(token);
|
|
}
|
|
}
|