diff --git a/scripts/patch-imapflow.cjs b/scripts/patch-imapflow.cjs new file mode 100644 index 0000000..689c59c --- /dev/null +++ b/scripts/patch-imapflow.cjs @@ -0,0 +1,86 @@ +#!/usr/bin/env node +// Patches imapflow to handle Poste.io servers returning IMAP NO/BAD/BYE +// responses where some TEXT attributes have undefined .value, causing +// `val.value.trim()` to throw "Cannot read properties of undefined". +const fs = require('fs'); +const path = require('path'); + +const file = path.join(__dirname, '../node_modules/imapflow/lib/imap-flow.js'); +let src = fs.readFileSync(file, 'utf8'); + +const patches = [ + { + bad: `.map(val => val.value.trim())`, + fixed: `.map(val => (val.value != null ? val.value.trim() : ''))`, + }, + { + bad: `section[0].value.toUpperCase().trim()`, + fixed: `(section[0].value || '').toUpperCase().trim()`, + }, + { + bad: `attr.value.toUpperCase().trim()`, + fixed: `(attr.value || '').toUpperCase().trim()`, + }, + { + bad: `val.value.toUpperCase().trim()`, + fixed: `(val.value || '').toUpperCase().trim()`, + }, + { + bad: `contentType.value.toLowerCase().trim()`, + fixed: `(contentType.value || '').toLowerCase().trim()`, + }, + { + bad: `disposition.value.toLowerCase().trim()`, + fixed: `(disposition.value || '').toLowerCase().trim()`, + }, +]; + +let total = 0; +for (const p of patches) { + const re = new RegExp(p.bad.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'); + const count = (src.match(re) || []).length; + if (count > 0) { + src = src.replaceAll(p.bad, p.fixed); + total += count; + console.log(`[patch-imapflow] patched ${count} occurrence(s) of: ${p.bad}`); + } +} + +if (total > 0) { + fs.writeFileSync(file, src, 'utf8'); + console.log(`[patch-imapflow] total patched ${total} occurrence(s) in imap-flow.js`); +} else { + console.log('[patch-imapflow] already patched or patterns not found — no changes made'); +} + +// Also patch parser-instance.js (this.remainder can be undefined on malformed Poste.io responses) +const parserFile = path.join(__dirname, '../node_modules/imapflow/lib/handler/parser-instance.js'); +if (fs.existsSync(parserFile)) { + let parserSrc = fs.readFileSync(parserFile, 'utf8'); + let parserPatched = 0; + const parserPatches = [ + { bad: `this.humanReadable = this.remainder.trim();`, fixed: `this.humanReadable = (this.remainder || '').trim();` }, + { bad: `this.humanReadable = this.remainder.substring(i + 1).trim();`, fixed: `this.humanReadable = (this.remainder || '').substring(i + 1).trim();` }, + ]; + for (const p of parserPatches) { + if (parserSrc.includes(p.bad)) { + parserSrc = parserSrc.replaceAll(p.bad, p.fixed); + parserPatched++; + console.log(`[patch-imapflow] patched parser-instance.js: ${p.bad}`); + } + } + if (parserPatched > 0) fs.writeFileSync(parserFile, parserSrc, 'utf8'); +} + +// Also patch nodemailer inside imapflow (malformed Content-Type headers) +const nodemailerFile = path.join(__dirname, '../node_modules/imapflow/node_modules/nodemailer/lib/mime-node/index.js'); +if (fs.existsSync(nodemailerFile)) { + let nodemailerSrc = fs.readFileSync(nodemailerFile, 'utf8'); + const nodemailerBad = `this.contentType = structured.value.trim().toLowerCase();`; + const nodemailerFixed = `this.contentType = ((structured && structured.value) || '').trim().toLowerCase();`; + if (nodemailerSrc.includes(nodemailerBad)) { + nodemailerSrc = nodemailerSrc.replaceAll(nodemailerBad, nodemailerFixed); + fs.writeFileSync(nodemailerFile, nodemailerSrc, 'utf8'); + console.log('[patch-imapflow] patched nodemailer mime-node/index.js'); + } +} diff --git a/src/imap.ts b/src/imap.ts index 7e3d0ee..146c74a 100644 --- a/src/imap.ts +++ b/src/imap.ts @@ -1,12 +1,26 @@ import { ImapFlow } from 'imapflow'; +import { appendFileSync } from 'fs'; import type { EmailCredentials } from './multitenancy/credential-store.js'; +function logImapError(label: string, err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? (err.stack ?? '') : ''; + const line = `[${new Date().toISOString()}] [imap-error] ${label}: ${msg}\n${stack}\n---\n`; + console.error(line); + try { appendFileSync('/vaults/imap-errors.log', line); } catch { /* ignore */ } +} + export type Account = 'yahoo' | 'fetcherpay' | 'garfield' | 'sales' | 'leads' | 'founder' | 'gmail' | 'sqcp_garfield' | 'sqcp_info' | 'sqcp_sales' | 'sqcp_support' | 'sqcp_founder' | 'sqcp_contact' | 'sqcp_admin'; export type EmailCtx = Account | EmailCredentials; const FETCHERPAY_IMAP_HOST = process.env['FETCHERPAY_IMAP_HOST'] ?? 'mail.fetcherpay.com'; const FETCHERPAY_IMAP_PORT = parseInt(process.env['FETCHERPAY_IMAP_PORT'] ?? '30993'); +// squaremcp.com mail is on a separate server (mail.squaremcp.com / 104.190.60.129) +// same custom port as fetcherpay but different host +const SQCP_IMAP_HOST = process.env['SQCP_IMAP_HOST'] ?? 'mail.squaremcp.com'; +const SQCP_IMAP_PORT = parseInt(process.env['SQCP_IMAP_PORT'] ?? '30993'); + function fetcherpayImapConfig(user: string, pass: string) { return { host: FETCHERPAY_IMAP_HOST, @@ -17,6 +31,16 @@ function fetcherpayImapConfig(user: string, pass: string) { }; } +function sqcpImapConfig(user: string, pass: string) { + return { + host: SQCP_IMAP_HOST, + port: SQCP_IMAP_PORT, + secure: true, + auth: { user, pass }, + tls: { rejectUnauthorized: false }, + }; +} + function getEnvConfig(account: Account = 'yahoo') { switch (account) { case 'fetcherpay': @@ -30,19 +54,19 @@ function getEnvConfig(account: Account = 'yahoo') { case 'founder': return fetcherpayImapConfig(process.env['FOUNDER_EMAIL']!, process.env['FOUNDER_PASSWORD']!); case 'sqcp_garfield': - return fetcherpayImapConfig(process.env['SQCP_GARFIELD_EMAIL']!, process.env['SQCP_GARFIELD_PASSWORD']!); + return sqcpImapConfig(process.env['SQCP_GARFIELD_EMAIL']!, process.env['SQCP_GARFIELD_PASSWORD']!); case 'sqcp_info': - return fetcherpayImapConfig(process.env['SQCP_INFO_EMAIL']!, process.env['SQCP_INFO_PASSWORD']!); + return sqcpImapConfig(process.env['SQCP_INFO_EMAIL']!, process.env['SQCP_INFO_PASSWORD']!); case 'sqcp_sales': - return fetcherpayImapConfig(process.env['SQCP_SALES_EMAIL']!, process.env['SQCP_SALES_PASSWORD']!); + return sqcpImapConfig(process.env['SQCP_SALES_EMAIL']!, process.env['SQCP_SALES_PASSWORD']!); case 'sqcp_support': - return fetcherpayImapConfig(process.env['SQCP_SUPPORT_EMAIL']!, process.env['SQCP_SUPPORT_PASSWORD']!); + return sqcpImapConfig(process.env['SQCP_SUPPORT_EMAIL']!, process.env['SQCP_SUPPORT_PASSWORD']!); case 'sqcp_founder': - return fetcherpayImapConfig(process.env['SQCP_FOUNDER_EMAIL']!, process.env['SQCP_FOUNDER_PASSWORD']!); + return sqcpImapConfig(process.env['SQCP_FOUNDER_EMAIL']!, process.env['SQCP_FOUNDER_PASSWORD']!); case 'sqcp_contact': - return fetcherpayImapConfig(process.env['SQCP_CONTACT_EMAIL']!, process.env['SQCP_CONTACT_PASSWORD']!); + return sqcpImapConfig(process.env['SQCP_CONTACT_EMAIL']!, process.env['SQCP_CONTACT_PASSWORD']!); case 'sqcp_admin': - return fetcherpayImapConfig(process.env['SQCP_ADMIN_EMAIL']!, process.env['SQCP_ADMIN_PASSWORD']!); + return sqcpImapConfig(process.env['SQCP_ADMIN_EMAIL']!, process.env['SQCP_ADMIN_PASSWORD']!); case 'gmail': return { host: 'imap.gmail.com', @@ -86,11 +110,15 @@ function isGmail(ctx: EmailCtx): boolean { async function withClient(ctx: EmailCtx, fn: (client: ImapFlow) => Promise): Promise { const client = new ImapFlow(resolveImapConfig(ctx)); - await client.connect(); + client.on('error', (err) => logImapError('client-error-event', err)); try { + await client.connect(); return await fn(client); + } catch (err) { + logImapError('withClient', err); + throw err; } finally { - await client.logout(); + try { await client.logout(); } catch { /* ignore logout errors */ } } } @@ -173,27 +201,13 @@ async function searchInFolder( maxResults: number, ctx: EmailCtx ): Promise { - await client.mailboxOpen(folder); + const mailbox = await client.mailboxOpen(folder); + const total = mailbox.exists ?? 0; + if (total === 0) return []; - const criteria = isGmail(ctx) - ? { gmailraw: query } - : parseSearchCriteria(query); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const uids = await client.search(criteria as any, { uid: true }); - const uidList: number[] = Array.isArray(uids) ? uids : []; - const recentUids = uidList.slice(-maxResults).reverse(); - - if (recentUids.length === 0) return []; - - const messages: MessageSummary[] = []; - for await (const msg of client.fetch(recentUids, { - envelope: true, - flags: true, - size: true, - }, { uid: true })) { + const buildSummary = (msg: { uid: number; envelope?: { messageId?: string; subject?: string; from?: Array<{ name?: string; address?: string }>; to?: Array<{ address?: string }>; date?: Date }; flags?: Set; size?: number }) => { const env = msg.envelope; - messages.push({ + return { uid: msg.uid, messageId: env?.messageId ?? '', subject: env?.subject ?? '(no subject)', @@ -204,9 +218,43 @@ async function searchInFolder( seen: msg.flags?.has('\\Seen') ?? false, size: msg.size ?? 0, folder, - }); + }; + }; + + const criteria = isGmail(ctx) + ? { gmailraw: query } + : parseSearchCriteria(query); + + // Try SEARCH first; some Poste.io builds return malformed error responses that + // crash ImapFlow's parser. Fall back to fetching the N most recent by sequence. + let uidList: number[] = []; + let searchWorked = false; + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const uids = await client.search(criteria as any, { uid: true }); + uidList = Array.isArray(uids) ? uids : []; + searchWorked = true; + } catch (err) { + console.warn(`[imap] SEARCH failed on ${folder}, using sequence fallback:`, (err as Error).message); } - return messages; + + if (searchWorked) { + const recentUids = uidList.slice(-maxResults).reverse(); + if (recentUids.length === 0) return []; + const messages: MessageSummary[] = []; + for await (const msg of client.fetch(recentUids, { envelope: true, flags: true, size: true }, { uid: true })) { + messages.push(buildSummary(msg)); + } + return messages; + } + + // Sequence-range fallback: fetch last N messages without SEARCH + const start = Math.max(1, total - maxResults + 1); + const messages: MessageSummary[] = []; + for await (const msg of client.fetch(`${start}:${total}`, { envelope: true, flags: true, size: true })) { + messages.push(buildSummary(msg)); + } + return messages.reverse(); } export async function searchMessages(