/** * IMAP Service * Generic IMAP (e.g. Porkbun, Nextcloud mail backend) – connect, list, fetch, move to folder */ import { ImapFlow } from 'imapflow' import { log } from '../middleware/logger.mjs' const INBOX = 'INBOX' const FOLDER_PREFIX = 'MailFlow' /** Timeouts (ms) — avoid hanging on bad host/firewall */ const IMAP_CONNECT_TIMEOUT_MS = 10_000 const IMAP_OPERATION_TIMEOUT_MS = 10_000 /** Per-command cap so dead connections fail fast (sort try/catch + race can complete). */ const IMAP_OP_TIMEOUT_MS = 5000 async function withOpTimeout(promise, label = 'IMAP op') { return Promise.race([ promise, new Promise((_, reject) => setTimeout(() => reject(new Error(`${label} timed out`)), IMAP_OP_TIMEOUT_MS) ), ]) } function rejectAfter(ms, message) { return new Promise((_, reject) => { setTimeout(() => reject(new Error(message)), ms) }) } async function withTimeout(promise, ms, label) { const msg = `${label} timed out after ${ms / 1000} seconds` return Promise.race([promise, rejectAfter(ms, msg)]) } /** Map category key to IMAP folder name */ export function getFolderNameForCategory(category) { const map = { vip: 'VIP', customers: 'Clients', invoices: 'Invoices', newsletters: 'Newsletters', promotions: 'Promotions', social: 'Social', security: 'Security', calendar: 'Calendar', review: 'Review', archive: 'Archive', } return map[category] || 'Review' } /** * Folder last segments that must NEVER be used as sort targets (Trash, Sent, Drafts, Deleted only). * Junk, Spam, Archive, Favorites are valid targets. */ const FORBIDDEN_LAST_SEGMENTS = new Set(['trash', 'sent', 'drafts', 'deleted']) /** Full paths that are never valid move targets (case-insensitive). */ const FORBIDDEN_FULL_PATHS = new Set([ 'trash', 'sent', 'drafts', 'deleted', 'inbox.trash', 'inbox.sent', 'inbox.drafts', 'inbox.deleted', ]) /** * Keywords per AI category for matching existing mailbox paths (case-insensitive). * First match wins (keyword order, then folder list order). */ export const CATEGORY_FOLDER_KEYWORDS = { vip: ['favorites', 'vip', 'important', 'priority', 'wichtig'], customers: ['clients', 'customers', 'kunden', 'client'], invoices: ['invoices', 'invoice', 'rechnungen', 'rechnung', 'billing'], newsletters: ['junk', 'newsletters', 'newsletter', 'subscriptions'], promotions: ['junk', 'promotions', 'promotion', 'marketing', 'spam'], social: ['social', 'notifications', 'team'], security: ['security', 'alerts', 'sicherheit'], calendar: ['calendar', 'meetings'], review: ['archive', 'review', 'later'], } function lastMailboxSegment(path) { if (!path || typeof path !== 'string') return '' const parts = path.split(/[/\\]/).filter(Boolean) const leaf = parts.length ? parts[parts.length - 1] : path const dotted = leaf.split('.') return dotted[dotted.length - 1].toLowerCase() } /** * True if this path must not be used as a sort move destination. */ export function isForbiddenMoveTarget(folderPath) { if (!folderPath || typeof folderPath !== 'string') return true const norm = folderPath.trim() if (!norm) return true if (norm.toUpperCase() === 'INBOX') return true const fullLower = norm.toLowerCase().replace(/\\/g, '/') if (FORBIDDEN_FULL_PATHS.has(fullLower)) return true const last = lastMailboxSegment(norm) return FORBIDDEN_LAST_SEGMENTS.has(last) } /** * Pick the best existing folder for a category, or null (keep in INBOX). * Prefers exact last-segment match (e.g. path "Junk" or "*.Junk") over substring (avoids wrong "junk" hits). * @param {string} category * @param {string[]} existingFolders - paths from LIST * @returns {string | null} */ export function findBestFolder(category, existingFolders) { if (!existingFolders?.length) return null const keywords = CATEGORY_FOLDER_KEYWORDS[category] if (!keywords?.length) return null for (const keyword of keywords) { const kw = keyword.toLowerCase() for (const folderPath of existingFolders) { if (!folderPath || isForbiddenMoveTarget(folderPath)) continue if (lastMailboxSegment(folderPath) === kw) return folderPath } } for (const keyword of keywords) { const kw = keyword.toLowerCase() for (const folderPath of existingFolders) { if (!folderPath || isForbiddenMoveTarget(folderPath)) continue const pathLower = folderPath.toLowerCase() if (pathLower.includes(kw)) return folderPath } } return null } /** System mailboxes we never pull from during "re-sort" recovery (keep Sent/Drafts/Trash untouched). */ const RESORT_SKIP_LAST_SEGMENTS = new Set([ 'sent', 'drafts', 'trash', 'deleted', 'templates', 'outbox', 'inbox', ]) /** * Folders whose messages we move back to INBOX before a full re-sort (sort destinations + MailFlow/EmailSorter trees). * @param {string} folderPath * @returns {boolean} */ export function isReSortRecoveryFolder(folderPath) { if (!folderPath || typeof folderPath !== 'string') return false const norm = folderPath.trim() if (!norm || norm.toUpperCase() === 'INBOX') return false const last = lastMailboxSegment(norm) if (RESORT_SKIP_LAST_SEGMENTS.has(last)) return false const low = norm.toLowerCase().replace(/\\/g, '/') if (low.includes('mailflow') || low.includes('emailsorter')) return true const sortLeaves = new Set([ 'junk', 'spam', 'archive', 'newsletters', 'newsletter', 'promotions', 'promotion', 'social', 'review', 'security', 'calendar', 'invoices', 'invoice', 'clients', 'customers', 'client', 'vip', 'favorites', 'important', 'subscriptions', 'marketing', 'team', 'meetings', 'later', 'billing', 'rechnungen', 'rechnung', ]) return sortLeaves.has(last) } /** * Match a person/team folder by local name appearing in From or Subject. * @param {{ from?: string, subject?: string }} emailData * @param {string[]} existingFolders * @returns {string | null} */ export function findPersonFolder(emailData, existingFolders) { const from = (emailData.from || '').toLowerCase() const subject = (emailData.subject || '').toLowerCase() for (const folder of existingFolders) { const folderName = folder.split('.').pop() || folder if (folderName.length < 3) continue const skip = [ 'inbox', 'sent', 'trash', 'junk', 'spam', 'drafts', 'archive', 'deleted', 'favorites', 'emailsorter', 'vip', 'clients', 'invoices', 'newsletters', 'promotions', 'security', 'calendar', 'review', ] if (skip.includes(folderName.toLowerCase())) continue const name = folderName.toLowerCase() if (from.includes(name) || subject.includes(name)) { return folder } } return null } /** IMAP keywords ($MailFlow-*) — reliable on hosts that block custom folders */ export function getMailFlowKeywordForCategory(category) { const map = { vip: '$MailFlow-VIP', customers: '$MailFlow-Clients', invoices: '$MailFlow-Invoices', newsletters: '$MailFlow-Newsletters', promotions: '$MailFlow-Promotions', social: '$MailFlow-Social', security: '$MailFlow-Security', calendar: '$MailFlow-Calendar', review: '$MailFlow-Review', archive: '$MailFlow-Archive', } return map[category] || '$MailFlow-Review' } /** * IMAP Service – same conceptual interface as GmailService/OutlookService */ export class ImapService { /** * @param {object} opts * @param {string} opts.host - e.g. imap.porkbun.com * @param {number} opts.port - e.g. 993 * @param {boolean} opts.secure - true for SSL/TLS * @param {string} opts.user - email address * @param {string} opts.password - app password */ constructor(opts) { const { host, port = 993, secure = true, user, password } = opts this.host = host || 'imap.porkbun.com' this.port = port || 993 this._imapFlowOptions = { host: this.host, port: this.port, secure: secure !== false, auth: { user, pass: password }, logger: false, } this.client = new ImapFlow({ ...this._imapFlowOptions }) this.lock = null } async ensureConnected() { try { if (!this.client.usable) throw new Error('not usable') await withOpTimeout(this.client.noop(), 'noop') } catch { try { if (this.lock) await this.lock.release().catch(() => {}) } catch { /* ignore */ } this.lock = null try { await this.client.logout() } catch { try { this.client.close() } catch { /* ignore */ } } this.client = new ImapFlow({ ...this._imapFlowOptions }) await this.connect() } } async connect() { console.log('[IMAP] Connecting to:', this.host, this.port) await withTimeout(this.client.connect(), IMAP_CONNECT_TIMEOUT_MS, 'IMAP connect') } async close() { try { if (this.lock) await this.lock.release().catch(() => {}) await this.client.logout() } catch { this.client.close() } } /** * List messages from INBOX (returns ids = UIDs for use with getEmail/batchGetEmails) * @param {number} maxResults * @param {string|null} _pageToken - reserved for future pagination */ async listEmails(maxResults = 50, _pageToken = null) { return withTimeout(this._listEmailsImpl(maxResults, _pageToken), IMAP_OPERATION_TIMEOUT_MS, 'IMAP listEmails') } async _listEmailsImpl(maxResults = 50, _pageToken = null) { let lock = null try { lock = await this.client.getMailboxLock(INBOX) this.lock = lock const uids = await this.client.search({ all: true }, { uid: true }) const slice = uids.slice(0, maxResults) const nextPageToken = uids.length > maxResults ? String(slice[slice.length - 1]) : null return { messages: slice.map((uid) => ({ id: String(uid) })), nextPageToken, } } finally { if (lock) { lock.release() this.lock = null } } } /** Normalize ImapFlow message to same shape as Gmail/Outlook (id, headers.from, headers.subject, snippet) */ _normalize(msg) { if (!msg || !msg.envelope) return null const from = msg.envelope.from && msg.envelope.from[0] ? (msg.envelope.from[0].address || msg.envelope.from[0].name || '') : '' const subject = msg.envelope.subject || '' return { id: String(msg.uid), headers: { from, subject }, snippet: subject.slice(0, 200), } } /** * Get one message by id (UID string) */ async getEmail(messageId) { let lock = null try { lock = await this.client.getMailboxLock(INBOX) this.lock = lock const list = await this.client.fetchAll(String(messageId), { envelope: true }, { uid: true }) return this._normalize(list && list[0]) } finally { if (lock) { lock.release() this.lock = null } } } /** * Batch get multiple messages by id (UID strings) – single lock, one fetch */ async batchGetEmails(messageIds) { if (!messageIds.length) return [] return withTimeout(this._batchGetEmailsImpl(messageIds), IMAP_OPERATION_TIMEOUT_MS, 'IMAP batchGetEmails') } async _batchGetEmailsImpl(messageIds) { let lock = null try { lock = await this.client.getMailboxLock(INBOX) this.lock = lock const uids = messageIds.map((id) => (typeof id === 'string' ? Number(id) : id)).filter((n) => !Number.isNaN(n)) if (!uids.length) return [] const list = await this.client.fetchAll(uids, { envelope: true }, { uid: true }) return (list || []).map((m) => this._normalize(m)).filter(Boolean) } catch (e) { log.warn('IMAP batchGetEmails failed', { error: e.message }) return [] } finally { if (lock) { lock.release() this.lock = null } } } /** * List all mailbox paths on the server (for sort: map categories to existing folders only). * @returns {Promise} */ async listAllFolders() { await this.ensureConnected() const allFolders = await withOpTimeout(this.client.list(), 'list') return (allFolders || []) .map((f) => f.path || f.name) .filter((p) => typeof p === 'string' && p.length > 0) } /** * Ensure folder exists (create if not). Use subfolder under MailFlow to avoid clutter. * NOT USED IN SORT — automatic sort must not create folders; use existing paths only. */ async ensureFolder(folderName) { const path = `${FOLDER_PREFIX}/${folderName}` try { await withOpTimeout(this.client.mailboxCreate(path), 'mailboxCreate') log.info(`IMAP folder created: ${path}`) } catch (err) { if (err.code !== 'ALREADYEXISTS' && !/already exists/i.test(err.message)) { throw err } } return path } /** * NOT USED IN SORT — sort uses `copyToFolder` (no folder creation; originals stay in INBOX). * Move message (by UID) from INBOX to folder name (under MailFlow/) */ async moveToFolder(messageId, folderName) { await this.ensureConnected() const path = `${FOLDER_PREFIX}/${folderName}` const runMove = async () => { let lock = null try { lock = await withOpTimeout(this.client.getMailboxLock(INBOX), 'getMailboxLock') this.lock = lock await withOpTimeout( this.client.messageMove(String(messageId), path, { uid: true }), 'messageMove' ) } finally { if (lock) { lock.release() this.lock = null } } } try { await this.ensureFolder(folderName) await runMove() } catch (err) { log.warn('IMAP moveToFolder first attempt failed', { error: err.message }) await this.ensureConnected() await this.ensureFolder(folderName) await runMove() } } /** * Add MailFlow category keyword (and optional team tag) on INBOX message — no folder required. * Never throws; returns false if tagging failed (sort can still count the email as categorized). * @param {string | null} [assignedTo] * @returns {Promise} */ async addMailFlowCategoryKeyword(messageId, category, assignedTo = null) { const flags = [getMailFlowKeywordForCategory(category)] if (assignedTo) { const safe = String(assignedTo).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 50) flags.push(`$MailFlow-Team-${safe}`) } try { await this.ensureConnected() let lock = null try { lock = await withOpTimeout(this.client.getMailboxLock(INBOX), 'getMailboxLock') this.lock = lock await withOpTimeout( this.client.messageFlagsAdd(String(messageId), flags, { uid: true }), 'messageFlagsAdd' ) } finally { if (lock) { lock.release() this.lock = null } } return true } catch (err) { const msg = err?.message || String(err) log.warn('IMAP addMailFlowCategoryKeyword failed', { messageId, error: msg }) return false } } /** * Move UID from INBOX to an existing mailbox path only. Does not create folders. * IMAP sort (POST /sort) uses `copyToFolder` instead — originals stay in INBOX. * Returns true on success; false on validation failure or IMAP error (never throws). * @param {string} messageId - UID string * @param {string} destPath - exact path from LIST * @param {Set} existingFolderSet - paths that exist on server */ async moveMessageToExistingPath(messageId, destPath, existingFolderSet) { const uid = typeof messageId === 'string' ? Number(messageId) : Number(messageId) if (messageId == null || Number.isNaN(uid) || uid < 1) { log.warn('IMAP moveMessageToExistingPath: invalid UID', { messageId }) return false } if (!destPath || !existingFolderSet || !(existingFolderSet instanceof Set)) { log.warn('IMAP moveMessageToExistingPath: bad dest or set', { destPath }) return false } if (!existingFolderSet.has(destPath)) { log.warn('IMAP moveMessageToExistingPath: path not in existing set', { destPath }) return false } if (isForbiddenMoveTarget(destPath)) { log.warn('IMAP moveMessageToExistingPath: forbidden destination', { destPath }) return false } try { await this.ensureConnected() let lock = null try { lock = await withOpTimeout(this.client.getMailboxLock(INBOX), 'getMailboxLock') this.lock = lock await withOpTimeout( this.client.messageMove(String(uid), destPath, { uid: true }), 'messageMove' ) const uidRange = `${uid}:${uid}` const stillInInbox = await withOpTimeout( this.client.search({ uid: uidRange }, { uid: true }), 'search' ) if (stillInInbox?.length) { log.warn('IMAP moveMessageToExistingPath: UID still in INBOX after MOVE (path may be wrong)', { messageId, destPath, uid, }) return false } return true } finally { if (lock) { lock.release() this.lock = null } } } catch (err) { log.warn('IMAP moveMessageToExistingPath failed', { messageId, destPath, error: err?.message }) return false } } /** * Copy UID from INBOX to an existing mailbox path (original stays in INBOX). * Returns true on success; false on validation failure or IMAP error (never throws). * @param {string} messageId - UID string * @param {string} destPath - exact path from LIST * @param {Set} existingFolderSet - paths that exist on server */ async copyToFolder(messageId, destPath, existingFolderSet) { const uid = typeof messageId === 'string' ? Number(messageId) : Number(messageId) if (messageId == null || Number.isNaN(uid) || uid < 1) { log.warn('IMAP copyToFolder: invalid UID', { messageId }) return false } if (!destPath || !existingFolderSet || !(existingFolderSet instanceof Set)) { log.warn('IMAP copyToFolder: bad dest or set', { destPath }) return false } if (!existingFolderSet.has(destPath)) { log.warn('IMAP copyToFolder: path not in existing set', { destPath }) return false } if (isForbiddenMoveTarget(destPath)) { log.warn('IMAP copyToFolder: forbidden destination', { destPath }) return false } try { await this.ensureConnected() let lock = null try { lock = await withOpTimeout(this.client.getMailboxLock(INBOX), 'getMailboxLock') this.lock = lock await withOpTimeout( this.client.messageCopy(String(uid), destPath, { uid: true }), 'messageCopy' ) return true } finally { if (lock) { lock.release() this.lock = null } } } catch (err) { log.warn('IMAP copyToFolder failed', { messageId, destPath, error: err?.message }) return false } } /** * Remove $MailFlow-sorted keyword from all messages in INBOX (UID SEARCH + STORE). * @returns {Promise} count of UIDs processed (same as messages touched) */ async removeAllSortedFlags() { await this.connect() let lock = null try { lock = await withOpTimeout(this.client.getMailboxLock(INBOX), 'getMailboxLock') this.lock = lock const allUids = await withOpTimeout( this.client.search({ all: true }, { uid: true }), 'search' ) const list = (allUids || []) .map((u) => (typeof u === 'bigint' ? Number(u) : u)) .filter((n) => n != null && !Number.isNaN(Number(n))) if (list.length > 0) { await withOpTimeout( this.client.messageFlagsRemove(list, ['$MailFlow-sorted'], { uid: true }), 'messageFlagsRemove' ) } log.info(`Removed $MailFlow-sorted from ${list.length} emails`) return list.length } catch (err) { log.warn('IMAP removeAllSortedFlags failed', { error: err?.message }) return 0 } finally { if (lock) { lock.release() this.lock = null } try { await this.close() } catch { /* ignore */ } } } /** * NOT USED IN SORT — sort never moves to Trash/Archive for cleanup. * Move to Trash or Archive (first that works) — standard mailboxes on most hosts. */ async moveToArchiveOrTrash(messageId) { const candidates = ['Trash', 'Archive', 'Deleted', 'INBOX.Trash'] let lastErr = null for (const dest of candidates) { try { await this.ensureConnected() let lock = null try { lock = await withOpTimeout(this.client.getMailboxLock(INBOX), 'getMailboxLock') this.lock = lock await withOpTimeout( this.client.messageMove(String(messageId), dest, { uid: true }), 'messageMove' ) return } finally { if (lock) { lock.release() this.lock = null } } } catch (err) { lastErr = err log.warn(`IMAP moveToArchiveOrTrash: ${dest} failed`, { error: err.message }) } } throw lastErr || new Error('IMAP move to Trash/Archive failed') } /** * Mark message as read (\\Seen) */ async markAsRead(messageId) { await this.ensureConnected() let lock = null try { lock = await withOpTimeout(this.client.getMailboxLock(INBOX), 'getMailboxLock') this.lock = lock await withOpTimeout( this.client.messageFlagsAdd(String(messageId), ['\\Seen'], { uid: true }), 'messageFlagsAdd' ) } finally { if (lock) { lock.release() this.lock = null } } } /** * Remove every $MailFlow-* keyword from all messages in INBOX (incl. $MailFlow-sorted and $MailFlow-Team-*). * Does not close the connection. * @returns {Promise} number of messages that had at least one MailFlow keyword removed */ async stripAllMailFlowKeywordsInInbox() { await this.ensureConnected() let lock = null let touched = 0 try { lock = await withOpTimeout(this.client.getMailboxLock(INBOX), 'getMailboxLock') this.lock = lock const allUids = await withOpTimeout( this.client.search({ all: true }, { uid: true }), 'search' ) const uidList = (allUids || []) .map((u) => (typeof u === 'bigint' ? Number(u) : u)) .filter((n) => n != null && !Number.isNaN(Number(n))) if (!uidList.length) return 0 const messages = await withOpTimeout( this.client.fetchAll(uidList, { flags: true, uid: true }), 'fetchAll' ) for (const msg of messages || []) { const flagSet = msg.flags if (!flagSet || !flagSet.size) continue const toRemove = [...flagSet].filter( (f) => typeof f === 'string' && f.startsWith('$MailFlow') ) if (!toRemove.length) continue await withOpTimeout( this.client.messageFlagsRemove(String(msg.uid), toRemove, { uid: true }), 'messageFlagsRemove' ) touched++ } log.info(`Stripped MailFlow keywords from ${touched} INBOX message(s)`) return touched } catch (err) { log.warn('IMAP stripAllMailFlowKeywordsInInbox failed', { error: err?.message }) return touched } finally { if (lock) { lock.release() this.lock = null } } } /** * Move messages from sort-related folders back to INBOX, then strip $MailFlow-* keywords in INBOX. * Safer than recoverAllToInbox (does not touch Sent/Drafts/Trash/Deleted). * Caller should connect first; does not call logout/close. * @returns {Promise<{ recovered: number, folders: Array<{ folder: string, count: number }>, mailFlowKeywordsStripped: number }>} */ async reSortRecoverAndStripKeywords() { await this.ensureConnected() let recovered = 0 const folders = [] try { const listed = await withOpTimeout(this.client.list(), 'list') for (const folder of listed || []) { const name = folder.path || folder.name if (!name || !isReSortRecoveryFolder(name)) continue if (folder.flags?.has?.('\\Noselect') || folder.flags?.has?.('\\NonExistent')) continue try { const lock = await withOpTimeout(this.client.getMailboxLock(name), 'getMailboxLock') this.lock = lock try { const uids = await withOpTimeout( this.client.search({ all: true }, { uid: true }), 'search' ) if (!uids?.length) continue const uidList = uids .map((u) => (typeof u === 'bigint' ? Number(u) : u)) .filter((n) => n != null && !Number.isNaN(Number(n))) if (!uidList.length) continue folders.push({ folder: name, count: uidList.length }) await withOpTimeout( this.client.messageMove(uidList, INBOX, { uid: true }), 'messageMove' ) recovered += uidList.length log.info(`Re-sort recovery: ${uidList.length} message(s) from "${name}" → INBOX`) } finally { lock.release() this.lock = null } } catch (err) { log.warn(`Re-sort: could not empty folder "${name}"`, { error: err?.message }) } } const mailFlowKeywordsStripped = await this.stripAllMailFlowKeywordsInInbox() return { recovered, folders, mailFlowKeywordsStripped } } catch (err) { log.warn('IMAP reSortRecoverAndStripKeywords failed', { error: err?.message }) return { recovered, folders, mailFlowKeywordsStripped: 0 } } } /** * Move all messages from every mailbox except INBOX into INBOX (nuclear recovery). * WARNING: Also affects Sent, Drafts, Trash, Junk, etc. — only use to recover mail misplaced by buggy moves. */ async recoverAllToInbox() { await this.connect() const checkedFolders = [] let recovered = 0 try { const allFolders = await withOpTimeout(this.client.list(), 'list') for (const folder of allFolders) { const name = folder.path || folder.name if (!name || name.toUpperCase() === 'INBOX') continue try { const lock = await withOpTimeout(this.client.getMailboxLock(name), 'getMailboxLock') this.lock = lock try { const uids = await withOpTimeout( this.client.search({ all: true }, { uid: true }), 'search' ) if (!uids || !uids.length) continue const uidList = uids .map((u) => (typeof u === 'bigint' ? Number(u) : u)) .filter((n) => n != null && !Number.isNaN(Number(n))) if (!uidList.length) continue checkedFolders.push({ folder: name, count: uidList.length }) await withOpTimeout( this.client.messageMove(uidList, INBOX, { uid: true }), 'messageMove' ) recovered += uidList.length log.info(`Recovered ${uidList.length} emails from "${name}" → INBOX`) } finally { lock.release() this.lock = null } } catch (err) { log.warn(`Could not process folder "${name}"`, { error: err.message }) } } } finally { try { await this.close() } catch { /* ignore */ } } return { recovered, folders: checkedFolders } } }