diff --git a/src/lib/email/index.ts b/src/lib/email/index.ts index b9a2175..9dd82f8 100644 --- a/src/lib/email/index.ts +++ b/src/lib/email/index.ts @@ -33,6 +33,11 @@ function createTransporterFromConfig(cfg: PortEmailConfig): Transporter { }); } +export interface EmailAttachmentRef { + fileId: string; + filename?: string; +} + export interface SendEmailOptions { to: string | string[]; subject: string; @@ -41,6 +46,50 @@ export interface SendEmailOptions { /** When provided, port-level email settings override env defaults. */ portId?: string; text?: string; + /** + * File attachments to fetch from MinIO and attach to the message. + * Resolution + cross-port enforcement happens via `resolveAttachments` + * before the SMTP call. + */ + attachments?: EmailAttachmentRef[]; +} + +/** + * Resolve attachment refs to nodemailer attachment payloads. Reads each file + * from MinIO and enforces port-isolation: an attachment that doesn't belong + * to `portId` throws ForbiddenError. Returns an empty array when no refs + * are provided. + */ +async function resolveAttachments( + refs: EmailAttachmentRef[] | undefined, + portId: string | undefined, +): Promise> { + if (!refs || refs.length === 0) return []; + const { db } = await import('@/lib/db'); + const { files } = await import('@/lib/db/schema/documents'); + const { eq } = await import('drizzle-orm'); + const { ForbiddenError, NotFoundError } = await import('@/lib/errors'); + const { minioClient } = await import('@/lib/minio'); + + return Promise.all( + refs.map(async (ref) => { + const file = await db.query.files.findFirst({ where: eq(files.id, ref.fileId) }); + if (!file) throw new NotFoundError('File'); + if (portId && file.portId !== portId) { + throw new ForbiddenError('File belongs to a different port'); + } + const stream = await minioClient.getObject(file.storageBucket, file.storagePath); + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return { + filename: ref.filename ?? file.originalName, + content: Buffer.concat(chunks), + ...(file.mimeType ? { contentType: file.mimeType } : {}), + }; + }), + ); } /** @@ -57,6 +106,7 @@ export async function sendEmail( from?: string, text?: string, portId?: string, + attachments?: EmailAttachmentRef[], ): Promise { const cfg = portId ? await getPortEmailConfig(portId) : null; const transporter = cfg ? createTransporterFromConfig(cfg) : createTransporter(); @@ -73,6 +123,8 @@ export async function sendEmail( env.SMTP_FROM ?? `Port Nimara CRM `; + const resolvedAttachments = await resolveAttachments(attachments, portId); + const info = await transporter.sendMail({ from: fromHeader, to: effectiveTo, @@ -80,6 +132,7 @@ export async function sendEmail( html, ...(cfg?.replyTo ? { replyTo: cfg.replyTo } : {}), ...(text ? { text } : {}), + ...(resolvedAttachments.length > 0 ? { attachments: resolvedAttachments } : {}), }); logger.debug( diff --git a/src/lib/services/email-compose.service.ts b/src/lib/services/email-compose.service.ts index 8930b6c..606a435 100644 --- a/src/lib/services/email-compose.service.ts +++ b/src/lib/services/email-compose.service.ts @@ -3,9 +3,12 @@ import { and, eq, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { emailAccounts, emailMessages, emailThreads } from '@/lib/db/schema/email'; +import { documents, documentEvents, files } from '@/lib/db/schema/documents'; import { createAuditLog } from '@/lib/audit'; import { NotFoundError, ForbiddenError } from '@/lib/errors'; import { getDecryptedCredentials } from '@/lib/services/email-accounts.service'; +import { getPortEmailConfig } from '@/lib/services/port-config'; +import { sendEmail as sendSystemEmail } from '@/lib/email'; import type { ComposeEmailInput } from '@/lib/validators/email'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -17,6 +20,22 @@ interface AuditMeta { userAgent: string; } +// ─── Helpers ────────────────────────────────────────────────────────────────── + +async function assertAttachmentsForPort( + refs: { fileId: string }[] | undefined, + portId: string, +): Promise { + if (!refs || refs.length === 0) return; + for (const r of refs) { + const file = await db.query.files.findFirst({ where: eq(files.id, r.fileId) }); + if (!file) throw new NotFoundError('File'); + if (file.portId !== portId) { + throw new ForbiddenError('File belongs to a different port'); + } + } +} + // ─── Send Email ─────────────────────────────────────────────────────────────── export async function sendEmail( @@ -25,12 +44,29 @@ export async function sendEmail( data: ComposeEmailInput, audit: AuditMeta, ) { + // System path: port-config noreply identity + system SMTP, with optional + // file attachments. Skips email_messages/email_threads writes; logs a + // documentEvents row when the body is keyed to a document via + // metadata.documentId at the API boundary. + if (data.senderType === 'system') { + return sendSystem(portId, data, audit); + } + + if (!data.accountId) { + throw new ForbiddenError('accountId is required for user-path send'); + } + + // Personal-account sends are admin-gated per port. + const cfg = await getPortEmailConfig(portId); + if (!cfg.allowPersonalAccountSends) { + throw new ForbiddenError('Personal account sends are disabled for this port'); + } + + await assertAttachmentsForPort(data.attachments, portId); + // Verify the account belongs to the user const account = await db.query.emailAccounts.findFirst({ - where: and( - eq(emailAccounts.id, data.accountId), - eq(emailAccounts.userId, userId), - ), + where: and(eq(emailAccounts.id, data.accountId), eq(emailAccounts.userId, userId)), }); if (!account) { @@ -64,16 +100,10 @@ export async function sendEmail( const existingMessages = await db .select({ messageIdHeader: emailMessages.messageIdHeader }) .from(emailMessages) - .where( - and( - eq(emailMessages.threadId, data.threadId), - ), - ) + .where(and(eq(emailMessages.threadId, data.threadId))) .orderBy(emailMessages.sentAt); - const refIds = existingMessages - .map((m) => m.messageIdHeader) - .filter(Boolean) as string[]; + const refIds = existingMessages.map((m) => m.messageIdHeader).filter(Boolean) as string[]; if (refIds.length > 0) { references = refIds.join(' '); @@ -81,6 +111,29 @@ export async function sendEmail( } } + // Resolve attachments for the user-path SMTP send. + const resolvedAttachments = data.attachments + ? await Promise.all( + data.attachments.map(async (ref) => { + const file = await db.query.files.findFirst({ + where: eq(files.id, ref.fileId), + }); + if (!file) throw new NotFoundError('File'); + const { minioClient } = await import('@/lib/minio'); + const stream = await minioClient.getObject(file.storageBucket, file.storagePath); + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return { + filename: ref.filename ?? file.originalName, + content: Buffer.concat(chunks), + ...(file.mimeType ? { contentType: file.mimeType } : {}), + }; + }), + ) + : undefined; + // Send via the user's SMTP transporter const info = await transporter.sendMail({ from: account.emailAddress, @@ -90,6 +143,9 @@ export async function sendEmail( html: data.bodyHtml, inReplyTo, references, + ...(resolvedAttachments && resolvedAttachments.length > 0 + ? { attachments: resolvedAttachments } + : {}), }); const sentMessageId: string = @@ -101,10 +157,7 @@ export async function sendEmail( if (data.threadId) { // Verify thread belongs to this port const existingThread = await db.query.emailThreads.findFirst({ - where: and( - eq(emailThreads.id, data.threadId), - eq(emailThreads.portId, portId), - ), + where: and(eq(emailThreads.id, data.threadId), eq(emailThreads.portId, portId)), }); if (!existingThread) { throw new NotFoundError('Email thread'); @@ -140,6 +193,10 @@ export async function sendEmail( bodyHtml: data.bodyHtml, direction: 'outbound', sentAt: now, + attachmentFileIds: + data.attachments && data.attachments.length > 0 + ? data.attachments.map((a) => a.fileId) + : null, }) .returning(); @@ -174,3 +231,58 @@ export async function sendEmail( return { message, threadId }; } + +/** + * System-path send. Uses port-config noreply identity + system SMTP from + * `lib/email/index.ts → sendEmail()`. Skips email_messages/email_threads + * writes (no IMAP roundtrip expected). When the email targets a document's + * signed PDF (attachments include the doc's signedFileId), logs a + * documentEvents row so the document detail timeline reflects the send. + */ +async function sendSystem( + portId: string, + data: ComposeEmailInput, + audit: AuditMeta, +): Promise<{ message: { id: 'system' }; threadId: null }> { + await assertAttachmentsForPort(data.attachments, portId); + + await sendSystemEmail( + data.to, + data.subject, + data.bodyHtml, + undefined, + undefined, + portId, + data.attachments, + ); + + // If any attachment matches a document's signedFileId, log signed_doc_emailed. + if (data.attachments && data.attachments.length > 0) { + const fileIds = data.attachments.map((a) => a.fileId); + const matchingDocs = await db + .select({ id: documents.id, signedFileId: documents.signedFileId }) + .from(documents) + .where(and(eq(documents.portId, portId), sql`${documents.signedFileId} = ANY(${fileIds})`)); + + for (const doc of matchingDocs) { + await db.insert(documentEvents).values({ + documentId: doc.id, + eventType: 'signed_doc_emailed', + eventData: { recipients: data.to, subject: data.subject }, + }); + } + } + + void createAuditLog({ + userId: audit.userId, + portId: audit.portId, + action: 'create', + entityType: 'email_message', + entityId: 'system', + metadata: { senderType: 'system', to: data.to, subject: data.subject }, + ipAddress: audit.ipAddress, + userAgent: audit.userAgent, + }); + + return { message: { id: 'system' }, threadId: null }; +} diff --git a/src/lib/services/port-config.ts b/src/lib/services/port-config.ts index 5da645f..09780f7 100644 --- a/src/lib/services/port-config.ts +++ b/src/lib/services/port-config.ts @@ -19,6 +19,7 @@ export const SETTING_KEYS = { emailReplyTo: 'email_reply_to', emailSignatureHtml: 'email_signature_html', emailFooterHtml: 'email_footer_html', + emailAllowPersonalAccountSends: 'email_allow_personal_account_sends', smtpHostOverride: 'smtp_host_override', smtpPortOverride: 'smtp_port_override', smtpUserOverride: 'smtp_user_override', @@ -66,6 +67,12 @@ export interface PortEmailConfig { smtpPort: number; smtpUser: string | null; smtpPass: string | null; + /** + * When false, only the system (port-config) sender identity is allowed. + * When true, admins/users may send via their connected personal email + * account. Defaults to false for safety. + */ + allowPersonalAccountSends: boolean; } export async function getPortEmailConfig(portId: string): Promise { @@ -79,6 +86,7 @@ export async function getPortEmailConfig(portId: string): Promise(SETTING_KEYS.emailFromName, portId), readSetting(SETTING_KEYS.emailFromAddress, portId), @@ -89,6 +97,7 @@ export async function getPortEmailConfig(portId: string): Promise(SETTING_KEYS.smtpPortOverride, portId), readSetting(SETTING_KEYS.smtpUserOverride, portId), readSetting(SETTING_KEYS.smtpPassOverride, portId), + readSetting(SETTING_KEYS.emailAllowPersonalAccountSends, portId), ]); // Parse env.SMTP_FROM into name + address if no port override @@ -114,6 +123,7 @@ export async function getPortEmailConfig(portId: string): Promise d.senderType !== 'user' || Boolean(d.accountId), { + path: ['accountId'], + message: 'accountId is required when senderType=user', + }); export const listThreadsSchema = z.object({ page: z.coerce.number().int().positive().default(1),