Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
41f2299fe2
commit
c8f250715f
@@ -0,0 +1,157 @@
|
||||
// Server route that connects to the configured IMAP mailbox, fetches new
|
||||
// messages since the last polled UID, and stores them in incoming_emails.
|
||||
// Triggered by pg_cron and by the "Poll now" button in settings.
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { supabaseAdmin } from "@/integrations/supabase/client.server";
|
||||
import { ImapFlow } from "imapflow";
|
||||
import { simpleParser } from "mailparser";
|
||||
|
||||
const MAX_MESSAGES_PER_RUN = 50;
|
||||
|
||||
export const Route = createFileRoute("/hooks/poll-imap")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async () => {
|
||||
try {
|
||||
const { data: settings, error: setErr } = await supabaseAdmin
|
||||
.from("imap_settings")
|
||||
.select("*")
|
||||
.eq("enabled", true)
|
||||
.order("updated_at", { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
if (setErr) return json({ error: setErr.message }, 500);
|
||||
if (!settings) return json({ error: "No IMAP settings configured" }, 400);
|
||||
|
||||
const password = process.env.IMAP_PASSWORD;
|
||||
if (!password) {
|
||||
return json({ error: "IMAP_PASSWORD secret is not set" }, 400);
|
||||
}
|
||||
|
||||
const client = new ImapFlow({
|
||||
host: settings.host,
|
||||
port: settings.port,
|
||||
secure: !!settings.secure,
|
||||
auth: { user: settings.username, pass: password },
|
||||
logger: false,
|
||||
});
|
||||
|
||||
let imported = 0;
|
||||
let highestUid = settings.last_uid ?? 0;
|
||||
let errorMessage: string | null = null;
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
const lock = await client.getMailboxLock(settings.folder ?? "INBOX");
|
||||
|
||||
try {
|
||||
const sinceUid = (settings.last_uid ?? 0) + 1;
|
||||
const range = `${sinceUid}:*`;
|
||||
|
||||
for await (const msg of client.fetch(
|
||||
range,
|
||||
{ uid: true, source: true, envelope: true, size: true },
|
||||
{ uid: true },
|
||||
)) {
|
||||
if (imported >= MAX_MESSAGES_PER_RUN) break;
|
||||
if (msg.uid <= (settings.last_uid ?? 0)) continue;
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = await simpleParser(msg.source as Buffer);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse message uid", msg.uid, e);
|
||||
continue;
|
||||
}
|
||||
|
||||
const fromAddr = parsed.from?.value?.[0]?.address ?? null;
|
||||
const fromName = parsed.from?.value?.[0]?.name ?? null;
|
||||
const toAddrs =
|
||||
Array.isArray(parsed.to)
|
||||
? parsed.to.flatMap((a: any) => a.value.map((v: any) => v.address).filter(Boolean))
|
||||
: (parsed.to?.value?.map((v: any) => v.address).filter(Boolean) ?? []);
|
||||
const ccAddrs =
|
||||
Array.isArray(parsed.cc)
|
||||
? parsed.cc.flatMap((a: any) => a.value.map((v: any) => v.address).filter(Boolean))
|
||||
: (parsed.cc?.value?.map((v: any) => v.address).filter(Boolean) ?? []);
|
||||
|
||||
const text = parsed.text ?? null;
|
||||
const snippet = text
|
||||
? text.replace(/\s+/g, " ").trim().slice(0, 280)
|
||||
: null;
|
||||
const attachments = parsed.attachments ?? [];
|
||||
|
||||
const insertRow = {
|
||||
message_id: parsed.messageId ?? null,
|
||||
imap_uid: msg.uid,
|
||||
received_at: (parsed.date ?? new Date()).toISOString(),
|
||||
from_address: fromAddr,
|
||||
from_name: fromName,
|
||||
to_addresses: toAddrs,
|
||||
cc_addresses: ccAddrs,
|
||||
subject: parsed.subject ?? null,
|
||||
body_text: text,
|
||||
body_html: parsed.html || null,
|
||||
snippet,
|
||||
has_attachments: attachments.length > 0,
|
||||
attachment_count: attachments.length,
|
||||
raw_size_bytes: msg.size ?? null,
|
||||
};
|
||||
|
||||
// Upsert by message_id to avoid duplicates if mailbox is re-polled
|
||||
const { error: insErr } = await supabaseAdmin
|
||||
.from("incoming_emails")
|
||||
.upsert(insertRow, { onConflict: "message_id", ignoreDuplicates: true });
|
||||
|
||||
if (insErr && !insErr.message.includes("duplicate")) {
|
||||
console.error("Insert failed for uid", msg.uid, insErr.message);
|
||||
} else {
|
||||
imported++;
|
||||
}
|
||||
|
||||
if (msg.uid > highestUid) highestUid = msg.uid;
|
||||
}
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
await client.logout();
|
||||
} catch (e) {
|
||||
errorMessage = e instanceof Error ? e.message : String(e);
|
||||
console.error("IMAP poll error:", errorMessage);
|
||||
try {
|
||||
await client.close();
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
await supabaseAdmin
|
||||
.from("imap_settings")
|
||||
.update({
|
||||
last_uid: highestUid,
|
||||
last_polled_at: new Date().toISOString(),
|
||||
last_error: errorMessage,
|
||||
})
|
||||
.eq("id", settings.id);
|
||||
|
||||
if (errorMessage) {
|
||||
return json({ ok: false, imported, error: errorMessage }, 500);
|
||||
}
|
||||
return json({ ok: true, imported, last_uid: highestUid });
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
console.error("poll-imap fatal", message);
|
||||
return json({ error: message }, 500);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function json(data: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user