feat(mail): connect-error tracking + IDLE-heartbeat for accurate UI status
Adds 3 fields to mail_connections so UI can distinguish between "connection alive but no new mail" vs "connection dead" vs "auth-failed": - last_connect_error — text of last IMAP error (auth-fail, connect-fail) - last_connect_error_at — timestamp of error - last_idle_heartbeat_at — updated every 2min by NOOP-success in daemon Daemon (backend/imap-idle/index.mjs): - updateConnectionError() / clearConnectionError() / updateIdleHeartbeat() SQL helpers - logError now uses err.responseText (shows "AUTHENTICATIONFAILED" instead of generic "Command failed") - clearError on connect() success - updateError on connect() catch - updateHeartbeat in NOOP-success-path (every 2min) API (status.get.ts): returns the 3 new fields per account. Migration: ALTER TABLE rebreak.mail_connections ADD COLUMN ... (idempotent). UI-side (in flight, separate task): MailAccountCard renders auth-error banner when lastConnectError != null + heartbeat-based "live" indicator. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
01420eaa09
commit
c1a66e3d07
@ -50,6 +50,33 @@ const RECONNECT_LOOP_DELAY_MS = 60 * 1000;
|
||||
|
||||
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
|
||||
|
||||
async function updateConnectionError(connId, errorText) {
|
||||
await pool.query(
|
||||
`UPDATE rebreak.mail_connections
|
||||
SET last_connect_error = $1, last_connect_error_at = NOW()
|
||||
WHERE id = $2`,
|
||||
[errorText, connId],
|
||||
);
|
||||
}
|
||||
|
||||
async function clearConnectionError(connId) {
|
||||
await pool.query(
|
||||
`UPDATE rebreak.mail_connections
|
||||
SET last_connect_error = NULL, last_connect_error_at = NULL
|
||||
WHERE id = $1`,
|
||||
[connId],
|
||||
);
|
||||
}
|
||||
|
||||
async function updateIdleHeartbeat(connId) {
|
||||
await pool.query(
|
||||
`UPDATE rebreak.mail_connections
|
||||
SET last_idle_heartbeat_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[connId],
|
||||
);
|
||||
}
|
||||
|
||||
async function loadActiveConnections() {
|
||||
// DB-table heißt "mail_connections" + snake_case columns (Prisma @map).
|
||||
// Aliase auf camelCase damit der restliche Daemon-Code unverändert bleibt.
|
||||
@ -106,8 +133,9 @@ function log(email, msg) {
|
||||
}
|
||||
|
||||
function logError(email, msg, err) {
|
||||
const errMsg = err?.message ?? String(err);
|
||||
// Credentials tauchen nie in err.message auf (ImapFlow maskiert sie nicht,
|
||||
// responseText enthält z.B. IMAP-Serverantwort bei Auth-Fehlern ("NO [AUTHENTICATIONFAILED]")
|
||||
const errMsg = err?.responseText || err?.message || String(err);
|
||||
// Credentials tauchen nie in err.responseText/message auf (ImapFlow maskiert sie nicht,
|
||||
// aber wir liefern pass nur an ImapFlow — nicht in eigenen log-calls).
|
||||
console.error(`[idle/${email}] ${msg}: ${errMsg}`);
|
||||
}
|
||||
@ -185,6 +213,7 @@ async function runSession(conn) {
|
||||
await imap.connect();
|
||||
log(conn.email, `connected (${conn.imapHost}:${conn.imapPort})`);
|
||||
attempt = 0; // Reset nach erfolgreicher Verbindung
|
||||
await clearConnectionError(conn.id).catch(() => {}); // clear stale auth-error
|
||||
|
||||
await imap.getMailboxLock("INBOX");
|
||||
|
||||
@ -223,8 +252,7 @@ async function runSession(conn) {
|
||||
const noopTimer = setInterval(async () => {
|
||||
try {
|
||||
await imap.noop();
|
||||
// Optional: verbose-log für debugging — aktuell silent
|
||||
// log(conn.email, "noop ok");
|
||||
await updateIdleHeartbeat(conn.id).catch(() => {}); // UI: "connection alive"
|
||||
} catch (err) {
|
||||
logError(conn.email, "noop failed — connection dead, force reconnect", err);
|
||||
imap.close();
|
||||
@ -250,6 +278,8 @@ async function runSession(conn) {
|
||||
|
||||
} catch (err) {
|
||||
logError(conn.email, "connection error", err);
|
||||
const errText = err?.responseText || err?.message || String(err);
|
||||
await updateConnectionError(conn.id, errText).catch(() => {});
|
||||
try { imap.close(); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
-- Migration: add_mail_connection_status_fields
|
||||
-- Adds error-tracking + IDLE heartbeat timestamp to mail_connections.
|
||||
-- Deploy: pnpm prisma migrate deploy (on server)
|
||||
|
||||
ALTER TABLE "rebreak"."mail_connections"
|
||||
ADD COLUMN "last_connect_error" TEXT,
|
||||
ADD COLUMN "last_connect_error_at" TIMESTAMP(3),
|
||||
ADD COLUMN "last_idle_heartbeat_at" TIMESTAMP(3);
|
||||
@ -480,6 +480,9 @@ model MailConnection {
|
||||
nextScanAt DateTime? @map("next_scan_at")
|
||||
emailsBlocked Int @default(0) @map("emails_blocked")
|
||||
emailsScanned Int @default(0) @map("emails_scanned")
|
||||
lastConnectError String? @map("last_connect_error")
|
||||
lastConnectErrorAt DateTime? @map("last_connect_error_at")
|
||||
lastIdleHeartbeatAt DateTime? @map("last_idle_heartbeat_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
blockedMails MailBlocked[]
|
||||
|
||||
@ -23,6 +23,9 @@ export default defineEventHandler(async (event) => {
|
||||
c.emailsScanned > 0
|
||||
? Math.round((c.emailsBlocked / c.emailsScanned) * 100)
|
||||
: 0,
|
||||
lastConnectError: c.lastConnectError ?? null,
|
||||
lastConnectErrorAt: c.lastConnectErrorAt?.toISOString() ?? null,
|
||||
lastIdleHeartbeatAt: c.lastIdleHeartbeatAt?.toISOString() ?? null,
|
||||
}));
|
||||
|
||||
const blocked7d = await getMailBlockedStats(user.id);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user