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,30 @@
import { Type } from 'class-transformer';
import { IsInt, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
import { InlineTemplateDto } from '../templates/template.dto';
/** POST /v1/mail/internal — app-to-app mail (no SMTP). Provide EITHER `key` OR `inline`. */
export class MailInternalDto {
@IsOptional() @IsString() @IsNotEmpty() key?: string;
@IsOptional() @IsInt() version?: number;
@IsOptional() @ValidateNested() @Type(() => InlineTemplateDto) inline?: InlineTemplateDto;
@IsString() @IsNotEmpty() recipientUserId!: string;
@IsOptional() @IsObject() vars?: Record<string, unknown>;
@IsOptional() @IsString() locale?: string;
@IsString() @IsNotEmpty() idempotencyKey!: string;
}
/** POST /v1/mail/send — external email via SMTP + optional in-app mirror for a registered recipient. */
export class MailSendDto {
@IsOptional() @IsString() @IsNotEmpty() key?: string;
@IsOptional() @IsInt() version?: number;
@IsOptional() @ValidateNested() @Type(() => InlineTemplateDto) inline?: InlineTemplateDto;
@IsString() @IsNotEmpty() target!: string;
@IsOptional() @IsObject() vars?: Record<string, unknown>;
@IsOptional() @IsString() locale?: string;
@IsString() @IsNotEmpty() idempotencyKey!: string;
@IsOptional() @IsString() purpose?: string;
/** The registered recipient to mirror to; omit for a pre-registration send (email only). */
@IsOptional() @IsString() mirrorToUserId?: string;
}