Files
Emailsorter/server/services/imap.mjs
ANDJ cbb225c001 feat: Gitea Webhook, IMAP, Settings & Deployment docs
- Webhook route and Gitea integration
- IMAP service and Nextcloud/Porkbun setup docs
- Settings UI improvements and API updates
- SSH/Webhook fix prompt for emailsorter.webklar.com
- Bootstrap, config and AI sorter updates
2026-01-31 15:00:00 +01:00

182 lines
5.1 KiB
JavaScript
Raw Permalink 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 = '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
}
}
}