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:
chahinebrini 2026-05-09 23:48:11 +02:00
parent 01420eaa09
commit c1a66e3d07
4 changed files with 48 additions and 4 deletions

View File

@ -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 */ }
}

View File

@ -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);

View File

@ -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[]

View File

@ -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);