Skip to content

Commit dc568cf

Browse files
committed
feat: oauth based imap mailboxes
1 parent 5d125db commit dc568cf

File tree

18 files changed

+720
-723
lines changed

18 files changed

+720
-723
lines changed

apps/api/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
},
1616
"devDependencies": {
1717
"@types/bcrypt": "^5.0.0",
18+
"@types/email-reply-parser": "^1",
1819
"@types/formidable": "^3.4.5",
1920
"@types/jsonwebtoken": "^8.5.8",
2021
"@types/node": "^17.0.23",
@@ -39,6 +40,7 @@
3940
"axios": "^1.5.0",
4041
"bcrypt": "^5.0.1",
4142
"dotenv": "^16.0.0",
43+
"email-reply-parser": "^1.8.1",
4244
"fastify": "4.22.2",
4345
"fastify-formidable": "^3.0.2",
4446
"fastify-multer": "^2.0.3",

apps/api/src/controllers/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
// SSO Provider
55
// Portal Locale
66
// Feature Flags
7-
import { GoogleAuth, OAuth2Client } from "google-auth-library";
7+
import { OAuth2Client } from "google-auth-library";
88
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
99
const nodemailer = require("nodemailer");
1010

apps/api/src/controllers/queue.ts

Lines changed: 97 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
22

33
import { checkToken } from "../lib/jwt";
44
import { prisma } from "../prisma";
5+
import { OAuth2Client } from "google-auth-library";
56

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

1516
if (token) {
16-
const { name, username, password, hostname, tls }: any = request.body;
17-
18-
await prisma.emailQueue.create({
17+
const {
18+
name,
19+
username,
20+
password,
21+
hostname,
22+
tls,
23+
serviceType,
24+
clientId,
25+
clientSecret,
26+
redirectUri,
27+
}: any = request.body;
28+
29+
const mailbox = await prisma.emailQueue.create({
1930
data: {
20-
name,
31+
name: name,
2132
username,
2233
password,
2334
hostname,
2435
tls,
36+
serviceType,
37+
clientId,
38+
clientSecret,
39+
redirectUri,
2540
},
2641
});
2742

43+
// generate redirect uri
44+
if (serviceType === "gmail") {
45+
const google = new OAuth2Client(clientId, clientSecret, redirectUri);
46+
47+
const authorizeUrl = google.generateAuthUrl({
48+
access_type: "offline",
49+
scope: "https://mail.google.com",
50+
prompt: "consent",
51+
state: mailbox.id,
52+
});
53+
54+
reply.send({
55+
success: true,
56+
message: "Gmail imap provider created!",
57+
authorizeUrl: authorizeUrl,
58+
});
59+
}
60+
2861
reply.send({
2962
success: true,
3063
});
3164
}
3265
}
3366
);
3467

35-
// Get all email queues
68+
// Google oauth callback
69+
fastify.get(
70+
"/api/v1/email-queue/oauth/gmail",
71+
72+
async (request: FastifyRequest, reply: FastifyReply) => {
73+
const bearer = request.headers.authorization!.split(" ")[1];
74+
const token = checkToken(bearer);
75+
76+
if (token) {
77+
const { code, mailboxId }: any = request.query;
78+
79+
const mailbox = await prisma.emailQueue.findFirst({
80+
where: {
81+
id: mailboxId,
82+
},
83+
});
84+
85+
const google = new OAuth2Client(
86+
//@ts-expect-error
87+
mailbox?.clientId,
88+
mailbox?.clientSecret,
89+
mailbox?.redirectUri
90+
);
3691

92+
console.log(google);
93+
94+
const r = await google.getToken(code);
95+
96+
await prisma.emailQueue.update({
97+
where: { id: mailbox?.id },
98+
data: {
99+
refreshToken: r.tokens.refresh_token,
100+
accessToken: r.tokens.access_token,
101+
expiresIn: r.tokens.expiry_date,
102+
serviceType: "gmail",
103+
},
104+
});
105+
106+
reply.send({
107+
success: true,
108+
message: "Mailbox updated!",
109+
});
110+
}
111+
}
112+
);
113+
114+
// Get all email queue's
37115
fastify.get(
38116
"/api/v1/email-queues/all",
39117

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

44122
if (token) {
45-
const queues = await prisma.emailQueue.findMany({});
123+
const queues = await prisma.emailQueue.findMany({
124+
select: {
125+
id: true,
126+
name: true,
127+
serviceType: true,
128+
active: true,
129+
teams: true,
130+
username: true,
131+
hostname: true,
132+
tls: true,
133+
clientId: true,
134+
redirectUri: true,
135+
},
136+
});
46137

47138
reply.send({
48139
success: true,

apps/api/src/controllers/ticket.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export function ticketRoutes(fastify: FastifyInstance) {
3737
email,
3838
engineer,
3939
type,
40-
createdBy
40+
createdBy,
4141
}: any = request.body;
4242

4343
const ticket: any = await prisma.ticket.create({
@@ -48,12 +48,14 @@ export function ticketRoutes(fastify: FastifyInstance) {
4848
priority: priority ? priority : "low",
4949
email,
5050
type: type ? type.toLowerCase() : "support",
51-
createdBy: createdBy ? {
52-
id: createdBy.id,
53-
name: createdBy.name,
54-
role: createdBy.role,
55-
email: createdBy.email
56-
} : undefined,
51+
createdBy: createdBy
52+
? {
53+
id: createdBy.id,
54+
name: createdBy.name,
55+
role: createdBy.role,
56+
email: createdBy.email,
57+
}
58+
: undefined,
5759
client:
5860
company !== undefined
5961
? {
@@ -538,9 +540,8 @@ export function ticketRoutes(fastify: FastifyInstance) {
538540

539541
//@ts-expect-error
540542
const { email, title } = ticket;
541-
542543
if (public_comment && email) {
543-
sendComment(text, title, email);
544+
sendComment(text, title, ticket!.id, email!);
544545
}
545546

546547
await commentNotification(user!.id, ticket, user!.name);

apps/api/src/lib/imap.ts

Lines changed: 117 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
const Imap = require("imap");
2+
var EmailReplyParser = require("email-reply-parser");
3+
4+
import { GoogleAuth } from "google-auth-library";
25
import { prisma } from "../prisma";
36

47
const { simpleParser } = require("mailparser");
@@ -14,19 +17,103 @@ const year = date.getFullYear();
1417
//@ts-ignore
1518
const d = new Date([year, month, today]);
1619

20+
// Function to get or refresh the access token
21+
async function getValidAccessToken(queue: any) {
22+
const {
23+
clientId,
24+
clientSecret,
25+
refreshToken,
26+
accessToken,
27+
expiresIn,
28+
username,
29+
} = queue;
30+
31+
// Check if token is still valid
32+
const now = Math.floor(Date.now() / 1000);
33+
if (accessToken && expiresIn && now < expiresIn) {
34+
return accessToken;
35+
}
36+
37+
// Initialize GoogleAuth client
38+
const auth = new GoogleAuth({
39+
clientOptions: {
40+
clientId: clientId,
41+
clientSecret: clientSecret,
42+
},
43+
});
44+
45+
const oauth2Client = auth.fromJSON({
46+
client_id: clientId,
47+
client_secret: clientSecret,
48+
refresh_token: refreshToken,
49+
});
50+
51+
// Refresh the token if expired
52+
const tokenInfo = await oauth2Client.getAccessToken();
53+
54+
const expiryDate = queue.expiresIn + 3600;
55+
56+
if (tokenInfo.token) {
57+
await prisma.emailQueue.update({
58+
where: { id: queue.id },
59+
data: {
60+
accessToken: tokenInfo.token,
61+
expiresIn: expiryDate,
62+
},
63+
});
64+
return tokenInfo.token;
65+
} else {
66+
throw new Error("Unable to refresh access token.");
67+
}
68+
}
69+
70+
// Function to generate XOAUTH2 string
71+
function generateXOAuth2Token(user: string, accessToken: string) {
72+
const authString = [
73+
"user=" + user,
74+
"auth=Bearer " + accessToken,
75+
"",
76+
"",
77+
].join("\x01");
78+
return Buffer.from(authString).toString("base64");
79+
}
80+
81+
async function returnImapConfig(queue: any) {
82+
switch (queue.serviceType) {
83+
case "gmail":
84+
const validatedAccessToken = await getValidAccessToken(queue);
85+
return {
86+
user: queue.username,
87+
host: queue.hostname,
88+
port: 993,
89+
tls: true,
90+
xoauth2: generateXOAuth2Token(queue.username, validatedAccessToken),
91+
tlsOptions: { rejectUnauthorized: false, servername: queue.hostname },
92+
};
93+
case "other":
94+
return {
95+
user: queue.username,
96+
password: queue.password,
97+
host: queue.hostname,
98+
port: queue.tls ? 993 : 143,
99+
tls: queue.tls,
100+
tlsOptions: { rejectUnauthorized: false, servername: queue.hostname },
101+
};
102+
default:
103+
throw new Error("Unsupported service type");
104+
}
105+
}
106+
17107
export const getEmails = async () => {
18108
try {
19109
const queues = await client.emailQueue.findMany({});
20110

21111
for (let i = 0; i < queues.length; i++) {
22-
var imapConfig = {
23-
user: queues[i].username,
24-
password: queues[i].password,
25-
host: queues[i].hostname,
26-
port: queues[i].tls ? 993 : 110,
27-
tls: queues[i].tls,
28-
tlsOptions: { servername: queues[i].hostname },
29-
};
112+
var imapConfig = await returnImapConfig(queues[i]);
113+
114+
if (!imapConfig) {
115+
continue;
116+
}
30117

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

56-
// Handle reply emails
143+
var reply_text = new EmailReplyParser().read(text);
144+
57145
if (subject?.includes("Re:")) {
58-
// Extract ticket number from subject (e.g., "Re: Ticket #123")
59146
const ticketIdMatch = subject.match(/#(\d+)/);
60147
if (!ticketIdMatch) {
61148
console.log(
@@ -67,17 +154,28 @@ export const getEmails = async () => {
67154

68155
const ticketId = ticketIdMatch[1];
69156

70-
// Create comment with the reply
71-
return await client.comment.create({
72-
data: {
73-
text: text ? text : "No Body",
74-
userId: null,
75-
ticketId: ticketId,
76-
reply: true,
77-
replyEmail: from.value[0].address,
78-
public: true,
157+
const find = await client.ticket.findFirst({
158+
where: {
159+
Number: Number(ticketId),
79160
},
80161
});
162+
163+
if (find) {
164+
return await client.comment.create({
165+
data: {
166+
text: text
167+
? reply_text.fragments[0]._content
168+
: "No Body",
169+
userId: null,
170+
ticketId: find.id,
171+
reply: true,
172+
replyEmail: from.value[0].address,
173+
public: true,
174+
},
175+
});
176+
} else {
177+
console.log("Ticket not found");
178+
}
81179
} else {
82180
const imap = await client.imap_Email.create({
83181
data: {

apps/api/src/lib/nodemailer/ticket/comment.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { createTransportProvider } from "../transport";
55
export async function sendComment(
66
comment: string,
77
title: string,
8+
id: string,
89
email: string
910
) {
1011
try {
@@ -30,8 +31,8 @@ export async function sendComment(
3031
.sendMail({
3132
from: provider?.reply,
3233
to: email,
33-
subject: `New comment on a ticket`, // Subject line
34-
text: `Hello there, Ticket: ${title}, has had an update with a comment of ${comment}`, // plain text body
34+
subject: `New comment on Issue #${title} ref: #${id}`,
35+
text: `Hello there, Issue #${title}, has had an update with a comment of ${comment}`,
3536
html: htmlToSend,
3637
})
3738
.then((info: any) => {

0 commit comments

Comments
 (0)