Files
Emailsorter/server/services/imap.mjs
ANDJ 61008b63bb Performance fixes
Kiro fixed Performance
2026-02-07 17:23:27 +01:00

197 lines
5.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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'
/** 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) {
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 []
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
}
}
}
/**
* Ensure folder exists (create if not). Use subfolder under MailFlow 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 MailFlow/)
*/
async moveToFolder(messageId, folderName) {
const path = `${FOLDER_PREFIX}/${folderName}`
await this.ensureFolder(folderName)
let lock = null
try {
lock = await this.client.getMailboxLock(INBOX)
this.lock = lock
await this.client.messageMove(String(messageId), path, { uid: true })
} finally {
if (lock) {
lock.release()
this.lock = null
}
}
}
/**
* Mark message as read (\\Seen)
*/
async markAsRead(messageId) {
let lock = null
try {
lock = await this.client.getMailboxLock(INBOX)
this.lock = lock
await this.client.messageFlagsAdd(String(messageId), ['\\Seen'], { uid: true })
} finally {
if (lock) {
lock.release()
this.lock = null
}
}
}
}