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
This commit is contained in:
181
server/services/imap.mjs
Normal file
181
server/services/imap.mjs
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user