arcadiasuite/server/email/service.ts

450 lines
14 KiB
TypeScript

import nodemailer from "nodemailer";
import Imap from "imap";
import { simpleParser, type ParsedMail } from "mailparser";
import { db } from "../../db/index";
import { eq, and, desc, sql } from "drizzle-orm";
import {
emailAccounts,
emailMessages,
emailFolders,
} from "@shared/schema";
type EmailAccount = typeof emailAccounts.$inferSelect;
type EmailMessage = typeof emailMessages.$inferSelect;
interface EmailConfig {
email: string;
password: string;
imapHost: string;
imapPort: number;
smtpHost: string;
smtpPort: number;
useTls: boolean;
}
interface SendEmailParams {
accountId: number;
to: string;
cc?: string;
bcc?: string;
subject: string;
body: string;
isHtml?: boolean;
replyToMessageId?: number;
}
class EmailService {
private imapConnections: Map<number, Imap> = new Map();
private pollingIntervals: Map<number, NodeJS.Timeout> = new Map();
getProviderConfig(provider: string): Partial<EmailConfig> {
const configs: Record<string, Partial<EmailConfig>> = {
gmail: {
imapHost: "imap.gmail.com",
imapPort: 993,
smtpHost: "smtp.gmail.com",
smtpPort: 587,
useTls: true,
},
outlook: {
imapHost: "outlook.office365.com",
imapPort: 993,
smtpHost: "smtp.office365.com",
smtpPort: 587,
useTls: true,
},
yahoo: {
imapHost: "imap.mail.yahoo.com",
imapPort: 993,
smtpHost: "smtp.mail.yahoo.com",
smtpPort: 587,
useTls: true,
},
};
return configs[provider] || {};
}
async connectAccount(
userId: string,
tenantId: number | undefined,
email: string,
password: string,
provider: string = "gmail",
displayName?: string
): Promise<EmailAccount> {
const providerConfig = this.getProviderConfig(provider);
const config: EmailConfig = {
email,
password,
imapHost: providerConfig.imapHost || "imap.gmail.com",
imapPort: providerConfig.imapPort || 993,
smtpHost: providerConfig.smtpHost || "smtp.gmail.com",
smtpPort: providerConfig.smtpPort || 587,
useTls: providerConfig.useTls ?? true,
};
await this.testImapConnection(config);
await this.testSmtpConnection(config);
const [existing] = await db.select().from(emailAccounts)
.where(and(eq(emailAccounts.userId, userId), eq(emailAccounts.email, email)));
if (existing) {
const [updated] = await db.update(emailAccounts)
.set({
password,
imapHost: config.imapHost,
imapPort: config.imapPort,
smtpHost: config.smtpHost,
smtpPort: config.smtpPort,
status: "connected",
lastSyncAt: new Date(),
updatedAt: new Date(),
})
.where(eq(emailAccounts.id, existing.id))
.returning();
this.startPolling(updated.id);
return updated;
}
const [account] = await db.insert(emailAccounts).values({
userId,
tenantId,
email,
password,
displayName: displayName || email.split("@")[0],
provider,
imapHost: config.imapHost,
imapPort: config.imapPort,
smtpHost: config.smtpHost,
smtpPort: config.smtpPort,
status: "connected",
lastSyncAt: new Date(),
}).returning();
await this.createDefaultFolders(account.id);
this.startPolling(account.id);
return account;
}
private async testImapConnection(config: EmailConfig): Promise<void> {
return new Promise((resolve, reject) => {
const imap = new Imap({
user: config.email,
password: config.password,
host: config.imapHost,
port: config.imapPort,
tls: config.useTls,
tlsOptions: { rejectUnauthorized: false },
connTimeout: 10000,
authTimeout: 10000,
});
imap.once("ready", () => {
imap.end();
resolve();
});
imap.once("error", (err: Error) => {
reject(new Error(`IMAP connection failed: ${err.message}`));
});
imap.connect();
});
}
private async testSmtpConnection(config: EmailConfig): Promise<void> {
const transporter = nodemailer.createTransport({
host: config.smtpHost,
port: config.smtpPort,
secure: config.smtpPort === 465,
auth: {
user: config.email,
pass: config.password,
},
});
await transporter.verify();
}
private async createDefaultFolders(accountId: number): Promise<void> {
const folders = [
{ name: "INBOX", type: "inbox" },
{ name: "Sent", type: "sent" },
{ name: "Drafts", type: "drafts" },
{ name: "Trash", type: "trash" },
{ name: "Spam", type: "spam" },
];
for (const folder of folders) {
await db.insert(emailFolders).values({
accountId,
name: folder.name,
type: folder.type,
unreadCount: 0,
totalCount: 0,
}).onConflictDoNothing();
}
}
async disconnectAccount(accountId: number): Promise<void> {
this.stopPolling(accountId);
const imap = this.imapConnections.get(accountId);
if (imap) {
imap.end();
this.imapConnections.delete(accountId);
}
await db.update(emailAccounts)
.set({ status: "disconnected", updatedAt: new Date() })
.where(eq(emailAccounts.id, accountId));
}
async sendEmail(params: SendEmailParams): Promise<EmailMessage> {
const [account] = await db.select().from(emailAccounts)
.where(eq(emailAccounts.id, params.accountId));
if (!account) throw new Error("Email account not found");
const transporter = nodemailer.createTransport({
host: account.smtpHost || "smtp.gmail.com",
port: account.smtpPort || 587,
secure: (account.smtpPort || 587) === 465,
auth: {
user: account.email,
pass: account.password || "",
},
} as nodemailer.TransportOptions);
const mailOptions = {
from: `${account.displayName || account.email} <${account.email}>`,
to: params.to,
cc: params.cc,
bcc: params.bcc,
subject: params.subject,
[params.isHtml ? "html" : "text"]: params.body,
};
const info = await transporter.sendMail(mailOptions);
const [sentFolder] = await db.select().from(emailFolders)
.where(and(eq(emailFolders.accountId, params.accountId), eq(emailFolders.type, "sent")));
const [message] = await db.insert(emailMessages).values({
accountId: params.accountId,
folderId: sentFolder?.id,
messageId: info.messageId,
fromAddress: account.email,
fromName: account.displayName,
toAddresses: [params.to],
ccAddresses: params.cc ? [params.cc] : [],
subject: params.subject,
bodyText: params.isHtml ? undefined : params.body,
bodyHtml: params.isHtml ? params.body : undefined,
snippet: params.body.substring(0, 200),
isRead: 1,
isStarred: 0,
hasAttachments: 0,
sentAt: new Date(),
replyToId: params.replyToMessageId,
}).returning();
return message;
}
async fetchEmails(accountId: number, folderName: string = "INBOX", limit: number = 50): Promise<void> {
const [account] = await db.select().from(emailAccounts)
.where(eq(emailAccounts.id, accountId));
if (!account) throw new Error("Email account not found");
const imap = new Imap({
user: account.email,
password: account.password || "",
host: account.imapHost || "imap.gmail.com",
port: account.imapPort || 993,
tls: true,
tlsOptions: { rejectUnauthorized: false },
});
return new Promise((resolve, reject) => {
imap.once("ready", () => {
imap.openBox(folderName, true, async (err, box) => {
if (err) {
imap.end();
return reject(err);
}
const totalMessages = box.messages.total;
const start = Math.max(1, totalMessages - limit + 1);
const fetchRange = `${start}:${totalMessages}`;
const fetch = imap.seq.fetch(fetchRange, {
bodies: "",
struct: true,
});
const messages: ParsedMail[] = [];
fetch.on("message", (msg) => {
msg.on("body", (stream: any) => {
simpleParser(stream as any, async (parseErr: any, parsed: ParsedMail) => {
if (!parseErr) {
messages.push(parsed);
}
});
});
});
fetch.once("error", (fetchErr) => {
imap.end();
reject(fetchErr);
});
fetch.once("end", async () => {
imap.end();
const [folder] = await db.select().from(emailFolders)
.where(and(eq(emailFolders.accountId, accountId), eq(emailFolders.name, folderName)));
for (const parsed of messages) {
const existingMsg = await db.select().from(emailMessages)
.where(and(
eq(emailMessages.accountId, accountId),
eq(emailMessages.messageId, parsed.messageId || "")
));
if (existingMsg.length === 0 && parsed.messageId) {
await db.insert(emailMessages).values({
accountId,
folderId: folder?.id,
messageId: parsed.messageId,
fromAddress: (Array.isArray(parsed.from?.value) ? parsed.from?.value[0]?.address : parsed.from?.value?.address) || "",
fromName: (Array.isArray(parsed.from?.value) ? parsed.from?.value[0]?.name : parsed.from?.value?.name) || undefined,
toAddresses: (Array.isArray(parsed.to) ? parsed.to : parsed.to?.value)?.map((v: any) => v.address || "") || [],
ccAddresses: (Array.isArray(parsed.cc) ? parsed.cc : parsed.cc?.value)?.map((v: any) => v.address || "") || [],
subject: parsed.subject || "(No subject)",
bodyText: typeof parsed.text === 'string' ? parsed.text.substring(0, 50000) : undefined,
bodyHtml: typeof parsed.html === 'string' ? parsed.html.substring(0, 100000) : undefined,
snippet: typeof parsed.text === 'string' ? parsed.text.substring(0, 200) : "",
isRead: 0,
isStarred: 0,
hasAttachments: (parsed.attachments?.length || 0) > 0 ? 1 : 0,
receivedAt: parsed.date || new Date(),
}).catch(() => {});
}
}
await db.update(emailAccounts)
.set({ lastSyncAt: new Date(), updatedAt: new Date() })
.where(eq(emailAccounts.id, accountId));
resolve();
});
});
});
imap.once("error", (err: Error) => {
reject(new Error(`IMAP error: ${err.message}`));
});
imap.connect();
});
}
startPolling(accountId: number, intervalMs: number = 60000): void {
this.stopPolling(accountId);
const interval = setInterval(async () => {
try {
await this.fetchEmails(accountId);
} catch (error) {
console.error(`[EmailService] Polling error for account ${accountId}:`, error);
}
}, intervalMs);
this.pollingIntervals.set(accountId, interval);
console.log(`[EmailService] Started polling for account ${accountId}`);
}
stopPolling(accountId: number): void {
const interval = this.pollingIntervals.get(accountId);
if (interval) {
clearInterval(interval);
this.pollingIntervals.delete(accountId);
console.log(`[EmailService] Stopped polling for account ${accountId}`);
}
}
async getMessages(accountId: number, folderId?: number, limit: number = 50): Promise<EmailMessage[]> {
const conditions = [eq(emailMessages.accountId, accountId)];
if (folderId) conditions.push(eq(emailMessages.folderId, folderId));
return db.select().from(emailMessages)
.where(and(...conditions))
.orderBy(desc(emailMessages.receivedAt))
.limit(limit);
}
async getMessage(messageId: number): Promise<EmailMessage | null> {
const [message] = await db.select().from(emailMessages)
.where(eq(emailMessages.id, messageId));
return message || null;
}
async markAsRead(messageId: number): Promise<void> {
await db.update(emailMessages)
.set({ isRead: 1, updatedAt: new Date() })
.where(eq(emailMessages.id, messageId));
}
async markAsStarred(messageId: number, starred: boolean): Promise<void> {
await db.update(emailMessages)
.set({ isStarred: starred ? 1 : 0, updatedAt: new Date() })
.where(eq(emailMessages.id, messageId));
}
async moveToTrash(messageId: number): Promise<void> {
const [message] = await db.select().from(emailMessages)
.where(eq(emailMessages.id, messageId));
if (!message) return;
const [trashFolder] = await db.select().from(emailFolders)
.where(and(eq(emailFolders.accountId, message.accountId), eq(emailFolders.type, "trash")));
if (trashFolder) {
await db.update(emailMessages)
.set({ folderId: trashFolder.id, updatedAt: new Date() })
.where(eq(emailMessages.id, messageId));
}
}
async deletePermanently(messageId: number): Promise<void> {
await db.delete(emailMessages).where(eq(emailMessages.id, messageId));
}
async getFolders(accountId: number): Promise<any[]> {
return db.select().from(emailFolders)
.where(eq(emailFolders.accountId, accountId));
}
async getAccounts(userId: string): Promise<EmailAccount[]> {
return db.select().from(emailAccounts)
.where(eq(emailAccounts.userId, userId));
}
async getUnreadCount(accountId: number): Promise<number> {
const result = await db.select({ count: sql<number>`count(*)` })
.from(emailMessages)
.where(and(eq(emailMessages.accountId, accountId), eq(emailMessages.isRead, 0)));
return Number(result[0]?.count || 0);
}
}
export const emailService = new EmailService();