diff --git a/apps/api/package.json b/apps/api/package.json index 9c1bbad7e..8155db39c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -17,7 +17,9 @@ "@types/bcrypt": "^5.0.0", "@types/email-reply-parser": "^1", "@types/formidable": "^3.4.5", + "@types/imap": "^0.8.42", "@types/jsonwebtoken": "^8.5.8", + "@types/mailparser": "^3.4.5", "@types/node": "^17.0.23", "@types/nodemailer": "^6.4.14", "@types/passport-local": "^1.0.35", diff --git a/apps/api/src/lib/imap.ts b/apps/api/src/lib/imap.ts index a27a5826a..348a04777 100644 --- a/apps/api/src/lib/imap.ts +++ b/apps/api/src/lib/imap.ts @@ -1,245 +1,10 @@ -const Imap = require("imap"); -var EmailReplyParser = require("email-reply-parser"); +import { ImapService } from "./services/imap.service"; -import { GoogleAuth } from "google-auth-library"; -import { prisma } from "../prisma"; - -const { simpleParser } = require("mailparser"); - -require("dotenv").config(); - -const client = prisma; - -const date = new Date(); -const today = date.getDate(); -const month = date.getMonth(); -const year = date.getFullYear(); -//@ts-ignore -const d = new Date([year, month, today]); - -// Function to get or refresh the access token -async function getValidAccessToken(queue: any) { - const { - clientId, - clientSecret, - refreshToken, - accessToken, - expiresIn, - username, - } = queue; - - // Check if token is still valid - const now = Math.floor(Date.now() / 1000); - if (accessToken && expiresIn && now < expiresIn) { - return accessToken; - } - - // Initialize GoogleAuth client - const auth = new GoogleAuth({ - clientOptions: { - clientId: clientId, - clientSecret: clientSecret, - }, - }); - - const oauth2Client = auth.fromJSON({ - client_id: clientId, - client_secret: clientSecret, - refresh_token: refreshToken, - }); - - // Refresh the token if expired - const tokenInfo = await oauth2Client.getAccessToken(); - - const expiryDate = queue.expiresIn + 3600; - - if (tokenInfo.token) { - await prisma.emailQueue.update({ - where: { id: queue.id }, - data: { - accessToken: tokenInfo.token, - expiresIn: expiryDate, - }, - }); - return tokenInfo.token; - } else { - throw new Error("Unable to refresh access token."); - } -} - -// Function to generate XOAUTH2 string -function generateXOAuth2Token(user: string, accessToken: string) { - const authString = [ - "user=" + user, - "auth=Bearer " + accessToken, - "", - "", - ].join("\x01"); - return Buffer.from(authString).toString("base64"); -} - -async function returnImapConfig(queue: any) { - switch (queue.serviceType) { - case "gmail": - const validatedAccessToken = await getValidAccessToken(queue); - return { - user: queue.username, - host: queue.hostname, - port: 993, - tls: true, - xoauth2: generateXOAuth2Token(queue.username, validatedAccessToken), - tlsOptions: { rejectUnauthorized: false, servername: queue.hostname }, - }; - case "other": - return { - user: queue.username, - password: queue.password, - host: queue.hostname, - port: queue.tls ? 993 : 143, - tls: queue.tls, - tlsOptions: { rejectUnauthorized: false, servername: queue.hostname }, - }; - default: - throw new Error("Unsupported service type"); - } -} - -export const getEmails = async () => { +export const getEmails = async (): Promise => { try { - const queues = await client.emailQueue.findMany({}); - - for (let i = 0; i < queues.length; i++) { - var imapConfig = await returnImapConfig(queues[i]); - - if (!imapConfig) { - continue; - } - - const imap = new Imap(imapConfig); - imap.connect(); - - imap.once("ready", () => { - imap.openBox("INBOX", false, () => { - imap.search(["UNSEEN", ["ON", [date]]], (err: any, results: any) => { - if (err) { - console.log(err); - return; - } - - if (!results || !results.length) { - console.log("No new messages"); - imap.end(); - return; - } - - console.log(results.length + " num of emails"); - - const f = imap.fetch(results, { bodies: "" }); - f.on("message", (msg: any) => { - msg.on("body", (stream: any) => { - simpleParser(stream, async (err: any, parsed: any) => { - const { from, subject, textAsHtml, text, html } = parsed; - - var reply_text = new EmailReplyParser().read(text); - - if (subject?.includes("Re:")) { - const ticketIdMatch = subject.match(/#(\d+)/); - if (!ticketIdMatch) { - console.log( - "Could not extract ticket ID from subject:", - subject - ); - return; - } - - const ticketId = ticketIdMatch[1]; - - const find = await client.ticket.findFirst({ - where: { - Number: Number(ticketId), - }, - }); - - if (find) { - return await client.comment.create({ - data: { - text: text - ? reply_text.fragments[0]._content - : "No Body", - userId: null, - ticketId: find.id, - reply: true, - replyEmail: from.value[0].address, - public: true, - }, - }); - } else { - console.log("Ticket not found"); - } - } else { - const imap = await client.imap_Email.create({ - data: { - from: from.value[0].address, - subject: subject ? subject : "No Subject", - body: text ? text : "No Body", - html: html ? html : "", - text: textAsHtml, - }, - }); - - const ticket = await client.ticket.create({ - data: { - email: from.value[0].address, - name: from.value[0].name, - title: imap.subject ? imap.subject : "-", - isComplete: Boolean(false), - priority: "Low", - fromImap: Boolean(true), - detail: html ? html : textAsHtml, - }, - }); - - console.log(imap, ticket); - } - }); - }); - msg.once("attributes", (attrs: any) => { - const { uid } = attrs; - imap.addFlags(uid, ["\\Seen"], () => { - // Mark the email as read after reading it - console.log("Marked as read!"); - }); - }); - }); - f.once("error", (ex: any) => { - return Promise.reject(ex); - }); - f.once("end", () => { - console.log("Done fetching all messages!"); - imap.end(); - }); - }); - }); - }); - - imap.once("error", (err: any) => { - console.log(err); - }); - - imap.once("end", () => { - console.log("Connection ended"); - }); - } - - console.log("loop completed"); + await ImapService.fetchEmails(); + console.log('Email fetch completed'); } catch (error) { - console.log("an error occurred ", error); + console.error('An error occurred while fetching emails:', error); } }; - -// Helper function to extract reply text -function extractReplyText(emailBody: string): string { - // Implement logic to extract reply text from the email body - // This might involve removing quoted text from previous emails - return emailBody; // Placeholder, replace with actual logic -} diff --git a/apps/api/src/lib/services/auth.service.ts b/apps/api/src/lib/services/auth.service.ts new file mode 100644 index 000000000..073ad9268 --- /dev/null +++ b/apps/api/src/lib/services/auth.service.ts @@ -0,0 +1,50 @@ +import { GoogleAuth } from 'google-auth-library'; +import { prisma } from '../../prisma'; +import { EmailQueue } from '../types/email'; + +export class AuthService { + public static generateXOAuth2Token(username: string, accessToken: string): string { + const authString = [ + `user=${username}`, + `auth=Bearer ${accessToken}`, + '', + '' + ].join('\x01'); + return Buffer.from(authString).toString('base64'); + } + + static async getValidAccessToken(queue: EmailQueue): Promise { + const { clientId, clientSecret, refreshToken, accessToken, expiresIn } = queue; + + const now = Math.floor(Date.now() / 1000); + if (accessToken && expiresIn && now < expiresIn) { + return accessToken; + } + + const auth = new GoogleAuth({ + clientOptions: { clientId, clientSecret } + }); + + const oauth2Client = auth.fromJSON({ + client_id: clientId, + client_secret: clientSecret, + refresh_token: refreshToken + }); + + const tokenInfo = await oauth2Client.getAccessToken(); + if (!tokenInfo.token) { + throw new Error('Unable to refresh access token.'); + } + + const expiryDate = (expiresIn || 0) + 3600; + await prisma.emailQueue.update({ + where: { id: queue.id }, + data: { + accessToken: tokenInfo.token, + expiresIn: expiryDate + } + }); + + return tokenInfo.token; + } +} \ No newline at end of file diff --git a/apps/api/src/lib/services/imap.service.ts b/apps/api/src/lib/services/imap.service.ts new file mode 100644 index 000000000..783b0db69 --- /dev/null +++ b/apps/api/src/lib/services/imap.service.ts @@ -0,0 +1,175 @@ +import EmailReplyParser from 'email-reply-parser'; +import Imap from 'imap'; +import { simpleParser } from 'mailparser'; +import { prisma } from '../../prisma'; +import { EmailConfig, EmailQueue } from '../types/email'; +import { AuthService } from './auth.service'; + +function getReplyText(email: any): string { + const parsed = new EmailReplyParser().read(email.text); + let replyText = '' + + parsed.fragments.forEach(fragment => { + if (fragment.isHidden() && !fragment.isSignature() && !fragment.isQuoted()) return; + replyText += fragment.content; + }); + + return replyText; + +} + +export class ImapService { + private static async getImapConfig(queue: EmailQueue): Promise { + switch (queue.serviceType) { + case 'gmail': { + const validatedAccessToken = await AuthService.getValidAccessToken(queue); + return { + user: queue.username, + host: queue.hostname, + port: 993, + tls: true, + xoauth2: AuthService.generateXOAuth2Token(queue.username, validatedAccessToken), + tlsOptions: { rejectUnauthorized: false, servername: queue.hostname } + }; + } + case 'other': + return { + user: queue.username, + password: queue.password, + host: queue.hostname, + port: queue.tls ? 993 : 143, + tls: queue.tls || false, + tlsOptions: { rejectUnauthorized: false, servername: queue.hostname } + }; + default: + throw new Error('Unsupported service type'); + } + } + + private static async processEmail(parsed: any, isReply: boolean): Promise { + const { from, subject, text, html, textAsHtml } = parsed; + + if (isReply) { + const ticketIdMatch = subject.match(/#(\d+)/); + if (!ticketIdMatch) { + throw new Error(`Could not extract ticket ID from subject: ${subject}`); + } + + const ticketId = ticketIdMatch[1]; + const ticket = await prisma.ticket.findFirst({ + where: { Number: Number(ticketId) } + }); + + if (!ticket) { + throw new Error(`Ticket not found: ${ticketId}`); + } + + const replyText = getReplyText(parsed); + await prisma.comment.create({ + data: { + text: text ? replyText : 'No Body', + userId: null, + ticketId: ticket.id, + reply: true, + replyEmail: from.value[0].address, + public: true + } + }); + } else { + const imapEmail = await prisma.imap_Email.create({ + data: { + from: from.value[0].address, + subject: subject || 'No Subject', + body: text || 'No Body', + html: html || '', + text: textAsHtml + } + }); + + await prisma.ticket.create({ + data: { + email: from.value[0].address, + name: from.value[0].name, + title: imapEmail.subject || '-', + isComplete: false, + priority: 'Low', + fromImap: true, + detail: html || textAsHtml + } + }); + } + } + + static async fetchEmails(): Promise { + const queues = (await prisma.emailQueue.findMany()) as unknown as EmailQueue[]; + const today = new Date(); + + for (const queue of queues) { + try { + const imapConfig = await this.getImapConfig(queue); + if (!imapConfig.password) { + console.error('IMAP configuration is missing a password'); + throw new Error('IMAP configuration is missing a password'); + } + + // @ts-ignore + const imap = new Imap(imapConfig); + + await new Promise((resolve, reject) => { + imap.once('ready', () => { + imap.openBox('INBOX', false, (err) => { + if (err) { + reject(err); + return; + } + imap.search(['UNSEEN', ['ON', today]], (err, results) => { + if (err) reject(err); + if (!results?.length) { + console.log('No new messages'); + imap.end(); + resolve(null); + return; + } + + const fetch = imap.fetch(results, { bodies: '' }); + + fetch.on('message', (msg) => { + msg.on('body', (stream) => { + simpleParser(stream, async (err, parsed) => { + if (err) throw err; + const isReply = parsed.subject?.includes('Re:'); + await this.processEmail(parsed, isReply || false); + }); + }); + + msg.once('attributes', (attrs) => { + imap.addFlags(attrs.uid, ['\\Seen'], () => { + console.log('Marked as read!'); + }); + }); + }); + + fetch.once('error', reject); + fetch.once('end', () => { + console.log('Done fetching messages'); + imap.end(); + resolve(null); + }); + }); + }); + }); + + imap.once('error', reject); + imap.once('end', () => { + console.log('Connection ended'); + resolve(null); + }); + + imap.connect(); + }); + } catch (error) { + console.error(`Error processing queue ${queue.id}:`, error); + } + } + } +} \ No newline at end of file diff --git a/apps/api/src/lib/types/email.ts b/apps/api/src/lib/types/email.ts new file mode 100644 index 000000000..4700c209e --- /dev/null +++ b/apps/api/src/lib/types/email.ts @@ -0,0 +1,26 @@ +export interface EmailConfig { + user: string; + host: string; + port: number; + tls: boolean; + tlsOptions: { + rejectUnauthorized: boolean; + servername: string; + }; + xoauth2?: string; + password?: string; +} + +export type EmailQueue = { + serviceType: "gmail" | "other"; + id: string; + username: string; + hostname: string; + password?: string; + clientId?: string; + clientSecret?: string; + refreshToken?: string; + accessToken?: string; + expiresIn?: number; + tls?: boolean; +}; diff --git a/yarn.lock b/yarn.lock index 6ec4ada52..299cccc1b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5206,6 +5206,15 @@ __metadata: languageName: node linkType: hard +"@types/imap@npm:^0.8.42": + version: 0.8.42 + resolution: "@types/imap@npm:0.8.42" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/ae5fe37b39320255a7ca0fb7b6b14214e481098e191a83abb07a26fdeea6fc2f6eb1a2565017649bae8425ae80dea83dfa9bb7a8e5a0aab0d78915c407974c94 + languageName: node + linkType: hard + "@types/json-schema@npm:^7.0.5, @types/json-schema@npm:^7.0.8, @types/json-schema@npm:^7.0.9": version: 7.0.15 resolution: "@types/json-schema@npm:7.0.15" @@ -5259,6 +5268,16 @@ __metadata: languageName: node linkType: hard +"@types/mailparser@npm:^3.4.5": + version: 3.4.5 + resolution: "@types/mailparser@npm:3.4.5" + dependencies: + "@types/node": "npm:*" + iconv-lite: "npm:^0.6.3" + checksum: 10c0/60cb65e8d735a16d53ea84b5347a590464fa373b3a3c405fc2f12c97498bbeaa1857e5c9e182f60e8a05da307a7752df7228c5722e9865f8e758a1febc199620 + languageName: node + linkType: hard + "@types/markdown-it@npm:^14.0.0": version: 14.1.2 resolution: "@types/markdown-it@npm:14.1.2" @@ -6083,7 +6102,9 @@ __metadata: "@types/bcrypt": "npm:^5.0.0" "@types/email-reply-parser": "npm:^1" "@types/formidable": "npm:^3.4.5" + "@types/imap": "npm:^0.8.42" "@types/jsonwebtoken": "npm:^8.5.8" + "@types/mailparser": "npm:^3.4.5" "@types/node": "npm:^17.0.23" "@types/nodemailer": "npm:^6.4.14" "@types/passport-local": "npm:^1.0.35" @@ -12231,7 +12252,7 @@ __metadata: languageName: node linkType: hard -"iconv-lite@npm:0.6, iconv-lite@npm:0.6.3, iconv-lite@npm:^0.6.2": +"iconv-lite@npm:0.6, iconv-lite@npm:0.6.3, iconv-lite@npm:^0.6.2, iconv-lite@npm:^0.6.3": version: 0.6.3 resolution: "iconv-lite@npm:0.6.3" dependencies: