import nodemailer, { type Transporter } from 'nodemailer'; import { env } from '@/lib/env'; import { logger } from '@/lib/logger'; import { getPortEmailConfig, type PortEmailConfig } from '@/lib/services/port-config'; /** * Creates and returns a new Nodemailer SMTP transporter using env defaults. * For port-scoped configuration use {@link createPortTransporter} instead. * * A new instance is created on each call so the factory can be used in * contexts where connection pooling is managed externally (e.g. per-request * in serverless, or once at worker startup). */ // Nodemailer's default `connectionTimeout` is 2 minutes and there is no // `socketTimeout`, so a hung SMTP server would hold a BullMQ `email` // worker concurrency slot for up to 2 min × 5 retry attempts = 10 min // per job. With concurrency 5, all slots can be starved by a single // flaky upstream. Explicit timeouts cap the worst case under a minute. const SMTP_TIMEOUTS = { connectionTimeout: 10_000, greetingTimeout: 10_000, socketTimeout: 30_000, } as const; export function createTransporter(): Transporter { return nodemailer.createTransport({ host: env.SMTP_HOST, port: env.SMTP_PORT, // Implicitly secure when port is 465; STARTTLS for all other ports. secure: env.SMTP_PORT === 465, ...SMTP_TIMEOUTS, ...(env.SMTP_USER && env.SMTP_PASS ? { auth: { user: env.SMTP_USER, pass: env.SMTP_PASS } } : {}), }); } function createTransporterFromConfig(cfg: PortEmailConfig): Transporter { return nodemailer.createTransport({ host: cfg.smtpHost, port: cfg.smtpPort, secure: cfg.smtpPort === 465, ...SMTP_TIMEOUTS, ...(cfg.smtpUser && cfg.smtpPass ? { auth: { user: cfg.smtpUser, pass: cfg.smtpPass } } : {}), }); } export interface EmailAttachmentRef { fileId: string; filename?: string; } export interface SendEmailOptions { to: string | string[]; subject: string; html: string; from?: string; /** 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'); // Pluggable storage backend (s3 OR filesystem). Direct MinIO imports // break the filesystem-mode deployment path documented in CLAUDE.md. const { getStorageBackend } = await import('@/lib/storage'); const backend = await getStorageBackend(); 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 backend.get(file.storagePath); const chunks: Buffer[] = []; for await (const chunk of stream as AsyncIterable) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } return { filename: ref.filename ?? file.originalName, content: Buffer.concat(chunks), ...(file.mimeType ? { contentType: file.mimeType } : {}), }; }), ); } /** * Sends a single email via SMTP. * * Returns the nodemailer info object on success. Propagates errors to the * caller - callers in background jobs should wrap in try/catch and handle * retries via BullMQ. */ export async function sendEmail( to: string | string[], subject: string, html: string, from?: string, text?: string, portId?: string, attachments?: EmailAttachmentRef[], ): Promise { const cfg = portId ? await getPortEmailConfig(portId) : null; const transporter = cfg ? createTransporterFromConfig(cfg) : createTransporter(); const requestedTo = Array.isArray(to) ? to.join(', ') : to; const effectiveTo = env.EMAIL_REDIRECT_TO ?? requestedTo; const effectiveSubject = env.EMAIL_REDIRECT_TO ? `[redirected from ${requestedTo}] ${subject}` : subject; const fromHeader = from ?? (cfg ? `${cfg.fromName} <${cfg.fromAddress}>` : undefined) ?? env.SMTP_FROM ?? `Port Nimara CRM `; const resolvedAttachments = await resolveAttachments(attachments, portId); const info = await transporter.sendMail({ from: fromHeader, to: effectiveTo, subject: effectiveSubject, html, ...(cfg?.replyTo ? { replyTo: cfg.replyTo } : {}), ...(text ? { text } : {}), ...(resolvedAttachments.length > 0 ? { attachments: resolvedAttachments } : {}), }); logger.debug( { messageId: info.messageId, to: effectiveTo, originalTo: requestedTo, subject, portId }, env.EMAIL_REDIRECT_TO ? 'Email sent (redirected)' : 'Email sent', ); return info; }