Files
perinco-CRM/server/src/mail/store.js
T
Sascha WolfandClaude Opus 5 8090d0aae1 fix(postfach): Zeitgrenze gilt auch in Folgelaeufen des Erstabgleichs
Lehnt Graph den Serverfilter ab, laeuft der Erstabgleich ohne Filter - und zieht
sich dann ueber mehrere Laeufe. Der Merker dafuer galt nur im ersten Lauf, in
allen folgenden waere der Rest des Postfachs trotz eingestellter Grenze
hereingekommen. Die Grenze wird jetzt immer geprueft, und zwar nur fuer noch
unbekannte Mails: eine bereits gespiegelte Mail soll auch dann aktualisiert
werden, wenn sie aus dem Zeitfenster gelaufen ist.

insertCandidates() gibt die Zeilen jetzt in der Form der Datenbank zurueck. Die
Umformung an der Aufrufstelle waere bei jedem neuen Feld still auseinandergelaufen
und haette es bei der automatischen Uebernahme verloren.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 00:08:33 +02:00

222 lines
7.1 KiB
JavaScript

import { pool } from '../db.js';
/*
* Datenbankzugriffe des Mailabgleichs. Lesende Abfragen der Oberflaeche stehen
* wie im uebrigen Backend direkt in routes/mail.js - hier liegt nur, was sowohl
* der Hintergrundlauf als auch die Routen brauchen.
*/
/** Zustandszeile des Abgleichs (immer id = 1). */
export async function getSyncState() {
const [rows] = await pool.query('SELECT * FROM crm_mail_sync_state WHERE id = 1 LIMIT 1');
if (rows[0]) return rows[0];
// Sollte die Startzeile fehlen (fremder Dump), hier nachziehen.
await pool.query('INSERT IGNORE INTO crm_mail_sync_state (id) VALUES (1)');
const [again] = await pool.query('SELECT * FROM crm_mail_sync_state WHERE id = 1 LIMIT 1');
return again[0];
}
const SYNC_STATE_FIELDS = new Set([
'mailbox',
'delta_link',
'has_more',
'last_run_at',
'last_success_at',
'last_error',
'last_new_messages',
'last_candidates',
]);
/** Teilweises Aktualisieren der Zustandszeile. */
export async function saveSyncState(patch) {
const entries = Object.entries(patch).filter(([key]) => SYNC_STATE_FIELDS.has(key));
if (entries.length === 0) return;
await pool.query(
`UPDATE crm_mail_sync_state SET ${entries.map(([key]) => `${key} = ?`).join(', ')} WHERE id = 1`,
entries.map(([, value]) => value)
);
}
/** Vollstaendigen Neuaufbau anstossen: Delta-Verweis verwerfen. */
export async function resetDelta() {
await saveSyncState({ delta_link: null, has_more: 0 });
}
/**
* Ist die Mail schon bekannt, und liegt ihr Text vor? Der Delta-Abgleich holt
* absichtlich nur Metadaten; der Mailtext wird einzeln nachgeladen und nur dann,
* wenn er wirklich fehlt.
*/
export async function getMessageStateByHash(graphIdHash) {
const [rows] = await pool.query(
`SELECT id, (body_text IS NOT NULL AND body_text <> '') AS has_body
FROM crm_mail_messages WHERE graph_id_hash = ? LIMIT 1`,
[graphIdHash]
);
if (!rows[0]) return null;
return { id: rows[0].id, hasBody: Boolean(Number(rows[0].has_body)) };
}
/**
* Nachricht anlegen oder aktualisieren. Bewusst mit vorherigem SELECT statt
* ON DUPLICATE KEY UPDATE: so ist eindeutig, ob es eine neue Mail war - nur dann
* werden Kontakt-Vorschlaege erzeugt.
*
* Fehlen die Textfelder im uebergebenen Objekt (undefined), bleiben die
* gespeicherten Werte unangetastet - ein reines Metadaten-Update darf den
* bereits geholten Mailtext nicht loeschen.
*/
export async function upsertMessage(message) {
const [existing] = await pool.query(
'SELECT id FROM crm_mail_messages WHERE graph_id_hash = ? LIMIT 1',
[message.graphIdHash]
);
const columns = {
graph_id: message.graphId,
graph_id_hash: message.graphIdHash,
internet_message_id: message.internetMessageId,
conversation_id: message.conversationId,
subject: message.subject,
from_name: message.fromName,
from_email: message.fromEmail,
to_json: JSON.stringify(message.to ?? []),
cc_json: JSON.stringify(message.cc ?? []),
bcc_json: JSON.stringify(message.bcc ?? []),
received_at: message.receivedAt,
sent_at: message.sentAt,
body_preview: message.bodyPreview,
body_text: message.bodyText,
body_html: message.bodyHtml,
has_attachments: message.hasAttachments ? 1 : 0,
importance: message.importance,
web_link: message.webLink,
is_read_in_mailbox: message.isReadInMailbox ? 1 : 0,
// Wiederaufgetauchte Mail gilt wieder als vorhanden.
is_deleted: 0,
};
if (message.bodyText === undefined) {
delete columns.body_text;
delete columns.body_html;
delete columns.body_preview;
}
if (existing[0]) {
const keys = Object.keys(columns).filter((key) => key !== 'graph_id_hash');
await pool.query(
`UPDATE crm_mail_messages SET ${keys.map((key) => `${key} = ?`).join(', ')} WHERE id = ?`,
[...keys.map((key) => columns[key]), existing[0].id]
);
return { id: existing[0].id, isNew: false };
}
const keys = Object.keys(columns);
const [result] = await pool.query(
`INSERT INTO crm_mail_messages (${keys.join(', ')})
VALUES (${keys.map(() => '?').join(', ')})`,
keys.map((key) => columns[key])
);
return { id: result.insertId, isNew: true };
}
/**
* Im Postfach geloeschte Mail markieren, nicht entfernen: eine bereits an eine
* Opportunity gehaengte Mail soll nicht aus dem CRM verschwinden, nur weil
* jemand das Postfach aufraeumt.
*/
export async function markMessageDeleted(graphIdHash) {
const [result] = await pool.query(
'UPDATE crm_mail_messages SET is_deleted = 1 WHERE graph_id_hash = ?',
[graphIdHash]
);
return result.affectedRows > 0;
}
const CANDIDATE_COLUMNS = [
'message_id',
'dedupe_hash',
'first_name',
'last_name',
'salutation',
'email',
'phone',
'mobile',
'company',
'position',
'street',
'zip',
'city',
'website',
'linkedin',
'origin',
'confidence',
];
/**
* 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 - 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 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(', ')})`,
CANDIDATE_COLUMNS.map((column) => row[column])
);
if (result.affectedRows > 0) created.push({ id: result.insertId, ...row });
}
return created;
}
/** Bestehenden Kontakt zu einer Adresse finden (Gross-/Kleinschreibung egal). */
export async function findLeadByEmail(email) {
if (!email) return null;
const [rows] = await pool.query(
'SELECT id, first_name, last_name FROM crm_leads WHERE LOWER(email) = LOWER(?) ORDER BY id LIMIT 1',
[email]
);
return rows[0] ?? null;
}
/** Verknuepfung Mail <-> Kontakt/Opportunity; doppelte Paare werden ignoriert. */
export async function linkMessage({ messageId, leadId = null, opportunityId = null, origin = 'manuell', note = null, userId = null }) {
const [result] = await pool.query(
`INSERT IGNORE INTO crm_mail_links (message_id, lead_id, opportunity_id, origin, note, created_by)
VALUES (?, ?, ?, ?, ?, ?)`,
[messageId, leadId, opportunityId, origin, note, userId]
);
return result.affectedRows > 0;
}