diff --git a/backend/src/app.ts b/backend/src/app.ts index 4cb9a89a1..efa617520 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -18,7 +18,6 @@ import initializeSentry from './middleware/sentry'; import initializeAuthRoutes from './routes/auth.routes'; import initializeContactsRoutes from './routes/contacts.routes'; import initializeEnrichmentRoutes from './routes/enrichment.routes'; -import initializeImapRoutes from './routes/imap.routes'; import initializeMiningRoutes from './routes/mining.routes'; import initializeSmtpSendersRoutes from './routes/smtp-senders.routes'; import initializeStreamRouter from './routes/stream.routes'; @@ -65,7 +64,6 @@ export default function initializeApp( ); app.use('/api/auth', initializeAuthRoutes()); - app.use('/api/imap', initializeImapRoutes(authResolver, miningSourceService)); app.use('/api/imap', initializeStreamRouter(miningEngine, authResolver)); app.use( '/api/imap', diff --git a/backend/src/controllers/imap.controller.ts b/backend/src/controllers/imap.controller.ts deleted file mode 100644 index 9ed33eb24..000000000 --- a/backend/src/controllers/imap.controller.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { User } from '@supabase/supabase-js'; -import { NextFunction, Request, Response } from 'express'; -import { - MiningSources, - OAuthMiningSourceCredentials -} from '../db/interfaces/MiningSources'; -import azureOAuth2Client from '../services/OAuth2/azure'; -import googleOAuth2Client from '../services/OAuth2/google'; -import ImapBoxesFetcher from '../services/imap/ImapBoxesFetcher'; -import ImapConnectionProvider from '../services/imap/ImapConnectionProvider'; -import { ImapAuthError } from '../utils/errors'; -import hashEmail from '../utils/helpers/hashHelpers'; -import logger from '../utils/logger'; -import { generateErrorObjectFromImapError } from './imap.helpers'; - -function getTokenAndProvider(data: OAuthMiningSourceCredentials) { - const { provider, accessToken, refreshToken, expiresAt } = data; - const client = provider === 'azure' ? azureOAuth2Client : googleOAuth2Client; - - const token = client.createToken({ - access_token: accessToken, - refresh_token: refreshToken, - expires_at: expiresAt - }); - - return { token, refreshToken, provider }; -} - -export default function initializeImapController( - miningSourceService: MiningSources -) { - return { - async getImapBoxes(req: Request, res: Response, next: NextFunction) { - const { email } = req.body; - - let imapConnection: Awaited< - ReturnType - > | null = null; - - try { - const userId = (res.locals.user as User).id; - const sources = await miningSourceService.getSourcesForUser( - userId, - email - ); - - const data = - sources?.find((e) => e.email === email)?.credentials ?? null; - - if (!data) { - res.status(400); - return next( - new Error('Unable to retrieve credentials for this mining source') - ); - } - - const isImapCredentials = - 'tls' in data && 'email' in data && 'password' in data; - - if (!('accessToken' in data) && !isImapCredentials) { - return res.status(400).send({ - data: { - message: 'This mining source does not support IMAP folders lookup' - } - }); - } - - if ('accessToken' in data) { - const { token, refreshToken } = getTokenAndProvider(data); - - if (!refreshToken) - return res.status(401).send({ - data: { message: 'No Refresh Token' } - }); - - if (token.expired(1000)) { - return res.status(401).send({ - data: { message: 'Access token is expired' } - }); - } - } - - imapConnection = await ImapConnectionProvider.getSingleConnection( - email, - 'accessToken' in data - ? { - oauthToken: data.accessToken - } - : { - host: data.host, - password: data.password, - tls: data.tls, - port: data.port - } - ); - - const imapBoxesFetcher = new ImapBoxesFetcher(imapConnection, logger); - const tree: any = await imapBoxesFetcher.getTree(email); - - logger.info('Mining IMAP tree succeeded.', { - metadata: { - user: hashEmail(email, userId) - } - }); - - return res.status(200).send({ - data: { message: 'IMAP folders fetched successfully!', folders: tree } - }); - } catch (error: any) { - logger.error('Error during inbox fetch', { - message: error.message, - stack: error.stack, - code: error.code - }); - - if ([502, 503].includes(error?.output?.payload?.statusCode)) { - return res - .status(error?.output?.payload?.statusCode) - .send(error?.output?.payload?.error); - } - - const generatedError = generateErrorObjectFromImapError(error); - if (generatedError instanceof ImapAuthError) { - return res.status(generatedError.status).send(generatedError); - } - return next(generatedError); - } finally { - if (imapConnection) { - try { - await imapConnection.logout(); - } catch (logoutError) { - logger.warn( - 'Unable to close IMAP connection cleanly.', - logoutError - ); - } - } - } - } - }; -} diff --git a/backend/src/routes/imap.routes.ts b/backend/src/routes/imap.routes.ts deleted file mode 100644 index c47d58c07..000000000 --- a/backend/src/routes/imap.routes.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Router } from 'express'; -import initializeImapController from '../controllers/imap.controller'; -import { MiningSources } from '../db/interfaces/MiningSources'; -import initializeAuthMiddleware from '../middleware/auth'; -import AuthResolver from '../services/auth/AuthResolver'; - -export default function initializeImapRoutes( - authResolver: AuthResolver, - miningSources: MiningSources -) { - const router = Router(); - - const { getImapBoxes } = initializeImapController(miningSources); - - router.post('/boxes', initializeAuthMiddleware(authResolver), getImapBoxes); - - return router; -} diff --git a/backend/src/services/imap/ImapBoxesFetcher.ts b/backend/src/services/imap/ImapBoxesFetcher.ts deleted file mode 100644 index 868dbcb22..000000000 --- a/backend/src/services/imap/ImapBoxesFetcher.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { ImapFlow as Connection } from 'imapflow'; -import { Logger } from 'winston'; -import { - buildFinalTree, - createFlatTreeFromImap -} from '../../utils/helpers/imapTreeHelpers'; -import { EXCLUDED_IMAP_FOLDERS } from '../../utils/constants'; - -export default class ImapBoxesFetcher { - constructor( - private readonly imapConnection: Connection, - private readonly logger: Logger - ) {} - - /** - * Retrieves the IMAP tree of the email account. - * @returns IMAP tree. - */ - async getTree(userEmail: string) { - const tree = await this.imapConnection.list({ - statusQuery: { messages: true } - }); - - return buildFinalTree(createFlatTreeFromImap(tree), userEmail); - } - - /** - * Fetches the total number of messages across the specified folders on an IMAP server. - */ - async getTotalMessages(inboxes: string[]) { - let total = 0; - - try { - // Create an array of Promises that resolve to the total number of messages in each folder. - const folders = inboxes.filter( - (folder) => !EXCLUDED_IMAP_FOLDERS.includes(folder) - ); - - folders.forEach(async (folder) => { - try { - const status = await this.imapConnection?.status(folder, { - messages: true - }); - total += status?.messages ?? 0; - } catch (err) { - this.logger.warn(`Could not STATUS ${folder}`, err); - } - }); - return total; - } catch (err) { - this.logger.error('Failed fetching total messages', { - folders: inboxes, - error: err - }); - throw err; - } - } -} diff --git a/frontend/src/stores/leadminer.ts b/frontend/src/stores/leadminer.ts index dedc37313..8058f51d4 100644 --- a/frontend/src/stores/leadminer.ts +++ b/frontend/src/stores/leadminer.ts @@ -226,13 +226,10 @@ export const useLeadminerStore = defineStore('leadminer', () => { selectedBoxes.value = []; extractSignatures.value = true; - const { data } = await $api<{ + const { data } = await supabase.functions.invoke<{ data: { message: string; folders: BoxNode[] }; - }>('/imap/boxes', { - method: 'POST', - body: { - ...activeMiningSource.value, - }, + }>('imap/boxes', { + body: { email: activeMiningSource.value.email }, }); const { folders } = data || {}; diff --git a/micro-services/emails-fetcher/src/api.ts b/micro-services/emails-fetcher/src/api.ts index a43503ab0..1932dd2b4 100644 --- a/micro-services/emails-fetcher/src/api.ts +++ b/micro-services/emails-fetcher/src/api.ts @@ -8,6 +8,7 @@ import EmailsFetcher, { import GoogleContactsFetcher from './services/google_contacts/GoogleContactsFetcher'; import EmailFetcherFactory from './factory/EmailFetcherFactory'; import PSTFetcherFactory from './factory/PSTFetcherFactory'; +import ImapBoxesFetcher from './services/imap/ImapBoxesFetcher'; import ImapConnectionProvider from './services/imap/ImapConnectionProvider'; import { generateErrorObjectFromImapError } from './utils/imap'; import logger from './utils/logger'; @@ -566,4 +567,81 @@ apiRoutes.delete( } ); +apiRoutes.post( + '/imap/boxes', + async (req: Request, res: Response, next: NextFunction) => { + const { userId, email } = req.body; + + const errors = [ + validateType('userId', userId, 'string'), + validateType('email', email, 'string') + ].filter(Boolean); + + if (errors.length) { + return res + .status(400) + .json({ message: `Invalid input: ${errors.join(', ')}` }); + } + + const sanitizedEmail = sanitizeImapInput(email); + let connection: Connection | undefined; + + try { + const sources = await miningSourceService.getSourcesForUser( + userId, + sanitizedEmail + ); + const miningSourceCredentials = sources?.pop()?.credentials; + + if (!miningSourceCredentials) { + return res.status(401).json({ + message: "This mining source isn't registered for this user" + }); + } + + const credentials = + 'accessToken' in miningSourceCredentials + ? { + oauthToken: miningSourceCredentials.accessToken + } + : { + host: miningSourceCredentials.host, + password: miningSourceCredentials.password, + tls: miningSourceCredentials.tls, + port: miningSourceCredentials.port + }; + + connection = await ImapConnectionProvider.getSingleConnection( + sanitizedEmail, + credentials + ); + + const boxesFetcher = new ImapBoxesFetcher(connection, logger); + const folders = await boxesFetcher.getTree(sanitizedEmail); + + return res.status(200).json({ + data: { + message: 'IMAP folders fetched successfully', + folders + } + }); + } catch (err) { + logger.error('Failed to fetch IMAP boxes', err); + const newError = generateErrorObjectFromImapError(err); + + res.status(500); + return next(new Error(newError.message)); + } finally { + if (connection) { + try { + await connection.logout(); + } catch (logoutError) { + logger.error('Error closing IMAP connection', logoutError); + connection.close(); + } + } + } + } +); + export default apiRoutes; diff --git a/micro-services/emails-fetcher/src/services/imap/ImapBoxesFetcher.ts b/micro-services/emails-fetcher/src/services/imap/ImapBoxesFetcher.ts new file mode 100644 index 000000000..a3e380fb6 --- /dev/null +++ b/micro-services/emails-fetcher/src/services/imap/ImapBoxesFetcher.ts @@ -0,0 +1,25 @@ +import { ImapFlow as Connection } from 'imapflow'; +import { Logger } from 'winston'; +import { + buildFinalTree, + createFlatTreeFromImap +} from '../../utils/helpers/imapTreeHelpers'; + +export default class ImapBoxesFetcher { + constructor( + private readonly imapConnection: Connection, + private readonly logger: Logger + ) {} + + /** + * Retrieves the IMAP tree of the email account. + * @returns IMAP tree. + */ + async getTree(userEmail: string) { + const tree = await this.imapConnection.list({ + statusQuery: { messages: true } + }); + + return buildFinalTree(createFlatTreeFromImap(tree), userEmail); + } +} diff --git a/micro-services/emails-fetcher/src/utils/helpers/imapTreeHelpers.ts b/micro-services/emails-fetcher/src/utils/helpers/imapTreeHelpers.ts new file mode 100644 index 000000000..9d8b863de --- /dev/null +++ b/micro-services/emails-fetcher/src/utils/helpers/imapTreeHelpers.ts @@ -0,0 +1,63 @@ +import { ListResponse } from 'imapflow'; +import { FlatTree } from '../../services/imap/types'; + +export function createFlatTreeFromImap(boxes: ListResponse[]): FlatTree[] { + const pathMap = new Map(); + + // Create FlatTree nodes without linking parents yet + for (const box of boxes) { + pathMap.set(box.path, { + label: box.name, + key: box.path, + total: box.status?.messages || 0, + cumulativeTotal: box.status?.messages || 0, + attribs: Array.from(box.flags.values()) + }); + } + + // Assign parent references + for (const box of boxes) { + const node = pathMap.get(box.path); + if (node && box.parentPath && pathMap.has(box.parentPath)) { + node.parent = pathMap.get(box.parentPath); + } + } + + return [...pathMap.values()]; +} + +/** + * @param flatTree - A flat array of objects to build a tree. + * @param userEmail - The email address of the user you want to get the data for. + */ + +export function buildFinalTree(flatTree: FlatTree[], userEmail: string) { + const readableTree = []; + let totalInEmail = 0; + + for (const box of flatTree) { + box.key = box.key.toString(); + + if (box.parent) { + if (box.parent.children) { + box.parent.children.push(box); + } else { + box.parent.children = [box]; + } + box.parent.cumulativeTotal! += box.total!; + } else { + readableTree.push(box); + } + totalInEmail += box.total!; + delete box.parent; + } + + return [ + { + label: userEmail, + children: [...readableTree], + total: totalInEmail, + key: '' + } + ]; +} diff --git a/supabase/functions/.env.dev b/supabase/functions/.env.dev index e3d42ef11..a090fe3f4 100644 --- a/supabase/functions/.env.dev +++ b/supabase/functions/.env.dev @@ -12,6 +12,7 @@ campaign_compliance_footer= LOGO_URL="https://db.leadminer.io/storage/v1/object/public/templates/LogoWithIcon.png" FRONTEND_HOST="http://localhost:8082" SERVER_ENDPOINT = http://localhost:8081 # ( REQUIRED ) URL of the Backend server. +EMAILS_FETCHER_URL=http://localhost:7778 # Required for IMAP box listing OAUTH_CALLBACK_BASE_URL = http://localhost:54321 # ( REQUIRED ) Public Supabase URL for OAuth callback. OAuth providers redirect here (e.g., https://db.yourdomain.com for production). diff --git a/supabase/functions/imap/deno.json b/supabase/functions/imap/deno.json index f82f499c3..b2348c3ca 100644 --- a/supabase/functions/imap/deno.json +++ b/supabase/functions/imap/deno.json @@ -1,5 +1,7 @@ { "imports": { + "hono": "https://esm.sh/hono@4.7.4", + "hono/": "https://esm.sh/hono@4.7.4/", "zod": "https://deno.land/x/zod@v3.25/mod.ts" } } diff --git a/supabase/functions/imap/index.ts b/supabase/functions/imap/index.ts index a0a5051d2..876f6560d 100644 --- a/supabase/functions/imap/index.ts +++ b/supabase/functions/imap/index.ts @@ -1,9 +1,25 @@ +import { Hono } from "hono"; import corsHeaders from "../_shared/cors.ts"; -import Logger from "../_shared/logger.ts"; +import { createLogger } from "../_shared/logger.ts"; import { createSupabaseClient } from "../_shared/supabase.ts"; import { emailSchema } from "../_shared/validation.ts"; import IMAPSettingsDetector from "npm:@ankaboot.io/imap-autoconfig"; +const logger = createLogger("imap"); +const functionName = "imap"; +const app = new Hono().basePath(`/${functionName}`); + +const EMAILS_FETCHER_URL = Deno.env.get("EMAILS_FETCHER_URL") as string; + +// CORS middleware +app.use("*", async (_c, next) => { + await next(); + Object.entries(corsHeaders).forEach(([key, value]) => { + _c.res.headers.set(key, value); + }); +}); +app.options("*", () => new Response("ok", { headers: corsHeaders })); + /** * Sanitizes input to prevent IMAP injection and CRLF attacks. * - Removes special IMAP characters: `{}`, `"`, `\`, `(`, `)`, `*`. @@ -32,9 +48,9 @@ export function sanitizeImapInput(input: string): string { /** * Validates the authorization token and retrieves the authenticated user. - * Returns the user or a 401 Response if unauthorized. + * Returns the user or throws/returns a 401 Response. */ -async function validateAuthAndGetUser(authorization: string | null) { +async function validateAuthAndGetUser(authorization: string | null | undefined) { const client = createSupabaseClient(authorization ?? ""); const { data: { user }, @@ -53,51 +69,65 @@ async function validateAuthAndGetUser(authorization: string | null) { } /** - * Extracts the email from request parameters. - * Returns the email string or a 400 Response if missing. + * POST /detect - Detects IMAP settings for the given email address. */ -function extractEmailFromRequest(req: Request) { - const email = new URL(req.url).searchParams.get("email"); - const parsed = emailSchema.safeParse(email); +app.post("/detect", async (c) => { + try { + const user = await validateAuthAndGetUser(c.req.header("authorization")); - if (!parsed.success) { - return new Response(null, { - headers: { ...corsHeaders, "Content-Type": "application/json" }, - status: 400, - }); - } + if (user instanceof Response) return user; - return sanitizeImapInput(parsed.data); -} + const email = c.req.query("email"); + const parsed = emailSchema.safeParse(email); -/** - * Handles incoming requests, ensuring authentication and processing IMAP detection. - */ -Deno.serve(async (req: Request) => { - if (req.method === "OPTIONS") { - return new Response("ok", { headers: corsHeaders }); + if (!parsed.success) { + return c.json({ error: "Invalid email" }, 400); + } + + const sanitizedEmail = sanitizeImapInput(parsed.data); + const config = await new IMAPSettingsDetector.default().detect( + sanitizedEmail, + ); + + return c.json(config, 200); + } catch (error) { + logger.error(`Server error: ${(error as Error).message}`); + return c.json({ error: "Internal Server Error" }, 500); } +}); +/** + * POST /boxes - Proxies to emails-fetcher microservice to list IMAP boxes. + */ +app.post("/boxes", async (c) => { try { - const user = await validateAuthAndGetUser(req.headers.get("authorization")); + const user = await validateAuthAndGetUser(c.req.header("authorization")); if (user instanceof Response) return user; - const email = extractEmailFromRequest(req); - - if (email instanceof Response) return email; + const body = await c.req.json(); + const { mining_id, email } = body as { + mining_id?: string; + email?: string; + }; - const config = await new IMAPSettingsDetector.default().detect(email); + if (!mining_id || !email) { + return c.json({ error: "mining_id and email are required" }, 400); + } - return new Response(JSON.stringify(config), { - headers: { ...corsHeaders, "Content-Type": "application/json" }, - status: 200, + const response = await fetch(`${EMAILS_FETCHER_URL}/api/imap/boxes`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId: user.id, email }), }); + + const data = await response.json(); + + return c.json(data, response.status as 200); } catch (error) { - Logger.error(`Server error: ${(error as Error).message}`); - return new Response(JSON.stringify({ error: "Internal Server Error" }), { - headers: { ...corsHeaders, "Content-Type": "application/json" }, - status: 500, - }); + logger.error(`Boxes endpoint error: ${(error as Error).message}`); + return c.json({ error: "Internal Server Error" }, 500); } }); + +Deno.serve(app.fetch);