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:
86
scripts/patch-imapflow.cjs
Normal file
86
scripts/patch-imapflow.cjs
Normal file
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
108
src/imap.ts
108
src/imap.ts
@@ -1,12 +1,26 @@
|
|||||||
import { ImapFlow } from 'imapflow';
|
import { ImapFlow } from 'imapflow';
|
||||||
|
import { appendFileSync } from 'fs';
|
||||||
import type { EmailCredentials } from './multitenancy/credential-store.js';
|
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 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;
|
export type EmailCtx = Account | EmailCredentials;
|
||||||
|
|
||||||
const FETCHERPAY_IMAP_HOST = process.env['FETCHERPAY_IMAP_HOST'] ?? 'mail.fetcherpay.com';
|
const FETCHERPAY_IMAP_HOST = process.env['FETCHERPAY_IMAP_HOST'] ?? 'mail.fetcherpay.com';
|
||||||
const FETCHERPAY_IMAP_PORT = parseInt(process.env['FETCHERPAY_IMAP_PORT'] ?? '30993');
|
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) {
|
function fetcherpayImapConfig(user: string, pass: string) {
|
||||||
return {
|
return {
|
||||||
host: FETCHERPAY_IMAP_HOST,
|
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') {
|
function getEnvConfig(account: Account = 'yahoo') {
|
||||||
switch (account) {
|
switch (account) {
|
||||||
case 'fetcherpay':
|
case 'fetcherpay':
|
||||||
@@ -30,19 +54,19 @@ function getEnvConfig(account: Account = 'yahoo') {
|
|||||||
case 'founder':
|
case 'founder':
|
||||||
return fetcherpayImapConfig(process.env['FOUNDER_EMAIL']!, process.env['FOUNDER_PASSWORD']!);
|
return fetcherpayImapConfig(process.env['FOUNDER_EMAIL']!, process.env['FOUNDER_PASSWORD']!);
|
||||||
case 'sqcp_garfield':
|
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':
|
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':
|
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':
|
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':
|
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':
|
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':
|
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':
|
case 'gmail':
|
||||||
return {
|
return {
|
||||||
host: 'imap.gmail.com',
|
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> {
|
async function withClient<T>(ctx: EmailCtx, fn: (client: ImapFlow) => Promise<T>): Promise<T> {
|
||||||
const client = new ImapFlow(resolveImapConfig(ctx));
|
const client = new ImapFlow(resolveImapConfig(ctx));
|
||||||
await client.connect();
|
client.on('error', (err) => logImapError('client-error-event', err));
|
||||||
try {
|
try {
|
||||||
|
await client.connect();
|
||||||
return await fn(client);
|
return await fn(client);
|
||||||
|
} catch (err) {
|
||||||
|
logImapError('withClient', err);
|
||||||
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
await client.logout();
|
try { await client.logout(); } catch { /* ignore logout errors */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,27 +201,13 @@ async function searchInFolder(
|
|||||||
maxResults: number,
|
maxResults: number,
|
||||||
ctx: EmailCtx
|
ctx: EmailCtx
|
||||||
): Promise<MessageSummary[]> {
|
): 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)
|
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 }) => {
|
||||||
? { 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 env = msg.envelope;
|
const env = msg.envelope;
|
||||||
messages.push({
|
return {
|
||||||
uid: msg.uid,
|
uid: msg.uid,
|
||||||
messageId: env?.messageId ?? '',
|
messageId: env?.messageId ?? '',
|
||||||
subject: env?.subject ?? '(no subject)',
|
subject: env?.subject ?? '(no subject)',
|
||||||
@@ -204,9 +218,43 @@ async function searchInFolder(
|
|||||||
seen: msg.flags?.has('\\Seen') ?? false,
|
seen: msg.flags?.has('\\Seen') ?? false,
|
||||||
size: msg.size ?? 0,
|
size: msg.size ?? 0,
|
||||||
folder,
|
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(
|
export async function searchMessages(
|
||||||
|
|||||||
Reference in New Issue
Block a user