feat(mail): inbox mirror + INTERNAL (app-to-app) delivery

MailService renders a template then deposits it as an EMAIL interaction on a
per-email thread (thread model: one thread per email), reusing IngestService.
Because ingest adds no participants and a thread is only visible to its
participants, it ensureParticipant()s BOTH sender and recipient — so the mirror
is actually visible.

- postInternal: app-to-app mail, no SMTP → recipient's in-app inbox.
- sendExternalWithMirror: SMTP send (TemplatedSender) + mirror an interaction
  ONLY for a registered recipient (pre-registration sends are email-only — no
  inbox exists yet). Idempotent across both the send and the mirror.
- Writes Interactions, NEVER InboxItems (the projector owns those — KG-15).
- POST /v1/mail/internal, POST /v1/mail/send. MailModule in AppModule.

Verified over HTTP: internal mail → recipient SEES the thread in their inbox;
external send → command + mirror; no-source/no-auth 400. 7 unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 13:54:32 +05:30
parent bba5fae061
commit 9b075f46f9
6 changed files with 316 additions and 0 deletions
@@ -0,0 +1,55 @@
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.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);
}
}