fix(email): always log incoming mail to received_emails (was lost on insert error)
Symptom: an emailed attachment landed in Incoming invoices but the message never appeared under Received emails. The attachment is saved BEFORE the received_emails insert, so any throw there left the audit row unwritten and silently swallowed. - coerce a malformed Date: header (Invalid Date) to now — it would otherwise throw on the Postgres timestamp insert (most likely root cause) - isolate each attachment in its own try so one bad file can't skip the audit - truncate from_address to the column width; persist attachment errors + an 'error' status so partial failures are visible - log loudly when the received_emails insert itself fails (no more silent loss) Self-healing: the stuck message was never marked \Seen, so the next poll re-processes it and writes the row.
This commit is contained in:
@@ -237,39 +237,61 @@ async function pollOnce() {
|
|||||||
try {
|
try {
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
for await (const msg of client.fetch({ seen: false }, { source: true, uid: true })) {
|
for await (const msg of client.fetch({ seen: false }, { source: true, uid: true })) {
|
||||||
|
let messageId = `uid-${cfg.folder}-${msg.uid}`;
|
||||||
try {
|
try {
|
||||||
const parsed = await simpleParser(msg.source);
|
const parsed = await simpleParser(msg.source);
|
||||||
const messageId = parsed.messageId || `uid-${cfg.folder}-${msg.uid}`;
|
messageId = parsed.messageId || messageId;
|
||||||
const seen = await db('received_emails').where({ message_id: messageId }).first();
|
const seen = await db('received_emails').where({ message_id: messageId }).first();
|
||||||
if (seen) { await client.messageFlagsAdd(msg.uid, ['\\Seen'], { uid: true }); continue; }
|
if (seen) { await client.messageFlagsAdd(msg.uid, ['\\Seen'], { uid: true }); continue; }
|
||||||
|
|
||||||
|
// Ingest attachments. Isolate each so one bad file can't prevent the
|
||||||
|
// audit row (the symptom: doc lands in Incoming invoices but the
|
||||||
|
// email never shows under Received).
|
||||||
const atts = (parsed.attachments || []).filter((a) => ALLOWED_MIME.includes(a.contentType));
|
const atts = (parsed.attachments || []).filter((a) => ALLOWED_MIME.includes(a.contentType));
|
||||||
let inboundId = null;
|
let inboundId = null;
|
||||||
let count = 0;
|
let count = 0;
|
||||||
|
const attErrors = [];
|
||||||
for (const att of atts) {
|
for (const att of atts) {
|
||||||
|
try {
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// 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
|
// 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) {
|
||||||
|
attErrors.push(ae.message);
|
||||||
|
logger.error?.(`emailIntake: attachment "${att.filename}" failed: ${ae.message}`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A malformed Date: header yields an Invalid Date, which throws on a
|
||||||
|
// Postgres timestamp insert — coerce to now.
|
||||||
|
const receivedAt = (parsed.date instanceof Date && !Number.isNaN(parsed.date.getTime())) ? parsed.date : new Date();
|
||||||
|
const status = count > 0 ? 'ingested' : (attErrors.length ? 'error' : 'no_attachment');
|
||||||
|
// Audit EVERY processed message, even attachment-less ones, so the
|
||||||
|
// Received tab is a complete log.
|
||||||
await db('received_emails').insert({
|
await db('received_emails').insert({
|
||||||
message_id: messageId,
|
message_id: messageId,
|
||||||
from_address: (parsed.from && parsed.from.text) || null,
|
from_address: ((parsed.from && parsed.from.text) || '').slice(0, 512) || null,
|
||||||
subject: parsed.subject || null,
|
subject: parsed.subject || null,
|
||||||
received_at: parsed.date || new Date(),
|
received_at: receivedAt,
|
||||||
attachment_count: count,
|
attachment_count: count,
|
||||||
status: count > 0 ? 'ingested' : 'no_attachment',
|
status,
|
||||||
inbound_document_id: inboundId,
|
inbound_document_id: inboundId,
|
||||||
|
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(msg.uid, ['\\Seen'], { uid: true });
|
||||||
processed += 1;
|
processed += 1;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error?.(`emailIntake: message uid ${msg.uid} failed: ${e.message}`);
|
// Loud: this is exactly where a silent failure would hide a missing
|
||||||
|
// Received row.
|
||||||
|
logger.error?.(`emailIntake: message uid ${msg.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-${msg.uid}-${Date.now()}`, status: 'error', error: e.message, attachment_count: 0, received_at: new Date(), created_at: new Date() });
|
||||||
} catch (_e) { /* ignore */ }
|
} catch (ie) {
|
||||||
|
logger.error?.(`emailIntake: could not even write the error row (received_emails insert failing): ${ie.message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user