882 lines
27 KiB
JavaScript
882 lines
27 KiB
JavaScript
/**
|
||
* 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<string[]>}
|
||
*/
|
||
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<boolean>}
|
||
*/
|
||
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<string>} 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<string>} 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<number>} 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>} 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 }
|
||
}
|
||
}
|