fix(email): log all received mail, not just unseen (90-day lookback + dedup)
Cause of 'not all received emails listed': the poller fetched {seen:false}
only, so any message already read in another client was never pulled or logged.
Now the poller scans a LOOKBACK_DAYS (90) window regardless of \Seen via a
cheap envelope-only pass, dedups by message-id against received_emails, and only
downloads + processes (fetchOne source) messages not yet logged — so the
Received tab is complete while each poll stays light. Marks processed messages
seen; re-checks the parsed message-id before insert.
This commit is contained in:
@@ -24,6 +24,10 @@ let polling = false;
|
|||||||
// SMTP port). Without these, ImapFlow waits indefinitely and the HTTP request
|
// SMTP port). Without these, ImapFlow waits indefinitely and the HTTP request
|
||||||
// dies at the proxy as a 502 with no useful message.
|
// dies at the proxy as a 502 with no useful message.
|
||||||
const IMAP_TIMEOUTS = { connectionTimeout: 10000, greetingTimeout: 10000, socketTimeout: 30000 };
|
const IMAP_TIMEOUTS = { connectionTimeout: 10000, greetingTimeout: 10000, socketTimeout: 30000 };
|
||||||
|
// Look back this far so the Received log captures mail already read in another
|
||||||
|
// client (the unseen-only fetch missed those). Dedup by message-id keeps each
|
||||||
|
// poll cheap — only un-logged messages are downloaded + processed.
|
||||||
|
const LOOKBACK_DAYS = 90;
|
||||||
|
|
||||||
function makeImapClient(cfg) {
|
function makeImapClient(cfg) {
|
||||||
return new ImapFlow({ host: cfg.host, port: cfg.port, secure: cfg.secure, auth: cfg.auth, logger: false, ...IMAP_TIMEOUTS });
|
return new ImapFlow({ host: cfg.host, port: cfg.port, secure: cfg.secure, auth: cfg.auth, logger: false, ...IMAP_TIMEOUTS });
|
||||||
@@ -234,15 +238,45 @@ async function pollOnce() {
|
|||||||
try {
|
try {
|
||||||
await connectWithTimeout(client);
|
await connectWithTimeout(client);
|
||||||
const lock = await client.getMailboxLock(cfg.folder);
|
const lock = await client.getMailboxLock(cfg.folder);
|
||||||
|
/* eslint-disable no-await-in-loop */
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// 1) Candidate UIDs within the lookback window — regardless of \Seen, so
|
||||||
for await (const msg of client.fetch({ seen: false }, { source: true, uid: true })) {
|
// mail already read elsewhere is still logged. Fall back to unseen-only
|
||||||
let messageId = `uid-${cfg.folder}-${msg.uid}`;
|
// if the server rejects a SINCE search.
|
||||||
|
const since = new Date(Date.now() - LOOKBACK_DAYS * 86400000);
|
||||||
|
let uids = [];
|
||||||
|
try { uids = (await client.search({ since }, { uid: true })) || []; } catch (_) { uids = []; }
|
||||||
|
if (!uids.length) { try { uids = (await client.search({ seen: false }, { uid: true })) || []; } catch (_) { uids = []; } }
|
||||||
|
|
||||||
|
// 2) Cheap envelope-only pass → uid + message-id (no source download).
|
||||||
|
const candidates = [];
|
||||||
|
if (uids.length) {
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
for await (const m of client.fetch(uids, { uid: true, envelope: true }, { uid: true })) {
|
||||||
|
candidates.push({ uid: m.uid, messageId: (m.envelope && m.envelope.messageId) || `uid-${cfg.folder}-${m.uid}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Drop ones we've already logged (so each poll only does new work).
|
||||||
|
const logged = new Set();
|
||||||
|
for (let i = 0; i < candidates.length; i += 500) {
|
||||||
|
const chunk = candidates.slice(i, i + 500).map((c) => c.messageId);
|
||||||
|
const rows = await db('received_emails').whereIn('message_id', chunk).select('message_id');
|
||||||
|
rows.forEach((r) => logged.add(r.message_id));
|
||||||
|
}
|
||||||
|
const fresh = candidates.filter((c) => !logged.has(c.messageId));
|
||||||
|
|
||||||
|
// 4) Download + process each fresh message.
|
||||||
|
for (const cand of fresh) {
|
||||||
|
let messageId = cand.messageId;
|
||||||
try {
|
try {
|
||||||
const parsed = await simpleParser(msg.source);
|
const one = await client.fetchOne(String(cand.uid), { source: true }, { uid: true });
|
||||||
messageId = parsed.messageId || messageId;
|
if (!one || !one.source) continue;
|
||||||
const seen = await db('received_emails').where({ message_id: messageId }).first();
|
const parsed = await simpleParser(one.source);
|
||||||
if (seen) { await client.messageFlagsAdd(msg.uid, ['\\Seen'], { uid: true }); continue; }
|
messageId = parsed.messageId || cand.messageId;
|
||||||
|
// Re-check with the parsed id (can differ from the envelope's).
|
||||||
|
const dup = await db('received_emails').where({ message_id: messageId }).first();
|
||||||
|
if (dup) { await client.messageFlagsAdd(cand.uid, ['\\Seen'], { uid: true }); continue; }
|
||||||
|
|
||||||
// Ingest attachments. Isolate each so one bad file can't prevent the
|
// Ingest attachments. Isolate each so one bad file can't prevent the
|
||||||
// audit row (the symptom: doc lands in Incoming invoices but the
|
// audit row (the symptom: doc lands in Incoming invoices but the
|
||||||
@@ -253,9 +287,7 @@ async function pollOnce() {
|
|||||||
const attErrors = [];
|
const attErrors = [];
|
||||||
for (const att of atts) {
|
for (const att of atts) {
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line no-await-in-loop
|
|
||||||
const filePath = await saveAttachment(att);
|
const filePath = await saveAttachment(att);
|
||||||
// eslint-disable-next-line no-await-in-loop
|
|
||||||
const doc = await expenseService.recordInboundDocument({ source: 'email', filePath, originalFilename: att.filename || 'attachment', mimeType: att.contentType }, null);
|
const doc = await expenseService.recordInboundDocument({ source: 'email', filePath, originalFilename: att.filename || 'attachment', mimeType: att.contentType }, null);
|
||||||
inboundId = doc.id; count += 1;
|
inboundId = doc.id; count += 1;
|
||||||
} catch (ae) {
|
} catch (ae) {
|
||||||
@@ -281,14 +313,14 @@ async function pollOnce() {
|
|||||||
error: attErrors.length ? attErrors.join('; ').slice(0, 2000) : null,
|
error: attErrors.length ? attErrors.join('; ').slice(0, 2000) : null,
|
||||||
created_at: new Date(),
|
created_at: new Date(),
|
||||||
});
|
});
|
||||||
await client.messageFlagsAdd(msg.uid, ['\\Seen'], { uid: true });
|
await client.messageFlagsAdd(cand.uid, ['\\Seen'], { uid: true });
|
||||||
processed += 1;
|
processed += 1;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Loud: this is exactly where a silent failure would hide a missing
|
// Loud: this is exactly where a silent failure would hide a missing
|
||||||
// Received row.
|
// Received row.
|
||||||
logger.error?.(`emailIntake: message uid ${msg.uid} (${messageId}) failed: ${e.message}`);
|
logger.error?.(`emailIntake: message uid ${cand.uid} (${messageId}) failed: ${e.message}`);
|
||||||
try {
|
try {
|
||||||
await db('received_emails').insert({ message_id: `err-${msg.uid}-${Date.now()}`, status: 'error', error: e.message, attachment_count: 0, received_at: new Date(), created_at: new Date() });
|
await db('received_emails').insert({ message_id: `err-${cand.uid}-${Date.now()}`, status: 'error', error: e.message, attachment_count: 0, received_at: new Date(), created_at: new Date() });
|
||||||
} catch (ie) {
|
} catch (ie) {
|
||||||
logger.error?.(`emailIntake: could not even write the error row (received_emails insert failing): ${ie.message}`);
|
logger.error?.(`emailIntake: could not even write the error row (received_emails insert failing): ${ie.message}`);
|
||||||
}
|
}
|
||||||
@@ -297,6 +329,7 @@ async function pollOnce() {
|
|||||||
} finally {
|
} finally {
|
||||||
lock.release();
|
lock.release();
|
||||||
}
|
}
|
||||||
|
/* eslint-enable no-await-in-loop */
|
||||||
await client.logout();
|
await client.logout();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error?.(`emailIntake: poll failed: ${e.message}`);
|
logger.error?.(`emailIntake: poll failed: ${e.message}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user