Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM, PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source files covering clients, berths, interests/pipeline, documents/EOI, expenses/invoices, email, notifications, dashboard, admin, and client portal. CI/CD via Gitea Actions with Docker builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
176
src/lib/services/email-compose.service.ts
Normal file
176
src/lib/services/email-compose.service.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user