diff --git a/docs/11-postfach.md b/docs/11-postfach.md index e309b3f..28da46d 100644 --- a/docs/11-postfach.md +++ b/docs/11-postfach.md @@ -125,6 +125,7 @@ sequenceDiagram G-->>Y: Seite mit Metadaten + nextLink/deltaLink loop je Nachricht Y->>D: bekannt? Text vorhanden? + Note over Y: unbekannt und aelter als die Grenze? dann weiter opt Text fehlt Y->>G: /messages/{id}?$select=body end @@ -156,10 +157,14 @@ Wesentliche Eigenschaften: * **Nur Metadaten in der Delta-Abfrage.** Der Mailtext wird einzeln nachgeholt und nur, wenn er fehlt. Ein reines Metadaten-Update (etwa ein geaendertes Gelesen-Kennzeichen) loescht den gespeicherten Text nicht. -* **Zeitfilter mit Ersatzweg.** Der Erstabgleich versucht - `$filter=receivedDateTime ge ...`. Die Filterunterstuetzung in Delta-Abfragen - ist eng begrenzt; lehnt Graph ab (400), laeuft der Abgleich ohne Filter und die - Zeitgrenze greift beim Speichern. +* **Zeitgrenze doppelt.** Der Erstabgleich versucht + `$filter=receivedDateTime ge ...`; die Filterunterstuetzung in Delta-Abfragen + ist eng begrenzt, bei einem 400 laeuft er ohne Filter weiter. Der Filter ist + deshalb nur eine Ersparnis - geprueft wird die Grenze **immer** beim Speichern, + und zwar nur fuer noch unbekannte Mails. Ein an den ersten Lauf gebundener + Merker waere hier falsch: ein ungefilterter Erstabgleich zieht sich ueber + mehrere Laeufe, und in den folgenden kaeme der Rest des Postfachs trotz Grenze + herein. Im Postfach geloeschte Mails werden mit `is_deleted = 1` gekennzeichnet, nicht entfernt: eine an eine Opportunity gehaengte Mail soll nicht aus dem CRM diff --git a/server/src/mail/store.js b/server/src/mail/store.js index ad5b4e3..33241a8 100644 --- a/server/src/mail/store.js +++ b/server/src/mail/store.js @@ -158,39 +158,43 @@ const CANDIDATE_COLUMNS = [ /** * Vorschlaege zu einer Mail speichern. INSERT IGNORE zusammen mit dem * Unique-Schluessel (message_id, dedupe_hash) macht den Aufruf wiederholbar. - * Zurueck kommen die tatsaechlich neu angelegten Zeilen. + * + * Zurueck kommen die tatsaechlich neu angelegten Zeilen - und zwar in der Form + * der Datenbank (snake_case), damit sie direkt an adoptCandidate() weitergegeben + * werden koennen. Eine Umformung an der Aufrufstelle wuerde bei jedem neuen Feld + * still auseinanderlaufen. */ export async function insertCandidates(messageId, candidates) { const created = []; for (const candidate of candidates) { - const values = [ - messageId, - candidate.dedupeHash, - candidate.firstName, - candidate.lastName, - candidate.salutation, - candidate.email, - candidate.phone, - candidate.mobile, - candidate.company, - candidate.position, - candidate.street, - candidate.zip, - candidate.city, - candidate.website, - candidate.linkedin, - candidate.origin, - candidate.confidence, - ]; + const row = { + message_id: messageId, + dedupe_hash: candidate.dedupeHash, + first_name: candidate.firstName, + last_name: candidate.lastName, + salutation: candidate.salutation, + email: candidate.email, + phone: candidate.phone, + mobile: candidate.mobile, + company: candidate.company, + position: candidate.position, + street: candidate.street, + zip: candidate.zip, + city: candidate.city, + website: candidate.website, + linkedin: candidate.linkedin, + origin: candidate.origin, + confidence: candidate.confidence, + }; const [result] = await pool.query( `INSERT IGNORE INTO crm_mail_contact_candidates (${CANDIDATE_COLUMNS.join(', ')}) VALUES (${CANDIDATE_COLUMNS.map(() => '?').join(', ')})`, - values + CANDIDATE_COLUMNS.map((column) => row[column]) ); - if (result.affectedRows > 0) created.push({ id: result.insertId, ...candidate }); + if (result.affectedRows > 0) created.push({ id: result.insertId, ...row }); } return created; diff --git a/server/src/mail/sync.js b/server/src/mail/sync.js index 93cf8ea..aef54e8 100644 --- a/server/src/mail/sync.js +++ b/server/src/mail/sync.js @@ -136,16 +136,16 @@ function initialDeltaUrl(since) { /** * Delta-Seite holen. Der erste Aufruf versucht es mit Zeitfilter; lehnt Graph den * ab (die Filterunterstuetzung in Delta-Abfragen ist eng begrenzt), wird ohne - * Filter wiederholt und der Zeitraum stattdessen beim Speichern beachtet. + * Filter wiederholt. Der Filter ist damit reine Ersparnis - die Zeitgrenze wird + * beim Speichern in jedem Fall geprueft. */ -async function fetchDeltaPage(url, { allowFilterFallback = false, since = null } = {}) { +async function fetchDeltaPage(url, { allowFilterFallback = false } = {}) { try { - return { data: await graphRequest(url), url }; + return await graphRequest(url); } catch (err) { if (allowFilterFallback && err instanceof GraphError && err.status === 400) { console.warn('[Mail] Delta mit Zeitfilter abgelehnt - wiederhole ohne Filter.'); - const fallback = initialDeltaUrl(null); - return { data: await graphRequest(fallback), url: fallback, filtered: false, since }; + return graphRequest(initialDeltaUrl(null)); } throw err; } @@ -182,21 +182,12 @@ export async function runMailSync({ trigger = 'manuell' } = {}) { const cutoffSql = toSqlUtc(cutoff.toISOString()); let url = state.delta_link || initialDeltaUrl(cutoff.toISOString().replace(/\.\d{3}Z$/, 'Z')); - // Wird nur gesetzt, wenn Graph den Zeitfilter ablehnt und wir ohne Filter - // durchlaufen - dann muss die Zeitgrenze hier greifen. - let applyCutoffLocally = false; let deltaLink = null; let hasMore = false; while (url && pages < MAX_PAGES_PER_RUN) { - const page = await fetchDeltaPage(url, { - allowFilterFallback: isFirstRun && pages === 0, - since: cutoff.toISOString(), - }); - if (page.filtered === false) applyCutoffLocally = true; - + const data = (await fetchDeltaPage(url, { allowFilterFallback: isFirstRun && pages === 0 })) ?? {}; pages += 1; - const data = page.data ?? {}; for (const raw of data.value || []) { if (!raw?.id) continue; @@ -208,11 +199,20 @@ export async function runMailSync({ trigger = 'manuell' } = {}) { } const message = normalizeMessage(raw); - - // Beim Ersatzweg ohne Zeitfilter greift die Grenze hier. - if (applyCutoffLocally && cutoffSql && message.receivedAt < cutoffSql) continue; - const existing = await getMessageStateByHash(message.graphIdHash); + + /* + * Zeitgrenze - immer geprueft, nicht nur wenn der Serverfilter versagt + * hat. Ein Erstabgleich ohne Filter zieht sich ueber mehrere Laeufe; in + * den folgenden waere ein an den ersten Lauf gebundenes Merker-Flag + * verloren, und der Rest des Postfachs kaeme trotz Grenze herein. + * + * Nur fuer unbekannte Mails: eine bereits gespiegelte Mail soll auch dann + * aktualisiert werden, wenn sie inzwischen aus dem Zeitfenster gelaufen + * ist. + */ + if (!existing && cutoffSql && message.receivedAt < cutoffSql) continue; + if (!existing || !existing.hasBody) { try { Object.assign(message, await loadBody(message.graphId)); @@ -236,31 +236,17 @@ export async function runMailSync({ trigger = 'manuell' } = {}) { const created = await insertCandidates(stored.id, candidates); newCandidates += created.length; + /* + * Automatisch uebernommen wird nur, wer eine echte Adresse hat und aus + * Absender oder Empfaengerliste stammt. Signatur- und Textfunde liegen + * unter der Schwelle und warten auf eine Entscheidung. + */ if (settings.autoContacts) { for (const candidate of created) { if (!candidate.email) continue; if (candidate.confidence < AUTO_ADOPT_MIN_CONFIDENCE) continue; try { - const result = await adoptCandidate( - { - id: candidate.id, - message_id: stored.id, - first_name: candidate.firstName, - last_name: candidate.lastName, - salutation: candidate.salutation, - email: candidate.email, - phone: candidate.phone, - mobile: candidate.mobile, - company: candidate.company, - position: candidate.position, - street: candidate.street, - zip: candidate.zip, - city: candidate.city, - website: candidate.website, - linkedin: candidate.linkedin, - }, - { mode: 'automatisch' } - ); + const result = await adoptCandidate(candidate, { mode: 'automatisch' }); if (result.status === 'uebernommen') adoptedContacts += 1; } catch (err) { console.warn(`[Mail] Kontakt-Vorschlag konnte nicht uebernommen werden: ${err.message}`);