40c98522dc
ingest() puts metadata on the interaction, not the thread, and listThreads filters THREAD metadata — so MailService now tags the thread directly after deposit (source=crm-mail). Lets the CRM list mail threads apart from Messenger chat (source=crm-messenger). 7 tests. NOTE: requires an IIOS redeploy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
122 lines
6.3 KiB
TypeScript
122 lines
6.3 KiB
TypeScript
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<string, unknown>;
|
|
locale?: string;
|
|
idempotencyKey: string;
|
|
}
|
|
|
|
export interface SendExternalInput {
|
|
source: TemplateSource;
|
|
target: string; // email address (external)
|
|
vars?: Record<string, unknown>;
|
|
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 };
|
|
}
|
|
}
|