177 lines
4.9 KiB
TypeScript
177 lines
4.9 KiB
TypeScript
|
|
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 };
|
||
|
|
}
|