Feat/s3 storage #4
@@ -27,6 +27,7 @@ export interface IngestInteractionRequest {
|
|||||||
bodyText?: string;
|
bodyText?: string;
|
||||||
contentRef?: string;
|
contentRef?: string;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
|
sizeBytes?: number;
|
||||||
}>;
|
}>;
|
||||||
occurredAt: string;
|
occurredAt: string;
|
||||||
providerEventId?: string;
|
providerEventId?: string;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
|
IsInt,
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsObject,
|
IsObject,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
@@ -40,6 +41,7 @@ class PartDto {
|
|||||||
@IsOptional() @IsString() bodyText?: string;
|
@IsOptional() @IsString() bodyText?: string;
|
||||||
@IsOptional() @IsString() contentRef?: string;
|
@IsOptional() @IsString() contentRef?: string;
|
||||||
@IsOptional() @IsString() mimeType?: string;
|
@IsOptional() @IsString() mimeType?: string;
|
||||||
|
@IsOptional() @IsInt() sizeBytes?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validates the POST /v1/interactions/ingest body (conforms to IngestInteractionRequest). */
|
/** Validates the POST /v1/interactions/ingest body (conforms to IngestInteractionRequest). */
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ export class IngestService {
|
|||||||
bodyText: p.bodyText,
|
bodyText: p.bodyText,
|
||||||
contentRef: p.contentRef,
|
contentRef: p.contentRef,
|
||||||
mimeType: p.mimeType,
|
mimeType: p.mimeType,
|
||||||
|
sizeBytes: p.sizeBytes != null ? BigInt(p.sizeBytes) : undefined,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export class MailController {
|
|||||||
vars: body.vars ?? {},
|
vars: body.vars ?? {},
|
||||||
...(body.locale ? { locale: body.locale } : {}),
|
...(body.locale ? { locale: body.locale } : {}),
|
||||||
idempotencyKey: body.idempotencyKey,
|
idempotencyKey: body.idempotencyKey,
|
||||||
|
...(body.attachments && body.attachments.length > 0 ? { attachments: body.attachments } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export class AttachmentDto {
|
|||||||
@IsOptional() @IsString() filename?: string;
|
@IsOptional() @IsString() filename?: string;
|
||||||
@IsString() @IsNotEmpty() contentRef!: string;
|
@IsString() @IsNotEmpty() contentRef!: string;
|
||||||
@IsOptional() @IsString() mimeType?: string;
|
@IsOptional() @IsString() mimeType?: string;
|
||||||
|
@IsOptional() @IsInt() sizeBytes?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST /v1/mail/internal — app-to-app mail (no SMTP). Provide EITHER `key` OR `inline`. */
|
/** POST /v1/mail/internal — app-to-app mail (no SMTP). Provide EITHER `key` OR `inline`. */
|
||||||
@@ -19,6 +20,8 @@ export class MailInternalDto {
|
|||||||
@IsOptional() @IsObject() vars?: Record<string, unknown>;
|
@IsOptional() @IsObject() vars?: Record<string, unknown>;
|
||||||
@IsOptional() @IsString() locale?: string;
|
@IsOptional() @IsString() locale?: string;
|
||||||
@IsString() @IsNotEmpty() idempotencyKey!: string;
|
@IsString() @IsNotEmpty() idempotencyKey!: string;
|
||||||
|
/** In-app attachment refs — stored as message parts so the recipient's inbox can render them. */
|
||||||
|
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => AttachmentDto) attachments?: AttachmentDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST /v1/mail/send — external email via SMTP + optional in-app mirror for a registered recipient. */
|
/** POST /v1/mail/send — external email via SMTP + optional in-app mirror for a registered recipient. */
|
||||||
|
|||||||
@@ -66,6 +66,20 @@ describe('MailService.postInternal', () => {
|
|||||||
expect(aliceThreads.map((t) => t.threadId)).toContain(res.threadId);
|
expect(aliceThreads.map((t) => t.threadId)).toContain(res.threadId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('stores an in-app attachment as a media part alongside the body', async () => {
|
||||||
|
const res = await mail().postInternal(alice, {
|
||||||
|
source: inline, recipientUserId: 'bob', idempotencyKey: 'int:att',
|
||||||
|
attachments: [{ filename: 'roof.png', contentRef: 'scope/roof.png', mimeType: 'image/png', sizeBytes: 2048 }],
|
||||||
|
});
|
||||||
|
const interaction = await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: res.interactionId }, include: { parts: true } });
|
||||||
|
const file = interaction.parts.find((p) => p.contentRef);
|
||||||
|
expect(file).toBeDefined();
|
||||||
|
expect(file!.kind).toBe('MEDIA_REF'); // image/* → MEDIA_REF
|
||||||
|
expect(file!.contentRef).toBe('scope/roof.png');
|
||||||
|
expect(file!.mimeType).toBe('image/png');
|
||||||
|
expect(file!.sizeBytes != null ? Number(file!.sizeBytes) : null).toBe(2048);
|
||||||
|
});
|
||||||
|
|
||||||
it('is idempotent per key — a replay reuses the same thread, no duplicate interaction', async () => {
|
it('is idempotent per key — a replay reuses the same thread, no duplicate interaction', async () => {
|
||||||
const a = await mail().postInternal(alice, { source: inline, recipientUserId: 'bob', idempotencyKey: 'int:dup' });
|
const a = await mail().postInternal(alice, { source: inline, recipientUserId: 'bob', idempotencyKey: 'int:dup' });
|
||||||
const b = await mail().postInternal(alice, { source: inline, recipientUserId: 'bob', idempotencyKey: 'int:dup' });
|
const b = await mail().postInternal(alice, { source: inline, recipientUserId: 'bob', idempotencyKey: 'int:dup' });
|
||||||
|
|||||||
@@ -20,12 +20,16 @@ export function contentToParts(content: RenderedContent): { subject?: string; pa
|
|||||||
return { ...(content.subject != null ? { subject: content.subject } : {}), parts };
|
return { ...(content.subject != null ? { subject: content.subject } : {}), parts };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MailAttachment { filename?: string; contentRef: string; mimeType?: string; sizeBytes?: number }
|
||||||
|
|
||||||
export interface PostInternalInput {
|
export interface PostInternalInput {
|
||||||
source: TemplateSource;
|
source: TemplateSource;
|
||||||
recipientUserId: string;
|
recipientUserId: string;
|
||||||
vars?: Record<string, unknown>;
|
vars?: Record<string, unknown>;
|
||||||
locale?: string;
|
locale?: string;
|
||||||
idempotencyKey: string;
|
idempotencyKey: string;
|
||||||
|
/** In-app attachment refs — stored as FILE message parts alongside the body. */
|
||||||
|
attachments?: MailAttachment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SendExternalInput {
|
export interface SendExternalInput {
|
||||||
@@ -65,7 +69,7 @@ export class MailService {
|
|||||||
|
|
||||||
async postInternal(principal: MessagePrincipal, input: PostInternalInput): Promise<{ threadId: string; interactionId: string }> {
|
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 } : {}) });
|
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');
|
return this.deposit(principal, input.recipientUserId, content, input.idempotencyKey, 'PORTAL', input.attachments);
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendExternalWithMirror(principal: MessagePrincipal, input: SendExternalInput): Promise<{ commandId: string; mirror?: { threadId: string; interactionId: string } }> {
|
async sendExternalWithMirror(principal: MessagePrincipal, input: SendExternalInput): Promise<{ commandId: string; mirror?: { threadId: string; interactionId: string } }> {
|
||||||
@@ -79,13 +83,21 @@ export class MailService {
|
|||||||
if (!input.mirrorToUserId) return { commandId: command.id };
|
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 { 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');
|
const mirror = await this.deposit(principal, input.mirrorToUserId, content, `mirror:${input.idempotencyKey}`, 'EMAIL', input.attachments);
|
||||||
return { commandId: command.id, mirror };
|
return { commandId: command.id, mirror };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A stored media ref → a message part; kind follows the mime (image/video → MEDIA_REF, audio → VOICE_REF, else FILE_REF). */
|
||||||
|
private attachmentPart(a: MailAttachment): { kind: 'MEDIA_REF' | 'VOICE_REF' | 'FILE_REF'; bodyText?: string; contentRef: string; mimeType?: string; sizeBytes?: number } {
|
||||||
|
const mime = a.mimeType ?? '';
|
||||||
|
const kind = /^(image|video)\//.test(mime) ? 'MEDIA_REF' : /^audio\//.test(mime) ? 'VOICE_REF' : 'FILE_REF';
|
||||||
|
return { kind, ...(a.filename ? { bodyText: a.filename } : {}), contentRef: a.contentRef, ...(a.mimeType ? { mimeType: a.mimeType } : {}), ...(a.sizeBytes != null ? { sizeBytes: a.sizeBytes } : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
/** Ingest the rendered content as an EMAIL interaction on a per-email thread, visible to both parties. */
|
/** 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 }> {
|
private async deposit(principal: MessagePrincipal, recipientUserId: string, content: RenderedContent, key: string, channelType: string, attachments?: MailAttachment[]): Promise<{ threadId: string; interactionId: string }> {
|
||||||
const { subject, parts } = contentToParts(content);
|
const { subject, parts } = contentToParts(content);
|
||||||
|
const allParts = [...parts, ...(attachments ?? []).map((a) => this.attachmentPart(a))];
|
||||||
const res = await this.ingest.ingest(
|
const res = await this.ingest.ingest(
|
||||||
{
|
{
|
||||||
scope: { orgId: principal.orgId, appId: principal.appId, ...(principal.tenantId ? { tenantId: principal.tenantId } : {}) },
|
scope: { orgId: principal.orgId, appId: principal.appId, ...(principal.tenantId ? { tenantId: principal.tenantId } : {}) },
|
||||||
@@ -93,7 +105,7 @@ export class MailService {
|
|||||||
source: { handleKind: 'PORTAL_USER', externalId: principal.userId, ...(principal.displayName ? { displayName: principal.displayName } : {}) },
|
source: { handleKind: 'PORTAL_USER', externalId: principal.userId, ...(principal.displayName ? { displayName: principal.displayName } : {}) },
|
||||||
kind: 'EMAIL',
|
kind: 'EMAIL',
|
||||||
thread: { externalThreadId: key, ...(subject ? { subject } : {}) },
|
thread: { externalThreadId: key, ...(subject ? { subject } : {}) },
|
||||||
parts,
|
parts: allParts,
|
||||||
occurredAt: new Date().toISOString(),
|
occurredAt: new Date().toISOString(),
|
||||||
providerEventId: key,
|
providerEventId: key,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export interface ThreadMessage {
|
|||||||
actorId: string | null;
|
actorId: string | null;
|
||||||
kind: string;
|
kind: string;
|
||||||
occurredAt: Date;
|
occurredAt: Date;
|
||||||
parts: Array<{ kind: string; bodyText: string | null; contentRef: string | null }>;
|
parts: Array<{ kind: string; bodyText: string | null; contentRef: string | null; mimeType: string | null; sizeBytes: number | null }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -39,7 +39,7 @@ export class ThreadsService {
|
|||||||
actorId: i.actorId,
|
actorId: i.actorId,
|
||||||
kind: i.kind,
|
kind: i.kind,
|
||||||
occurredAt: i.occurredAt,
|
occurredAt: i.occurredAt,
|
||||||
parts: i.parts.map((p) => ({ kind: p.kind, bodyText: p.bodyText, contentRef: p.contentRef })),
|
parts: i.parts.map((p) => ({ kind: p.kind, bodyText: p.bodyText, contentRef: p.contentRef, mimeType: p.mimeType, sizeBytes: p.sizeBytes != null ? Number(p.sizeBytes) : null })),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user