import nodemailer from 'nodemailer'; 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 ──────────────────────────────────────────────────────────────────── interface AuditMeta { userId: string; portId: string; ipAddress: string; 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( userId: string, portId: string, 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)), }); if (!account) { throw new NotFoundError('Email account'); } if (account.portId !== portId) { throw new ForbiddenError('Email account does not belong to this port'); } // Decrypt credentials (INTERNAL — never logged or returned) const creds = await getDecryptedCredentials(data.accountId); // Build user-specific SMTP transporter const transporter = nodemailer.createTransport({ host: account.smtpHost, port: account.smtpPort, secure: account.smtpPort === 465, auth: { user: creds.username, pass: creds.password }, }); // Resolve threading headers if replying let inReplyTo: string | undefined; let references: string | undefined; if (data.inReplyToMessageId) { inReplyTo = data.inReplyToMessageId; // Gather the full references chain from the thread if (data.threadId) { const existingMessages = await db .select({ messageIdHeader: emailMessages.messageIdHeader }) .from(emailMessages) .where(and(eq(emailMessages.threadId, data.threadId))) .orderBy(emailMessages.sentAt); const refIds = existingMessages.map((m) => m.messageIdHeader).filter(Boolean) as string[]; if (refIds.length > 0) { references = refIds.join(' '); } } } // 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, to: data.to.join(', '), cc: data.cc?.join(', '), subject: data.subject, html: data.bodyHtml, inReplyTo, references, ...(resolvedAttachments && resolvedAttachments.length > 0 ? { attachments: resolvedAttachments } : {}), }); const sentMessageId: string = typeof info.messageId === 'string' ? info.messageId : String(info.messageId ?? ''); // Resolve or create thread let threadId: string; 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)), }); if (!existingThread) { throw new NotFoundError('Email thread'); } threadId = existingThread.id; } else { const newThreadRows = await db .insert(emailThreads) .values({ portId, subject: data.subject, lastMessageAt: new Date(), messageCount: 0, }) .returning(); const newThread = newThreadRows[0]; if (!newThread) throw new Error('Failed to create email thread'); threadId = newThread.id; } const now = new Date(); // Persist the outbound message const messageRows = await db .insert(emailMessages) .values({ threadId, messageIdHeader: sentMessageId || null, fromAddress: account.emailAddress, toAddresses: data.to, ccAddresses: data.cc ?? null, subject: data.subject, bodyHtml: data.bodyHtml, direction: 'outbound', sentAt: now, attachmentFileIds: data.attachments && data.attachments.length > 0 ? data.attachments.map((a) => a.fileId) : null, }) .returning(); const message = messageRows[0]; if (!message) throw new Error('Failed to persist outbound email message'); // Update thread metadata await db .update(emailThreads) .set({ lastMessageAt: now, messageCount: sql`${emailThreads.messageCount} + 1`, updatedAt: now, }) .where(eq(emailThreads.id, threadId)); void createAuditLog({ userId: audit.userId, portId: audit.portId, action: 'create', entityType: 'email_message', entityId: message.id, metadata: { threadId, to: data.to, subject: data.subject, accountId: data.accountId, }, ipAddress: audit.ipAddress, userAgent: audit.userAgent, }); 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 }; }