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 { createAuditLog } from '@/lib/audit'; import { NotFoundError, ForbiddenError } from '@/lib/errors'; import { getDecryptedCredentials } from '@/lib/services/email-accounts.service'; import type { ComposeEmailInput } from '@/lib/validators/email'; // ─── Types ──────────────────────────────────────────────────────────────────── interface AuditMeta { userId: string; portId: string; ipAddress: string; userAgent: string; } // ─── Send Email ─────────────────────────────────────────────────────────────── export async function sendEmail( userId: string, portId: string, data: ComposeEmailInput, audit: AuditMeta, ) { // 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(' '); } } } // 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, }); 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, }) .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 }; }