fix(imap): robust error logging + parser-instance.js null guards

- Move client.connect() inside try/catch in withClient
- Add logImapError() writing full stack to /vaults/imap-errors.log for diagnosis
- Extend patch-imapflow.cjs to guard this.remainder.trim() in parser-instance.js
- Root cause of reported crash was undefined args.q (callers passing 'query' not 'q')

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Garfield
2026-05-15 18:13:26 -04:00
parent 0714d2d6d6
commit c7eae2c735
2 changed files with 164 additions and 30 deletions

View File

@@ -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<T>(ctx: EmailCtx, fn: (client: ImapFlow) => Promise<T>): Promise<T> {
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<MessageSummary[]> {
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<string>; 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(