import { Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { IngestService } from '../interactions/ingest.service'; import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver'; import { TemplateService } from '../templates/template.service'; import { TemplatedSender } from '../templates/templated-sender'; import type { RenderedContent } from '../templates/template.renderer'; import type { TemplateSource } from '../templates/template.model'; export interface MailPart { kind: 'HTML' | 'TEXT'; bodyText: string } /** Turn rendered content into ingest parts (HTML + TEXT) + the thread subject. Pure. */ export function contentToParts(content: RenderedContent): { subject?: string; parts: MailPart[] } { const parts: MailPart[] = []; if (content.html != null) parts.push({ kind: 'HTML', bodyText: content.html }); if (content.text != null) parts.push({ kind: 'TEXT', bodyText: content.text }); // A message must carry at least one part; fall back to an empty text part rather than fail ingest. if (parts.length === 0) parts.push({ kind: 'TEXT', bodyText: '' }); return { ...(content.subject != null ? { subject: content.subject } : {}), parts }; } export interface PostInternalInput { source: TemplateSource; recipientUserId: string; vars?: Record; locale?: string; idempotencyKey: string; } export interface SendExternalInput { source: TemplateSource; target: string; // email address (external) vars?: Record; locale?: string; idempotencyKey: string; purpose?: string; /** Attachment refs (bytes resolved at send time). */ attachments?: Array<{ filename?: string; contentRef: string; mimeType?: string }>; /** The registered recipient to mirror to; omit for a pre-registration send (email only, no mirror). */ mirrorToUserId?: string; } /** * Mail orchestration: render a template, then deliver it. * - INTERNAL (app-to-app): create an EMAIL interaction on a per-email thread — no SMTP. * - EXTERNAL: send via SMTP (TemplatedSender) AND mirror a copy into the recipient's in-app inbox, * but only when they are a registered user (pre-registration sends have no inbox yet). * * ingest() writes the interaction + thread but adds no participants, and a thread is only visible to * its participants — so after each ingest we add BOTH the sender and the recipient as participants. */ @Injectable() export class MailService { constructor( private readonly templates: TemplateService, private readonly ingest: IngestService, private readonly sender: TemplatedSender, private readonly actors: ActorResolver, private readonly prisma: PrismaService, ) {} /** Marks a thread as CRM mail so it lists separately from chat (Messenger uses source=crm-messenger). */ static readonly SOURCE = 'crm-mail'; async postInternal(principal: MessagePrincipal, input: PostInternalInput): Promise<{ threadId: string; interactionId: string }> { const { content } = await this.templates.render(input.source, input.vars ?? {}, { channel: 'INTERNAL', ...(input.locale ? { locale: input.locale } : {}) }); return this.deposit(principal, input.recipientUserId, content, input.idempotencyKey, 'PORTAL'); } async sendExternalWithMirror(principal: MessagePrincipal, input: SendExternalInput): Promise<{ commandId: string; mirror?: { threadId: string; interactionId: string } }> { const command = await this.sender.sendTemplated({ source: input.source, channel: 'EMAIL', target: input.target, vars: input.vars ?? {}, ...(input.locale ? { locale: input.locale } : {}), idempotencyKey: input.idempotencyKey, ...(input.purpose ? { purpose: input.purpose } : {}), ...(input.attachments && input.attachments.length > 0 ? { attachments: input.attachments } : {}), }); // Mirror only for a registered recipient (timing rule: no inbox exists pre-registration). if (!input.mirrorToUserId) return { commandId: command.id }; const { content } = await this.templates.render(input.source, input.vars ?? {}, { channel: 'EMAIL', ...(input.locale ? { locale: input.locale } : {}) }); const mirror = await this.deposit(principal, input.mirrorToUserId, content, `mirror:${input.idempotencyKey}`, 'EMAIL'); return { commandId: command.id, mirror }; } /** Ingest the rendered content as an EMAIL interaction on a per-email thread, visible to both parties. */ private async deposit(principal: MessagePrincipal, recipientUserId: string, content: RenderedContent, key: string, channelType: string): Promise<{ threadId: string; interactionId: string }> { const { subject, parts } = contentToParts(content); const res = await this.ingest.ingest( { scope: { orgId: principal.orgId, appId: principal.appId, ...(principal.tenantId ? { tenantId: principal.tenantId } : {}) }, channel: { type: channelType, externalChannelId: channelType.toLowerCase() }, source: { handleKind: 'PORTAL_USER', externalId: principal.userId, ...(principal.displayName ? { displayName: principal.displayName } : {}) }, kind: 'EMAIL', thread: { externalThreadId: key, ...(subject ? { subject } : {}) }, parts, occurredAt: new Date().toISOString(), providerEventId: key, }, key, ); // Make the thread visible to both the sender and the recipient (ingest adds no participants). const scope = await this.actors.resolveScope(principal); const senderActor = await this.actors.resolveActor(scope.id, principal); const recipientActor = await this.actors.resolveActor(scope.id, { userId: recipientUserId, appId: principal.appId, orgId: principal.orgId, ...(principal.tenantId ? { tenantId: principal.tenantId } : {}), }); await this.actors.ensureParticipant(res.threadId, senderActor.id); await this.actors.ensureParticipant(res.threadId, recipientActor.id); // Tag the thread as CRM mail (thread metadata) so it lists separately from Messenger chat. // ingest() puts req.metadata on the interaction, not the thread, so we set it here directly. await this.prisma.iiosThread.update({ where: { id: res.threadId }, data: { metadata: { source: MailService.SOURCE } as Prisma.InputJsonValue }, }); return { threadId: res.threadId, interactionId: res.interactionId }; } }