/** * 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 = 'EmailSorter' /** 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' } /** * 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.client = new ImapFlow({ host: host || 'imap.porkbun.com', port: port || 993, secure: secure !== false, auth: { user, pass: password }, logger: false, }) this.lock = null } async connect() { await this.client.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) { const lock = await this.client.getMailboxLock(INBOX) this.lock = lock try { 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 { 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) { const lock = await this.client.getMailboxLock(INBOX) this.lock = lock try { const list = await this.client.fetchAll(String(messageId), { envelope: true }, { uid: true }) return this._normalize(list && list[0]) } finally { lock.release() this.lock = null } } /** * Batch get multiple messages by id (UID strings) – single lock, one fetch */ async batchGetEmails(messageIds) { if (!messageIds.length) return [] const lock = await this.client.getMailboxLock(INBOX) this.lock = lock try { 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 { lock.release() this.lock = null } } /** * Ensure folder exists (create if not). Use subfolder under EmailSorter to avoid clutter. */ async ensureFolder(folderName) { const path = `${FOLDER_PREFIX}/${folderName}` try { await this.client.mailboxCreate(path) log.info(`IMAP folder created: ${path}`) } catch (err) { if (err.code !== 'ALREADYEXISTS' && !/already exists/i.test(err.message)) { throw err } } return path } /** * Move message (by UID) from INBOX to folder name (under EmailSorter/) */ async moveToFolder(messageId, folderName) { const path = `${FOLDER_PREFIX}/${folderName}` await this.ensureFolder(folderName) const lock = await this.client.getMailboxLock(INBOX) this.lock = lock try { await this.client.messageMove(String(messageId), path, { uid: true }) } finally { lock.release() this.lock = null } } /** * Mark message as read (\\Seen) */ async markAsRead(messageId) { const lock = await this.client.getMailboxLock(INBOX) this.lock = lock try { await this.client.messageFlagsAdd(String(messageId), ['\\Seen'], { uid: true }) } finally { lock.release() this.lock = null } } }