feat(kontakte): Einverständnis für Mails und Mailingliste

Am Kontakt gibt es unter der E-Mail-Adresse das Kästchen "Einverständnis
für Mails". Ohne Adresse ist es gesperrt. Der Zeitpunkt wird in
`mail_consent_at` mitgeschrieben und im Kontakt angezeigt: bei einer
Beschwerde ist nicht die Frage, ob ein Einverständnis vorliegt, sondern
seit wann. Beim Widerruf wird er geleert; ein erneutes Speichern
verschiebt ihn nicht.

Der Knopf "Mailingliste erstellen" in der Kontaktleiste sammelt alle
Adressen mit Haken, durch Komma getrennt, mit Zähler am Knopf. Rechts im
Feld ein Kopiersymbol (Zwischenablage, mit Fallback für Browser ohne die
Schnittstelle) und ein Mailsymbol.

Zum Mailsymbol zwei Entscheidungen:

- Die Adressen gehen ins BCC, nicht ins An-Feld. Im An-Feld sähe jeder
  Empfänger die vollständige Kundenliste.
- Windows gibt an das Mailprogramm nur rund 2000 Zeichen weiter. Ab 1900
  Zeichen ist das Symbol deshalb gesperrt und der Dialog erklärt es -
  sonst wäre die Empfängerliste unbemerkt abgeschnitten.

Nur für richtige Kontakte: Der Vorbereich "Aus Mails" hat das Kästchen
nicht, und beim Übernehmen wird der Haken nicht mitgesetzt - dass jemand
eine Mail geschrieben hat, ist keine Einwilligung in Werbung. Das Backend
weist ein `mail_consent` für Einträge mit `mail_pool = 1` ab.

Sichtbar ohne Öffnen: grünes Briefsymbol in der Kachel, Häkchen hinter der
Adresse in der Liste.

Geprüft im Browser gegen das Stub-Backend: Haken setzen und widerrufen,
Zähler, Komma-Liste, Zwischenablage, mailto mit BCC, kein Kästchen im
Vorbereich (15 Prüfungen).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-08-11 14:45:36 +02:00
co-authored by Claude Opus 5
parent 9a4266ae7e
commit 41e9f6898f
14 changed files with 463 additions and 18 deletions
+35 -2
View File
@@ -64,11 +64,25 @@ function mapLead(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.
// TINYINT(1) kommt als 0/1 - im Frontend sollen es Schalter sein.
mail_pool: Boolean(row.mail_pool),
mail_consent: Boolean(row.mail_consent),
};
}
/*
* Einverstaendnis in Werbemails setzen. Der Zeitpunkt wird nur beim Wechsel von
* "nein" auf "ja" gestempelt - ein zweites Speichern desselben Kontakts darf den
* Nachweis nicht auf heute verschieben. Beim Widerruf wird er geleert.
*/
function consentUpdate(incoming, previous) {
const next = incoming === true || incoming === 'true' || incoming === 1 || incoming === '1';
if (next === Boolean(previous?.mail_consent)) return {};
return next
? { mail_consent: 1, mail_consent_at: new Date() }
: { mail_consent: 0, mail_consent_at: null };
}
const SELECT_LEADS = `
SELECT l.*,
creator.name AS created_by_name,
@@ -118,7 +132,9 @@ router.post('/', async (req, res, next) => {
return res.status(400).json({ error: 'Zuständiger Benutzer existiert nicht.' });
}
const columns = [...TEXT_FIELDS, 'status', 'tags', 'owner_id', 'created_by'];
const consent = consentUpdate(req.body?.mail_consent, null);
const columns = [...TEXT_FIELDS, 'status', 'tags', 'owner_id', 'created_by', ...Object.keys(consent)];
const values = [
...TEXT_FIELDS.map((field) =>
field === 'first_name' ? firstName : field === 'last_name' ? lastName : emptyToNull(req.body?.[field])
@@ -127,6 +143,7 @@ router.post('/', async (req, res, next) => {
serializeTags(req.body?.tags) ?? '[]',
ownerId,
req.user.id,
...Object.values(consent),
];
try {
@@ -439,6 +456,22 @@ router.patch('/:id', async (req, res, next) => {
values.push(ownerId);
}
/*
* Einverstaendnis: nur fuer richtige Kontakte. Im Vorbereich "Aus Mails" gibt
* es das Kennzeichen nicht - dort steht niemand, der eingewilligt haette.
*/
if (req.body?.mail_consent !== undefined) {
const [rows] = await pool.query('SELECT mail_consent, mail_pool FROM crm_leads WHERE id = ? LIMIT 1', [id]);
if (!rows[0]) return res.status(404).json({ error: 'Lead nicht gefunden.' });
if (rows[0].mail_pool) {
return res.status(400).json({ error: 'Einträge in „Aus Mails" haben kein Einverständnis für Mails.' });
}
for (const [column, value] of Object.entries(consentUpdate(req.body.mail_consent, rows[0]))) {
fields.push(`${column} = ?`);
values.push(value);
}
}
if (fields.length === 0) return res.status(400).json({ error: 'Keine Änderungen übergeben.' });
try {
+12
View File
@@ -74,6 +74,18 @@ CREATE TABLE IF NOT EXISTS crm_leads (
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);
/*
* Einverstaendnis in Werbemails. Ohne Haken kommt die Adresse in keine
* Mailingliste - Vorgabe ist deshalb 0, auch bei Kontakten, die aus dem
* Vorbereich uebernommen werden: eine Mail von jemandem ist keine Einwilligung.
*
* `mail_consent_at` haelt fest, wann der Haken gesetzt wurde. Bei einer
* Beschwerde ist die Frage nicht "gibt es ein Einverstaendnis", sondern "seit
* wann" - ohne Zeitpunkt ist das Kennzeichen als Nachweis wertlos.
*/
ALTER TABLE crm_leads ADD COLUMN IF NOT EXISTS mail_consent TINYINT(1) NOT NULL DEFAULT 0 AFTER mail_pool;
ALTER TABLE crm_leads ADD COLUMN IF NOT EXISTS mail_consent_at DATETIME NULL AFTER mail_consent;
-- 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 (