Skip to content

Commit

Permalink
feat: oauth based imap mailboxes
Browse files Browse the repository at this point in the history
  • Loading branch information
potts99 committed Nov 6, 2024
1 parent 5d125db commit dc568cf
Show file tree
Hide file tree
Showing 18 changed files with 720 additions and 723 deletions.
2 changes: 2 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
},
"devDependencies": {
"@types/bcrypt": "^5.0.0",
"@types/email-reply-parser": "^1",
"@types/formidable": "^3.4.5",
"@types/jsonwebtoken": "^8.5.8",
"@types/node": "^17.0.23",
Expand All @@ -39,6 +40,7 @@
"axios": "^1.5.0",
"bcrypt": "^5.0.1",
"dotenv": "^16.0.0",
"email-reply-parser": "^1.8.1",
"fastify": "4.22.2",
"fastify-formidable": "^3.0.2",
"fastify-multer": "^2.0.3",
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/controllers/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// SSO Provider
// Portal Locale
// Feature Flags
import { GoogleAuth, OAuth2Client } from "google-auth-library";
import { OAuth2Client } from "google-auth-library";
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
const nodemailer = require("nodemailer");

Expand Down
103 changes: 97 additions & 6 deletions apps/api/src/controllers/queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";

import { checkToken } from "../lib/jwt";
import { prisma } from "../prisma";
import { OAuth2Client } from "google-auth-library";

export function emailQueueRoutes(fastify: FastifyInstance) {
// Create a new email queue
Expand All @@ -13,27 +14,104 @@ export function emailQueueRoutes(fastify: FastifyInstance) {
const token = checkToken(bearer);

if (token) {
const { name, username, password, hostname, tls }: any = request.body;

await prisma.emailQueue.create({
const {
name,
username,
password,
hostname,
tls,
serviceType,
clientId,
clientSecret,
redirectUri,
}: any = request.body;

const mailbox = await prisma.emailQueue.create({
data: {
name,
name: name,
username,
password,
hostname,
tls,
serviceType,
clientId,
clientSecret,
redirectUri,
},
});

// generate redirect uri
if (serviceType === "gmail") {
const google = new OAuth2Client(clientId, clientSecret, redirectUri);

const authorizeUrl = google.generateAuthUrl({
access_type: "offline",
scope: "https://mail.google.com",
prompt: "consent",
state: mailbox.id,
});

reply.send({
success: true,
message: "Gmail imap provider created!",
authorizeUrl: authorizeUrl,
});
}

reply.send({
success: true,
});
}
}
);

// Get all email queues
// Google oauth callback
fastify.get(
"/api/v1/email-queue/oauth/gmail",

async (request: FastifyRequest, reply: FastifyReply) => {
const bearer = request.headers.authorization!.split(" ")[1];
const token = checkToken(bearer);

if (token) {
const { code, mailboxId }: any = request.query;

const mailbox = await prisma.emailQueue.findFirst({
where: {
id: mailboxId,
},
});

const google = new OAuth2Client(
//@ts-expect-error
mailbox?.clientId,
mailbox?.clientSecret,
mailbox?.redirectUri
);

console.log(google);

const r = await google.getToken(code);

await prisma.emailQueue.update({
where: { id: mailbox?.id },
data: {
refreshToken: r.tokens.refresh_token,
accessToken: r.tokens.access_token,
expiresIn: r.tokens.expiry_date,
serviceType: "gmail",
},
});

reply.send({
success: true,
message: "Mailbox updated!",
});
}
}
);

// Get all email queue's
fastify.get(
"/api/v1/email-queues/all",

Expand All @@ -42,7 +120,20 @@ export function emailQueueRoutes(fastify: FastifyInstance) {
const token = checkToken(bearer);

if (token) {
const queues = await prisma.emailQueue.findMany({});
const queues = await prisma.emailQueue.findMany({
select: {
id: true,
name: true,
serviceType: true,
active: true,
teams: true,
username: true,
hostname: true,
tls: true,
clientId: true,
redirectUri: true,
},
});

reply.send({
success: true,
Expand Down
19 changes: 10 additions & 9 deletions apps/api/src/controllers/ticket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function ticketRoutes(fastify: FastifyInstance) {
email,
engineer,
type,
createdBy
createdBy,
}: any = request.body;

const ticket: any = await prisma.ticket.create({
Expand All @@ -48,12 +48,14 @@ export function ticketRoutes(fastify: FastifyInstance) {
priority: priority ? priority : "low",
email,
type: type ? type.toLowerCase() : "support",
createdBy: createdBy ? {
id: createdBy.id,
name: createdBy.name,
role: createdBy.role,
email: createdBy.email
} : undefined,
createdBy: createdBy
? {
id: createdBy.id,
name: createdBy.name,
role: createdBy.role,
email: createdBy.email,
}
: undefined,
client:
company !== undefined
? {
Expand Down Expand Up @@ -538,9 +540,8 @@ export function ticketRoutes(fastify: FastifyInstance) {

//@ts-expect-error
const { email, title } = ticket;

if (public_comment && email) {
sendComment(text, title, email);
sendComment(text, title, ticket!.id, email!);
}

await commentNotification(user!.id, ticket, user!.name);
Expand Down
136 changes: 117 additions & 19 deletions apps/api/src/lib/imap.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
const Imap = require("imap");
var EmailReplyParser = require("email-reply-parser");

import { GoogleAuth } from "google-auth-library";
import { prisma } from "../prisma";

const { simpleParser } = require("mailparser");
Expand All @@ -14,19 +17,103 @@ 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 () => {
try {
const queues = await client.emailQueue.findMany({});

for (let i = 0; i < queues.length; i++) {
var imapConfig = {
user: queues[i].username,
password: queues[i].password,
host: queues[i].hostname,
port: queues[i].tls ? 993 : 110,
tls: queues[i].tls,
tlsOptions: { servername: queues[i].hostname },
};
var imapConfig = await returnImapConfig(queues[i]);

if (!imapConfig) {
continue;
}

const imap = new Imap(imapConfig);
imap.connect();
Expand All @@ -53,9 +140,9 @@ export const getEmails = async () => {
simpleParser(stream, async (err: any, parsed: any) => {
const { from, subject, textAsHtml, text, html } = parsed;

// Handle reply emails
var reply_text = new EmailReplyParser().read(text);

if (subject?.includes("Re:")) {
// Extract ticket number from subject (e.g., "Re: Ticket #123")
const ticketIdMatch = subject.match(/#(\d+)/);
if (!ticketIdMatch) {
console.log(
Expand All @@ -67,17 +154,28 @@ export const getEmails = async () => {

const ticketId = ticketIdMatch[1];

// Create comment with the reply
return await client.comment.create({
data: {
text: text ? text : "No Body",
userId: null,
ticketId: ticketId,
reply: true,
replyEmail: from.value[0].address,
public: true,
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: {
Expand Down
5 changes: 3 additions & 2 deletions apps/api/src/lib/nodemailer/ticket/comment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createTransportProvider } from "../transport";
export async function sendComment(
comment: string,
title: string,
id: string,
email: string
) {
try {
Expand All @@ -30,8 +31,8 @@ export async function sendComment(
.sendMail({
from: provider?.reply,
to: email,
subject: `New comment on a ticket`, // Subject line
text: `Hello there, Ticket: ${title}, has had an update with a comment of ${comment}`, // plain text body
subject: `New comment on Issue #${title} ref: #${id}`,
text: `Hello there, Issue #${title}, has had an update with a comment of ${comment}`,
html: htmlToSend,
})
.then((info: any) => {
Expand Down
Loading

0 comments on commit dc568cf

Please sign in to comment.