feat(kontakte): Vorbereich "Aus Mails" mit Dublettenabgleich

Der Postfachabgleich hat gefundene Personen bisher direkt in den
Kontaktbestand geschrieben. Damit wächst eine gepflegte Liste ungefragt
mit jeder Adresse aus jeder Mail - genau daran ist das Altsystem
gescheitert.

Funde landen jetzt in einem Vorbereich (crm_leads.mail_pool = 1), der als
Unterpunkt "Aus Mails" unter dem Menüpunkt Kontakte aufklappt. Der
Kontaktbestand bleibt unberührt, bis dort jemand entscheidet; in
Opportunities, Kalender und Netzwerk tauchen die Funde nicht auf.

Eine Spalte statt einer zweiten Tabelle, weil crm_mail_links und
crm_mail_contact_candidates per Fremdschlüssel auf crm_leads zeigen - so
behält der Vorbereich seine Mailbezüge.

Übernehmen kennt zwei Wege:
- neue Person: Kopie mit mail_pool = 0, die Mailverknüpfungen wandern mit
- Dublette (Adresse, sonst voller Name): gold markiert, abweichende
  Felder mit altem und neuem Wert; die Feldauswahl schreibt sie in den
  vorhandenen Kontakt. Vorausgewählt sind nur Lücken - ein Wert aus einer
  alten Signatur soll nichts Gepflegtes stillschweigend ersetzen.

db.js verschiebt beim ersten Start die vorhandenen Kontakte mit
source = 'Mail' in den Vorbereich, einmalig über den Merker
leads_mail_pool_migrated in crm_settings.

Die Sidebar-Gruppe ist dabei generisch geworden (MenuGroup) - Kontakte
und Scanner benutzen jetzt denselben Code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-08-11 08:59:14 +02:00
co-authored by Claude Opus 5
parent 8090d0aae1
commit ea55acdb5c
22 changed files with 1524 additions and 115 deletions
+30
View File
@@ -48,9 +48,39 @@ export async function initDatabase() {
conn.release();
}
await migrateMailPool();
await ensureAdminUser();
}
/*
* Einmalige Umstellung: die vom Postfachabgleich bereits angelegten Kontakte
* (source = 'Mail') wandern in den Vorbereich "Aus Mails" (mail_pool = 1).
*
* Das laeuft absichtlich nur ein einziges Mal und wird per Merker in
* crm_settings festgehalten - ein Kontakt, der aus dem Vorbereich uebernommen
* wurde, behaelt die Herkunft 'Mail' und wuerde bei jedem Start wieder
* zurueckwandern. Direkter SQL-Zugriff statt settings.js, weil das Modul
* seinerseits db.js braucht.
*/
async function migrateMailPool() {
const flag = 'leads_mail_pool_migrated';
const [rows] = await pool.query('SELECT value FROM crm_settings WHERE key_name = ? LIMIT 1', [flag]);
if (rows[0]?.value === 'true') return;
const [result] = await pool.query("UPDATE crm_leads SET mail_pool = 1 WHERE source = 'Mail'");
await pool.query(
`INSERT INTO crm_settings (key_name, value) VALUES (?, 'true')
ON DUPLICATE KEY UPDATE value = 'true'`,
[flag]
);
if (result.affectedRows > 0) {
console.log(
`[perinco CRM] ${result.affectedRows} Kontakt(e) aus Mails in den Vorbereich "Aus Mails" verschoben.`
);
}
}
async function ensureAdminUser() {
const [rows] = await pool.query('SELECT COUNT(*) AS count FROM crm_users');
if (rows[0].count > 0) return;
+30 -6
View File
@@ -4,7 +4,7 @@ import { findLeadByEmail, linkMessage } from './store.js';
/*
* Aus einem Kontakt-Vorschlag einen Kontakt machen.
*
* Zwei Dinge macht das bewusst anders als das Altsystem:
* Drei Dinge macht das bewusst anders als das Altsystem:
*
* 1. Es werden keine Platzhalter-Adressen erfunden. Wer keine E-Mail-Adresse hat,
* wird angelegt ohne - das Feld ist in crm_leads ohnehin optional. Das
@@ -13,6 +13,13 @@ import { findLeadByEmail, linkMessage } from './store.js';
*
* 2. Ein bereits vorhandener Kontakt wird nicht ein zweites Mal angelegt, sondern
* ergaenzt: leere Felder werden gefuellt, gepflegte Werte bleiben unangetastet.
*
* 3. Der Hintergrundlauf schreibt nicht in den Kontaktbestand, sondern in den
* Vorbereich "Aus Mails" (mail_pool = 1). Der Kontaktbestand ist gepflegt und
* soll nicht ohne Zutun wachsen; wer aus einer Mail auftaucht, wartet dort auf
* Sichtung. `target` waehlt den Bestand:
* 'pool' - Vorbereich, Standard des Hintergrundlaufs
* 'contacts' - richtiger Kontakt, so wie beim Uebernehmen aus der Mailansicht
*/
/** Herkunftskennzeichen in crm_leads.source - traegt die Unterkategorie. */
@@ -73,13 +80,22 @@ async function enrichLead(leadId, candidate) {
*
* `mode` steuert nur die Protokollierung: 'automatisch' kommt aus dem
* Hintergrundlauf, 'manuell' aus der Oberflaeche. `userId` ist bei automatischer
* Uebernahme null.
* Uebernahme null. `target` waehlt den Zielbestand (siehe Kopf der Datei).
*
* Die Dublettenpruefung laeuft immer nur im Zielbestand: ein Eintrag im
* Vorbereich soll die Uebernahme in den Kontaktbestand nicht blockieren, sonst
* wuerde die Uebernahme aus der Mailansicht den Vorbereichseintrag ergaenzen
* statt einen Kontakt anzulegen.
*
* Rueckgabe: { status, leadId, enrichedFields } - status ist 'uebernommen' bei
* einem neuen Kontakt und 'dublette', wenn die Adresse schon vorhanden war.
* einem neuen Eintrag und 'dublette', wenn die Adresse dort schon vorhanden war.
*/
export async function adoptCandidate(candidate, { userId = null, ownerId = null, mode = 'manuell' } = {}) {
const existing = candidate.email ? await findLeadByEmail(candidate.email) : null;
export async function adoptCandidate(
candidate,
{ userId = null, ownerId = null, mode = 'manuell', target = 'pool' } = {}
) {
const mailPool = target === 'contacts' ? 0 : 1;
const existing = candidate.email ? await findLeadByEmail(candidate.email, { mailPool }) : null;
if (existing) {
const enrichedFields = await enrichLead(existing.id, candidate);
@@ -95,7 +111,15 @@ export async function adoptCandidate(candidate, { userId = null, ownerId = null,
}
const fields = candidateToLeadFields(candidate);
const columns = { ...fields, status: 'Neu', source: MAIL_LEAD_SOURCE, tags: '[]', owner_id: ownerId, created_by: userId };
const columns = {
...fields,
status: 'Neu',
source: MAIL_LEAD_SOURCE,
mail_pool: mailPool,
tags: '[]',
owner_id: ownerId,
created_by: userId,
};
const keys = Object.keys(columns);
const [result] = await pool.query(
+13 -4
View File
@@ -200,12 +200,21 @@ export async function insertCandidates(messageId, candidates) {
return created;
}
/** Bestehenden Kontakt zu einer Adresse finden (Gross-/Kleinschreibung egal). */
export async function findLeadByEmail(email) {
/**
* Bestehenden Kontakt zu einer Adresse finden (Gross-/Kleinschreibung egal).
*
* `mailPool` waehlt den Bestand: 0 sind die richtigen Kontakte, 1 der Vorbereich
* "Aus Mails". Beide werden getrennt durchsucht - ein Eintrag im Vorbereich darf
* die Uebernahme in den Kontaktbestand nicht als Dublette erscheinen lassen und
* umgekehrt. Ohne Angabe wird ueber beide Bestaende gesucht.
*/
export async function findLeadByEmail(email, { mailPool = null } = {}) {
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]
`SELECT id, first_name, last_name, mail_pool FROM crm_leads
WHERE LOWER(email) = LOWER(?)${mailPool === null ? '' : ' AND mail_pool = ?'}
ORDER BY id LIMIT 1`,
mailPool === null ? [email] : [email, mailPool]
);
return rows[0] ?? null;
}
+7 -2
View File
@@ -240,13 +240,18 @@ export async function runMailSync({ trigger = 'manuell' } = {}) {
* 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.
*
* Ziel ist der Vorbereich "Aus Mails" (mail_pool = 1), nicht der
* Kontaktbestand: der Hintergrundlauf soll die gepflegte Kontaktliste
* nicht von selbst fuellen. Der Weg in die Kontakte fuehrt ueber die
* Ansicht "Aus Mails".
*/
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(candidate, { mode: 'automatisch' });
const result = await adoptCandidate(candidate, { mode: 'automatisch', target: 'pool' });
if (result.status === 'uebernommen') adoptedContacts += 1;
} catch (err) {
console.warn(`[Mail] Kontakt-Vorschlag konnte nicht uebernommen werden: ${err.message}`);
@@ -299,7 +304,7 @@ export async function runMailSync({ trigger = 'manuell' } = {}) {
if (newMessages || newCandidates || deletedMessages) {
console.log(
`[Mail] Abgleich (${trigger}): ${newMessages} neu, ${updatedMessages} aktualisiert, ` +
`${deletedMessages} entfernt, ${newCandidates} Kontakt-Vorschlaege, ${adoptedContacts} Kontakte angelegt.`
`${deletedMessages} entfernt, ${newCandidates} Kontakt-Vorschlaege, ${adoptedContacts} Eintraege in "Aus Mails".`
);
}
if (hasMore || truncated) {
+267 -1
View File
@@ -59,7 +59,14 @@ function serializeTags(value) {
}
function mapLead(row) {
return { ...row, tags: parseTags(row.tags), owner_id: row.owner_id, created_by: row.created_by };
return {
...row,
tags: parseTags(row.tags),
owner_id: row.owner_id,
created_by: row.created_by,
// TINYINT(1) kommt als 0/1 - im Frontend soll es ein Schalter sein.
mail_pool: Boolean(row.mail_pool),
};
}
const SELECT_LEADS = `
@@ -134,6 +141,265 @@ router.post('/', async (req, res, next) => {
}
});
// ---------------------------------------------------------------------------
// Vorbereich "Aus Mails" (mail_pool = 1)
//
// Der Postfachabgleich legt gefundene Personen hier ab, nicht im Kontaktbestand.
// Von hier fuehren zwei Wege in die Kontakte: als neuer Kontakt (Kopie) oder als
// Ergaenzung eines vorhandenen Kontakts mit ausgewaehlten Feldern.
// ---------------------------------------------------------------------------
/*
* Felder, die beim Uebernehmen verglichen und uebertragen werden koennen.
* `source` fehlt absichtlich - die Herkunft 'Mail' wandert unveraendert mit und
* ist keine Entscheidung des Benutzers. `notes`, `avatar`, `tags` und `status`
* bleiben ebenfalls aussen vor: sie sind gepflegte Angaben des Kontakts und
* haben in einem Mailfund kein Gegenstueck.
*/
const POOL_MERGE_FIELDS = [
'salutation',
'first_name',
'last_name',
'company',
'position',
'email',
'phone',
'mobile',
'linkedin',
'website',
'street',
'zip',
'city',
'country',
];
async function loadPoolLead(id) {
const [rows] = await pool.query('SELECT * FROM crm_leads WHERE id = ? AND mail_pool = 1 LIMIT 1', [id]);
return rows[0] ?? null;
}
/**
* Mailbezuege des Vorbereichseintrags auf den Zielkontakt uebertragen: die
* Mails, aus denen die Person stammt, haengen danach am richtigen Kontakt.
* Die Vorschlaege werden umgehaengt statt kopiert - beim Entfernen des
* Vorbereichseintrags wuerde ihr Verweis sonst auf NULL fallen.
*/
async function moveMailRelations(fromLeadId, toLeadId) {
await pool.query(
`INSERT IGNORE INTO crm_mail_links (message_id, lead_id, origin, note, created_by)
SELECT message_id, ?, origin, note, created_by FROM crm_mail_links WHERE lead_id = ?`,
[toLeadId, fromLeadId]
);
await pool.query('UPDATE crm_mail_contact_candidates SET lead_id = ? WHERE lead_id = ?', [
toLeadId,
fromLeadId,
]);
}
/** Vorbereichseintrag als neuen Kontakt anlegen; gibt die neue ID zurueck. */
async function copyPoolLeadToContacts(poolLead, { userId, ownerId }) {
const columns = {};
for (const field of TEXT_FIELDS) columns[field] = poolLead[field] ?? null;
columns.status = poolLead.status;
columns.tags = poolLead.tags ?? '[]';
columns.mail_pool = 0;
columns.owner_id = ownerId;
columns.created_by = userId;
const keys = Object.keys(columns);
const [result] = await pool.query(
`INSERT INTO crm_leads (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`,
keys.map((key) => columns[key])
);
await moveMailRelations(poolLead.id, result.insertId);
return result.insertId;
}
// GET /api/leads/mail-pool/sources - woher stammt welcher Eintrag
router.get('/mail-pool/sources', async (req, res, next) => {
try {
/*
* Betreff der juengsten Mail ohne Unterabfrage je Zeile: CONCAT stellt das
* Datum voran (als Text immer 19 Zeichen), MAX waehlt damit die juengste
* Zeile aus, SUBSTRING schneidet das Datum wieder ab.
*/
const [messages] = await pool.query(
`SELECT ml.lead_id,
COUNT(DISTINCT ml.message_id) AS message_count,
MAX(m.received_at) AS last_received_at,
SUBSTRING(MAX(CONCAT(m.received_at, COALESCE(m.subject, ''))), 20) AS last_subject
FROM crm_mail_links ml
JOIN crm_mail_messages m ON m.id = ml.message_id
JOIN crm_leads l ON l.id = ml.lead_id
WHERE l.mail_pool = 1 AND ml.lead_id IS NOT NULL
GROUP BY ml.lead_id`
);
// Fundstelle und Verlaesslichkeit: derselbe Trick, hier mit der auf drei
// Stellen aufgefuellten Trefferguete als Sortierpraefix.
const [candidates] = await pool.query(
`SELECT c.lead_id,
MAX(c.confidence) AS confidence,
SUBSTRING(MAX(CONCAT(LPAD(c.confidence, 3, '0'), c.origin)), 4) AS origin
FROM crm_mail_contact_candidates c
JOIN crm_leads l ON l.id = c.lead_id
WHERE l.mail_pool = 1
GROUP BY c.lead_id`
);
const sources = {};
for (const row of messages) {
sources[row.lead_id] = {
message_count: Number(row.message_count),
last_received_at: row.last_received_at,
last_subject: row.last_subject || null,
confidence: null,
origin: null,
};
}
for (const row of candidates) {
const entry = (sources[row.lead_id] ??= {
message_count: 0,
last_received_at: null,
last_subject: null,
confidence: null,
origin: null,
});
entry.confidence = Number(row.confidence);
entry.origin = row.origin || null;
}
res.json(sources);
} catch (err) {
next(err);
}
});
/*
* POST /api/leads/mail-pool/promote - mehrere Eintraege auf einen Schlag als
* neue Kontakte anlegen. Muss vor /:id/promote stehen, sonst faengt die
* Einzelroute "mail-pool" als ID ab.
*/
router.post('/mail-pool/promote', async (req, res, next) => {
const ids = Array.isArray(req.body?.ids) ? req.body.ids.map(Number).filter(Number.isInteger) : [];
if (ids.length === 0) return res.status(400).json({ error: 'Keine Eintraege uebergeben.' });
const keepPool = req.body?.keep_pool === true;
try {
const created = [];
const skipped = [];
for (const id of ids) {
const poolLead = await loadPoolLead(id);
if (!poolLead) {
skipped.push(id);
continue;
}
const leadId = await copyPoolLeadToContacts(poolLead, {
userId: req.user.id,
ownerId: poolLead.owner_id ?? req.user.id,
});
if (!keepPool) await pool.query('DELETE FROM crm_leads WHERE id = ?', [poolLead.id]);
created.push(leadId);
}
res.json({ ok: true, created, skipped });
} catch (err) {
next(err);
}
});
/*
* POST /api/leads/:id/promote - einen Eintrag uebernehmen.
*
* ohne target_lead_id : neuer Kontakt als Kopie
* mit target_lead_id : die in `fields` genannten Werte in den vorhandenen
* Kontakt schreiben (Dublettenfall)
*
* `keep_pool` behaelt den Eintrag in "Aus Mails"; ohne das verschwindet er dort,
* sobald er in den Kontakten steht.
*/
router.post('/:id/promote', async (req, res, next) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Lead-ID.' });
const keepPool = req.body?.keep_pool === true;
const targetLeadId =
req.body?.target_lead_id === undefined || req.body.target_lead_id === null
? null
: Number(req.body.target_lead_id);
if (targetLeadId !== null && !Number.isInteger(targetLeadId)) {
return res.status(400).json({ error: 'Ungueltige Ziel-Kontakt-ID.' });
}
try {
const poolLead = await loadPoolLead(id);
if (!poolLead) return res.status(404).json({ error: 'Eintrag in "Aus Mails" nicht gefunden.' });
// Fall 1: neuer Kontakt.
if (targetLeadId === null) {
const ownerId =
req.body?.owner_id === undefined || req.body.owner_id === null
? poolLead.owner_id ?? req.user.id
: Number(req.body.owner_id);
if (!Number.isInteger(ownerId) || !(await userExists(ownerId))) {
return res.status(400).json({ error: 'Zustaendiger Benutzer existiert nicht.' });
}
const leadId = await copyPoolLeadToContacts(poolLead, { userId: req.user.id, ownerId });
if (!keepPool) await pool.query('DELETE FROM crm_leads WHERE id = ?', [poolLead.id]);
return res.status(201).json({
merged: false,
fields: 0,
removed_pool: !keepPool,
lead: await fetchLead(leadId),
});
}
// Fall 2: vorhandenen Kontakt ergaenzen.
const [targets] = await pool.query(
'SELECT id FROM crm_leads WHERE id = ? AND mail_pool = 0 LIMIT 1',
[targetLeadId]
);
if (!targets[0]) return res.status(400).json({ error: 'Zielkontakt existiert nicht.' });
const requested = Array.isArray(req.body?.fields) ? req.body.fields : [];
const fields = requested.filter((field) => POOL_MERGE_FIELDS.includes(field));
const updates = {};
for (const field of fields) {
const value = emptyToNull(poolLead[field]);
// Ein leerer Wert aus dem Vorbereich darf einen gepflegten Kontakt nicht
// ausraeumen - und Namen duerfen ohnehin nicht leer werden.
if (value === null) continue;
updates[field] = value;
}
const keys = Object.keys(updates);
if (keys.length > 0) {
await pool.query(
`UPDATE crm_leads SET ${keys.map((key) => `${key} = ?`).join(', ')} WHERE id = ?`,
[...keys.map((key) => updates[key]), targetLeadId]
);
}
await moveMailRelations(poolLead.id, targetLeadId);
if (!keepPool) await pool.query('DELETE FROM crm_leads WHERE id = ?', [poolLead.id]);
res.json({
merged: true,
fields: keys.length,
removed_pool: !keepPool,
lead: await fetchLead(targetLeadId),
});
} catch (err) {
next(err);
}
});
// PATCH /api/leads/:id
router.patch('/:id', async (req, res, next) => {
const id = Number(req.params.id);
+20 -3
View File
@@ -74,6 +74,7 @@ router.get('/status', async (req, res, next) => {
(SELECT COUNT(*) FROM crm_mail_messages WHERE is_deleted = 0) AS messages,
(SELECT COUNT(*) FROM crm_mail_contact_candidates WHERE status = 'offen') AS open_candidates,
(SELECT COUNT(*) FROM crm_mail_contact_candidates WHERE status = 'uebernommen') AS adopted_candidates,
(SELECT COUNT(*) FROM crm_leads WHERE mail_pool = 1) AS pool_leads,
(SELECT COUNT(*) FROM crm_mail_links) AS links`
);
@@ -99,6 +100,7 @@ router.get('/status', async (req, res, next) => {
messages: Number(counts.messages),
open_candidates: Number(counts.open_candidates),
adopted_candidates: Number(counts.adopted_candidates),
pool_leads: Number(counts.pool_leads),
links: Number(counts.links),
},
last_result: lastMailSyncResult(),
@@ -283,8 +285,12 @@ router.get('/messages/:id', async (req, res, next) => {
const [candidates] = await pool.query(
`SELECT c.*,
CONCAT_WS(' ', lead.first_name, lead.last_name) AS lead_name,
/* Nur richtige Kontakte zaehlen (mail_pool = 0). Ein Eintrag im
Vorbereich "Aus Mails" ist keine Dublette - das Uebernehmen von
Hand legt trotzdem einen Kontakt an. */
(SELECT lx.id FROM crm_leads lx
WHERE c.email IS NOT NULL AND LOWER(lx.email) = LOWER(c.email)
WHERE c.email IS NOT NULL AND lx.mail_pool = 0
AND LOWER(lx.email) = LOWER(c.email)
ORDER BY lx.id LIMIT 1) AS existing_lead_id
FROM crm_mail_contact_candidates c
LEFT JOIN crm_leads lead ON lead.id = c.lead_id
@@ -423,9 +429,11 @@ router.get('/candidates', async (req, res, next) => {
CONCAT_WS(' ', lead.first_name, lead.last_name) AS lead_name,
u.name AS decided_by_name,
/* Gibt es diese Adresse schon als Kontakt? Dann ist es keine
Neuanlage, sondern eine Ergaenzung. */
Neuanlage, sondern eine Ergaenzung. Der Vorbereich "Aus Mails"
(mail_pool = 1) zaehlt hier nicht mit. */
(SELECT lx.id FROM crm_leads lx
WHERE c.email IS NOT NULL AND LOWER(lx.email) = LOWER(c.email)
WHERE c.email IS NOT NULL AND lx.mail_pool = 0
AND LOWER(lx.email) = LOWER(c.email)
ORDER BY lx.id LIMIT 1) AS existing_lead_id
FROM crm_mail_contact_candidates c
JOIN crm_mail_messages m ON m.id = c.message_id
@@ -473,10 +481,19 @@ router.post('/candidates/:id/adopt', async (req, res, next) => {
: Number(req.body.owner_id);
if (!Number.isInteger(ownerId)) return res.status(400).json({ error: 'Ungueltige Benutzer-ID.' });
/*
* Von Hand uebernommen heisst: richtiger Kontakt. Der Vorbereich "Aus Mails"
* ist fuer den Hintergrundlauf gedacht - wer hier klickt, hat den Vorschlag
* schon gesehen und entschieden. Mit target: 'pool' laesst sich das im Bedarf
* umstellen, etwa aus einer Sammelaktion.
*/
const target = req.body?.target === 'pool' ? 'pool' : 'contacts';
const result = await adoptCandidate(candidate, {
userId: req.user.id,
ownerId,
mode: 'manuell',
target,
});
res.json(result);
+14
View File
@@ -60,6 +60,20 @@ CREATE TABLE IF NOT EXISTS crm_leads (
CONSTRAINT fk_crm_leads_creator FOREIGN KEY (created_by) REFERENCES crm_users (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*
* Kontakte aus dem Postfach landen nicht direkt im Kontaktbestand, sondern in
* einem Vorbereich: mail_pool = 1. Die Kontaktansicht zeigt nur mail_pool = 0,
* der Vorbereich steht als eigener Menuepunkt "Aus Mails" daneben. Erst die
* Uebernahme dort erzeugt einen richtigen Kontakt (mail_pool = 0).
*
* Bewusst eine Spalte statt einer zweiten Tabelle: Mailverknuepfungen
* (crm_mail_links) und Vorschlaege (crm_mail_contact_candidates) zeigen per
* Fremdschluessel auf crm_leads und bleiben damit auch fuer den Vorbereich
* gueltig. Die Einmal-Umstellung bestehender Zeilen macht db.js.
*/
ALTER TABLE crm_leads ADD COLUMN IF NOT EXISTS mail_pool TINYINT(1) NOT NULL DEFAULT 0 AFTER source;
ALTER TABLE crm_leads ADD INDEX IF NOT EXISTS idx_crm_leads_mail_pool (mail_pool);
-- Wer steht mit wem in Kontakt. Ungerichtet gedacht; Duplikate werden ueber
-- den Unique-Key auf dem sortierten Paar verhindert (siehe Backend).
CREATE TABLE IF NOT EXISTS crm_lead_connections (