Files
Emailsorter/server/services/imap.mjs
ANDJ 89bc86b615 Try
dfssdfsfdsf
2026-04-09 21:00:04 +02:00

882 lines
27 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'
/** 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 }
}
}