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
+2
View File
@@ -43,6 +43,8 @@ erDiagram
enum status "Neu .. Verloren"
varchar source "z.B. 'Mail' bei Herkunft Postfach"
tinyint mail_pool "1 = Vorbereich 'Aus Mails', noch kein Kontakt"
tinyint mail_consent "Einverständnis für Werbemails"
datetime mail_consent_at "wann erteilt - der Nachweis"
longtext tags "JSON-Array als Text"
longtext avatar
int owner_id FK
+52
View File
@@ -164,6 +164,58 @@ Sobald Kontakte aus dem Postfach übernommen wurden, erscheint neben dem
Statusfilter ein **Herkunftsfilter**: *Aus Mails übernommen* zeigt nur die aus
dem Postfach stammenden, *Selbst angelegt* nur die von Hand erfassten.
### Einverständnis für Mails
Im Kontakt steht direkt unter der E-Mail-Adresse das Kästchen **Einverständnis
für Mails**. Nur mit diesem Haken kommt die Adresse in eine Mailingliste. Ohne
eingetragene Adresse ist das Kästchen gesperrt - es gibt dann nichts, wozu
jemand einwilligen könnte.
![Kontakt mit gesetztem Einverständnis für Mails](bilder/kontakt-einverstaendnis.png)
Der Zeitpunkt wird mitgespeichert und im Kontakt angezeigt („Erteilt am …").
Beim Widerruf verschwindet er wieder. Bei einer Beschwerde ist nämlich nicht die
Frage, *ob* ein Einverständnis vorliegt, sondern *seit wann*.
Wo der Haken gesetzt ist, erscheint das Briefsymbol der Kachel grün, in der
Liste steht ein Häkchen hinter der Adresse. So sieht man es ohne jeden Kontakt zu
öffnen.
Zwei Dinge, die bewusst so sind:
* **Im Vorbereich *Aus Mails* gibt es das Kästchen nicht.** Dass jemand eine Mail
geschrieben hat, ist keine Einwilligung in Werbung.
* **Beim Übernehmen aus *Aus Mails* wird der Haken nicht mitgesetzt.** Ein neu
übernommener Kontakt startet immer ohne Einverständnis.
### Mailingliste erstellen
Der Knopf **Mailingliste erstellen** in der Kontaktleiste sammelt alle Adressen
mit Einverständnis, durch Komma getrennt. Die Zahl am Knopf sagt vorab, wie viele
das sind.
![Dialog Mailingliste mit Kopier- und Mailsymbol](bilder/kontakte-mailingliste.png)
Rechts im Feld stehen zwei Symbole:
| Symbol | Wirkung |
| --- | --- |
| **Kopieren** | legt die komplette Liste in die Zwischenablage - danach in Outlook ins BCC-Feld einfügen |
| **Brief** | öffnet direkt eine neue Mail im Mailprogramm, die Adressen stehen im **BCC** |
Der Brief-Weg (`mailto:`) hat eine Grenze: Windows gibt nur rund 2000 Zeichen an
das Mailprogramm weiter. Wird die Liste länger, ist das Symbol gesperrt und der
Dialog sagt es - sonst würde die Empfängerliste unterwegs abgeschnitten, ohne
dass es jemand merkt. Dann bleibt der Weg über die Zwischenablage.
Die Adressen gehen absichtlich ins **BCC** und nicht ins An-Feld: dort sähe jeder
Empfänger die vollständige Kundenliste.
Unter *Wer ist dabei* stehen Name, Firma und Adresse aller Empfänger zum
Nachprüfen. Darunter steht, wie viele Kontakte fehlen - ohne Haken oder mit Haken
aber ohne Adresse. Das **Trennzeichen** lässt sich auf Semikolon umstellen, falls
Outlook die Komma-Liste nicht auflöst.
### Aus Mails
Der Menüpunkt *Kontakte* klappt zwei Unterpunkte auf: *Alle Kontakte* und
+1
View File
@@ -246,6 +246,7 @@ Warum genau diese Konstanten festgelegt sind, steht in
| `components/leads/mailPool.ts` | `findMailPoolMatch()`, `diffFields()`, `isConflict()`, `MAIL_POOL_FIELDS` - Dublettenabgleich des Vorbereichs |
| `components/leads/MailPoolLeads.tsx` | Ansicht „Aus Mails": Goldmarkierung, Abweichungen, Übernehmen, Sammelübernahme |
| `components/leads/MailPoolMergeDialog.tsx` | Feldauswahl beim Ergänzen eines vorhandenen Kontakts |
| `components/leads/MailingListDialog.tsx` | Adressen der Kontakte mit Einverständnis: Zwischenablage und `mailto:` mit BCC, Längengrenze 1900 Zeichen |
| `components/scanner/scannerUi.tsx` | `cardClass`, Buttonklassen, `MatchBadge`, `MatchStatusBadge`, `PortalStatusPill`, `formatDateTime`, `projectTimestamp` |
| `components/mail/mailUi.tsx` | `Badge`, `ConfidenceBar`, Herkunfts- und Statusbeschriftungen, `formatAddresses`, `avatarColor` |
| `components/mail/MailBody.tsx` | Mailinhalt im `<iframe sandbox="">`, externe Bilder per CSP gesperrt |
+2
View File
@@ -15,6 +15,8 @@ Quelltext.
| **Delta Query** | Graph-Abfrage, die beim nächsten Aufruf nur noch die Änderungen liefert. Der Fortschritt steckt in einem Verweis (`delta_link`), der in `crm_mail_sync_state` liegt. |
| **Kontakt** / Lead | Person im Netzwerk: Firma, Position, Status, Verbindungen. Tabelle `crm_leads` mit `mail_pool = 0`. Im Code durchgängig `lead`. |
| **Aus Mails** / Vorbereich | Zeilen in `crm_leads` mit `mail_pool = 1`: Personen, die der Postfachabgleich gefunden hat. Eigener Menüpunkt unter *Kontakte*, taucht in keiner Auswahlliste auf. Erst das Übernehmen macht daraus einen Kontakt. |
| **Einverständnis für Mails** | Haken am Kontakt (`crm_leads.mail_consent`, Zeitpunkt in `mail_consent_at`). Nur damit kommt die Adresse in die **Mailingliste**. Im Vorbereich *Aus Mails* gibt es ihn nicht, und beim Übernehmen wird er nicht mitgesetzt. |
| **Mailingliste** | Alle Adressen mit Einverständnis, durch Komma getrennt. Über die Zwischenablage oder als `mailto:` mit den Adressen im BCC. Nichts wird versendet - das CRM hat keinen Postausgang. |
| **Dublette** (Aus Mails) | Ein Fund, zu dem es in den Kontakten schon eine Person gibt - erkannt an der E-Mail-Adresse, sonst am vollen Namen. In der Liste **gold** markiert, abweichende Felder hervorgehoben. |
| **Kontakt-Vorschlag** | Aus einer Mail gelesene Person, die noch kein Kontakt ist. Tabelle `crm_mail_contact_candidates`. Wird übernommen, ergänzt oder verworfen. |
| **Match** / Treffer | Die Bewertung **eines** Projekts für **einen** Agenten: Prozentwert, Pro-Liste, Contra-Liste, Status. Tabelle `matches`. Ein Projekt erzeugt so viele Treffer, wie es aktive Agenten gibt. |
Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

+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 (
+24 -9
View File
@@ -9,22 +9,26 @@ interface LeadGridProps {
onDelete: (lead: Lead) => void;
}
const IconLink: React.FC<{ href: string; title: string; path: string; external?: boolean }> = ({
href,
title,
path,
external,
}) => (
const IconLink: React.FC<{
href: string;
title: string;
path: string;
external?: boolean;
/** Hebt das Symbol hervor - beim Briefumschlag heisst das: Einverstaendnis liegt vor. */
highlight?: boolean;
}> = ({ href, title, path, external, highlight }) => (
<a
href={href}
title={title}
target={external ? '_blank' : undefined}
rel={external ? 'noopener noreferrer' : undefined}
onClick={(event) => event.stopPropagation()}
className="rounded-lg p-1.5 text-ink-400 transition hover:bg-brand-50 hover:text-brand-600"
className={`rounded-lg p-1.5 transition hover:bg-brand-50 hover:text-brand-600 ${
highlight ? 'text-brand-600' : 'text-ink-400'
}`}
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={path} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={highlight ? 2.4 : 2} d={path} />
</svg>
</a>
);
@@ -104,7 +108,18 @@ export const LeadGrid: React.FC<LeadGridProps> = ({ leads, connectionCount, onEd
<div className="mt-auto flex items-center justify-between gap-2 border-t border-ink-100 pt-4">
<div className="flex items-center gap-0.5">
{lead.email && <IconLink href={`mailto:${lead.email}`} title={lead.email} path={MAIL_PATH} />}
{lead.email && (
<IconLink
href={`mailto:${lead.email}`}
title={
lead.mail_consent
? `${lead.email} · Einverständnis für Mails liegt vor`
: lead.email
}
path={MAIL_PATH}
highlight={lead.mail_consent}
/>
)}
{(lead.phone || lead.mobile) && (
<IconLink
href={`tel:${lead.phone || lead.mobile}`}
+17 -1
View File
@@ -97,13 +97,29 @@ export const LeadList: React.FC<LeadListProps> = ({
<td className="px-5 py-3">
<div className="space-y-0.5 text-xs">
{lead.email ? (
<span className="flex min-w-0 items-center gap-1">
<a
href={`mailto:${lead.email}`}
onClick={(event) => event.stopPropagation()}
className="block truncate text-ink-600 transition hover:text-brand-600"
className="min-w-0 truncate text-ink-600 transition hover:text-brand-600"
>
{lead.email}
</a>
{/* Haken = Einverstaendnis fuer Mails; ohne ihn kommt die
Adresse in keine Mailingliste. */}
{lead.mail_consent && (
<svg
className="h-3.5 w-3.5 shrink-0 text-brand-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-label="Einverständnis für Mails"
>
<title>Einverständnis für Mails</title>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
)}
</span>
) : (
<span className="text-ink-300">keine E-Mail</span>
)}
+41
View File
@@ -46,6 +46,8 @@ interface FormState {
avatar: string;
owner_id: string;
tags: string[];
/** Einverstaendnis in Werbemails - Voraussetzung fuer die Mailingliste. */
mail_consent: boolean;
}
function toFormState(lead: Lead | null, fallbackOwnerId: number | null): FormState {
@@ -70,6 +72,7 @@ function toFormState(lead: Lead | null, fallbackOwnerId: number | null): FormSta
avatar: lead?.avatar ?? '',
owner_id: String(lead?.owner_id ?? fallbackOwnerId ?? ''),
tags: lead?.tags ?? [],
mail_consent: lead?.mail_consent ?? false,
};
}
@@ -303,6 +306,44 @@ export const LeadModal: React.FC<LeadModalProps> = ({
<label className={labelClass}>Website</label>
<input type="url" value={form.website} onChange={(e) => set('website', e.target.value)} className={inputClass} />
</div>
{/*
* Einverstaendnis in Werbemails. Steht bewusst direkt unter der
* E-Mail-Adresse und nicht bei den Tags: es gehoert zur Adresse und
* entscheidet, ob sie in eine Mailingliste darf.
*/}
<div className="sm:col-span-2">
<label
className={`flex cursor-pointer items-start gap-3 rounded-xl border px-4 py-3 transition ${
form.mail_consent
? 'border-brand-200 bg-brand-50'
: 'border-ink-200 bg-surface hover:bg-ink-50'
}`}
>
<input
type="checkbox"
checked={form.mail_consent}
onChange={(e) => set('mail_consent', e.target.checked)}
disabled={!form.email.trim()}
className="mt-0.5 h-4 w-4 shrink-0 rounded border-ink-300 text-brand-500 focus:ring-brand-500/25 disabled:opacity-40"
/>
<span className="min-w-0">
<span className="block text-sm font-bold text-ink-900">Einverständnis für Mails</span>
<span className="block text-xs leading-relaxed text-ink-500">
{form.email.trim()
? 'Diese Adresse darf für Mailings verwendet werden und erscheint in der Mailingliste.'
: 'Erst eine E-Mail-Adresse eintragen – ohne Adresse gibt es nichts einzuwilligen.'}
{lead?.mail_consent && lead.mail_consent_at && (
<>
{' '}
Erteilt am{' '}
{new Date(lead.mail_consent_at.replace(' ', 'T')).toLocaleDateString('de-DE')}.
</>
)}
</span>
</span>
</label>
</div>
</div>
</section>
+37
View File
@@ -3,6 +3,7 @@ import { LeadGrid } from './LeadGrid';
import { LeadList, type SortConfig, type SortKey } from './LeadList';
import { LeadNetwork } from './LeadNetwork';
import { LeadModal } from './LeadModal';
import { MailingListDialog } from './MailingListDialog';
import { ConfirmDialog } from '../ui/Modal';
import { useToast } from '../Toast';
import { leadsApi } from '../../lib/api';
@@ -74,6 +75,7 @@ export const Leads: React.FC<LeadsProps> = ({
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingLead, setEditingLead] = useState<Lead | null>(null);
const [leadToDelete, setLeadToDelete] = useState<Lead | null>(null);
const [isMailingListOpen, setIsMailingListOpen] = useState(false);
/*
* Sprung aus der Mailansicht: den gemeinten Kontakt direkt oeffnen. Ist er noch
@@ -160,6 +162,12 @@ export const Leads: React.FC<LeadsProps> = ({
[leads]
);
// Wie viele Adressen die Mailingliste enthaelt - ohne Adresse zaehlt der Haken nicht.
const consentCount = useMemo(
() => leads.filter((lead) => lead.mail_consent && lead.email?.trim()).length,
[leads]
);
const handleSort = (key: SortKey) => {
setSortConfig((prev) =>
prev.key === key
@@ -271,6 +279,31 @@ export const Leads: React.FC<LeadsProps> = ({
))}
</div>
{/*
* Mailingliste: nur die Kontakte mit Einverstaendnis. Der Zaehler steht
* am Knopf, damit man vor dem Oeffnen weiss, ob sich das lohnt.
*/}
<button
onClick={() => setIsMailingListOpen(true)}
title="Adressen aller Kontakte mit Einverständnis für Mails"
className="flex items-center gap-2 whitespace-nowrap rounded-xl border border-ink-200 bg-surface px-4 py-2.5 text-sm font-bold text-ink-700 transition hover:border-brand-300 hover:text-brand-700"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
Mailingliste erstellen
{consentCount > 0 && (
<span className="rounded-md border border-brand-200 bg-brand-50 px-1.5 py-0.5 text-[10px] font-extrabold text-brand-700">
{consentCount}
</span>
)}
</button>
<button
onClick={openCreate}
className="flex items-center gap-2 whitespace-nowrap rounded-xl bg-brand-500 px-4 py-2.5 text-sm font-bold text-white shadow-card transition hover:bg-brand-600"
@@ -349,6 +382,10 @@ export const Leads: React.FC<LeadsProps> = ({
/>
)}
{isMailingListOpen && (
<MailingListDialog leads={leads} onClose={() => setIsMailingListOpen(false)} />
)}
{leadToDelete && (
<ConfirmDialog
title="Kontakt löschen?"
+226
View File
@@ -0,0 +1,226 @@
import React, { useMemo, useState } from 'react';
import { Modal } from '../ui/Modal';
import { useToast } from '../Toast';
import { leadFullName, type Lead } from '../../types';
/*
* Mailingliste aus den Kontakten mit Einverstaendnis.
*
* Zwei Wege hinaus, weil beide ihre Grenzen haben:
*
* Zwischenablage - immer verlaesslich, auch bei mehreren Hundert Adressen.
* mailto: - oeffnet das Mailprogramm direkt, ist aber laengenbegrenzt.
* Windows gibt an die Shell rund 2000 Zeichen weiter, danach
* wird die Adressliste stillschweigend abgeschnitten. Deshalb
* wird die Laenge geprueft und der Knopf gesperrt, statt eine
* halbe Empfaengerliste zu oeffnen.
*
* Die Adressen gehen ins **BCC**. Im An-Feld saehe jeder Empfaenger die komplette
* Kundenliste - das ist der Klassiker unter den Datenschutzvorfaellen.
*/
interface MailingListDialogProps {
/** Nur der gepflegte Kontaktbestand; der Vorbereich "Aus Mails" hat kein Einverständnis. */
leads: Lead[];
onClose: () => void;
}
/** Ab hier wird die mailto-Adresse unzuverlässig (Windows-Shell-Grenze). */
const MAILTO_LIMIT = 1900;
export const MailingListDialog: React.FC<MailingListDialogProps> = ({ leads, onClose }) => {
const { addToast } = useToast();
const [separator, setSeparator] = useState<',' | ';'>(',');
const recipients = useMemo(
() =>
leads
.filter((lead) => lead.mail_consent && lead.email && lead.email.trim())
.sort((a, b) => leadFullName(a).localeCompare(leadFullName(b), 'de')),
[leads]
);
// Doppelte Adressen zusammenfassen - zwei Kontakte können dieselbe Adresse haben.
const addresses = useMemo(() => {
const seen = new Map<string, string>();
for (const lead of recipients) {
const address = lead.email!.trim();
if (!seen.has(address.toLowerCase())) seen.set(address.toLowerCase(), address);
}
return [...seen.values()];
}, [recipients]);
const list = addresses.join(`${separator} `);
const mailto = `mailto:?bcc=${encodeURIComponent(addresses.join(','))}`;
const tooLong = mailto.length > MAILTO_LIMIT;
const withoutConsent = leads.filter((lead) => !lead.mail_consent).length;
const consentWithoutMail = leads.filter((lead) => lead.mail_consent && !lead.email?.trim()).length;
const copy = async () => {
try {
await navigator.clipboard.writeText(list);
addToast(`${addresses.length} ${addresses.length === 1 ? 'Adresse' : 'Adressen'} kopiert.`, 'success');
} catch {
/*
* Ohne Berechtigung oder in einem unsicheren Kontext gibt es die
* Zwischenablage-Schnittstelle nicht. Dann der alte Weg: Text markieren
* und vom Browser kopieren lassen.
*/
const field = document.createElement('textarea');
field.value = list;
field.style.position = 'fixed';
field.style.opacity = '0';
document.body.appendChild(field);
field.select();
const ok = document.execCommand('copy');
document.body.removeChild(field);
addToast(
ok ? `${addresses.length} Adressen kopiert.` : 'Kopieren nicht möglich – bitte den Text markieren.',
ok ? 'success' : 'error'
);
}
};
return (
<Modal
title="Mailingliste"
subtitle={`${addresses.length} ${
addresses.length === 1 ? 'Adresse' : 'Adressen'
} mit Einverständnis für Mails`}
size="lg"
onClose={onClose}
footer={
<div className="flex flex-wrap items-center justify-between gap-3">
<label className="flex items-center gap-2 text-xs font-bold text-ink-500">
Trennzeichen
<select
value={separator}
onChange={(event) => setSeparator(event.target.value as ',' | ';')}
className="rounded-lg border border-ink-200 bg-surface px-2 py-1 text-xs font-bold text-ink-700 outline-none focus:border-brand-500"
title="Outlook erwartet im An-Feld je nach Einstellung ein Semikolon."
>
<option value=",">Komma</option>
<option value=";">Semikolon</option>
</select>
</label>
<button
onClick={onClose}
className="rounded-xl border border-ink-200 px-5 py-2.5 text-sm font-bold text-ink-600 transition hover:bg-ink-100"
>
Schließen
</button>
</div>
}
>
{addresses.length === 0 ? (
<div className="rounded-2xl border-2 border-dashed border-ink-200 py-14 text-center">
<p className="text-sm font-bold text-ink-500">Kein Kontakt hat ein Einverständnis für Mails.</p>
<p className="mt-2 text-xs text-ink-400">
Den Haken setzt man im Kontakt selbst, direkt unter der E-Mail-Adresse.
</p>
</div>
) : (
<div className="space-y-4">
<div className="relative">
<textarea
readOnly
value={list}
onFocus={(event) => event.currentTarget.select()}
rows={6}
className="scrollbar-slim w-full resize-y rounded-xl border border-ink-200 bg-ink-50/60 px-4 py-3 pr-24 text-sm leading-relaxed text-ink-800 outline-none focus:border-brand-500"
/>
<div className="absolute right-2 top-2 flex gap-1">
<button
onClick={copy}
title="In die Zwischenablage kopieren"
aria-label="Adressen in die Zwischenablage kopieren"
className="rounded-lg border border-ink-200 bg-surface p-2 text-ink-500 shadow-card transition hover:border-brand-300 hover:text-brand-600"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
</button>
{tooLong ? (
<span
title={`Zu viele Adressen für einen Mailprogramm-Aufruf (${mailto.length} von ${MAILTO_LIMIT} Zeichen). Bitte kopieren und im Mailprogramm einfügen.`}
className="cursor-not-allowed rounded-lg border border-ink-200 bg-surface p-2 text-ink-300 shadow-card"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</span>
) : (
<a
href={mailto}
title="Neue Mail im Mailprogramm öffnen – Adressen im BCC"
aria-label="Neue Mail im Mailprogramm öffnen"
className="rounded-lg border border-ink-200 bg-surface p-2 text-ink-500 shadow-card transition hover:border-brand-300 hover:text-brand-600"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</a>
)}
</div>
</div>
<p className="text-xs leading-relaxed text-ink-500">
Das Mailsymbol öffnet eine neue Mail mit den Adressen im <strong>BCC</strong> – so sieht kein
Empfänger, wer die Mail noch bekommen hat.
{tooLong && (
<>
{' '}
<span className="font-bold text-amber-700">
Bei dieser Menge ist der Weg über das Mailprogramm gesperrt ({mailto.length} Zeichen,
Grenze {MAILTO_LIMIT}): Windows würde die Empfängerliste abschneiden. Bitte kopieren und
im Mailprogramm ins BCC-Feld einfügen.
</span>
</>
)}
</p>
<details className="rounded-xl border border-ink-200 bg-surface px-4 py-3">
<summary className="cursor-pointer text-xs font-extrabold uppercase tracking-wider text-ink-500">
Wer ist dabei ({addresses.length})
</summary>
<ul className="mt-3 space-y-1">
{recipients.map((lead) => (
<li key={lead.id} className="flex flex-wrap items-baseline gap-x-2 text-xs">
<span className="font-bold text-ink-800">{leadFullName(lead)}</span>
{lead.company && <span className="text-ink-400">{lead.company}</span>}
<span className="text-ink-500">{lead.email}</span>
</li>
))}
</ul>
</details>
{(withoutConsent > 0 || consentWithoutMail > 0) && (
<p className="text-xs text-ink-400">
Nicht enthalten: {withoutConsent}{' '}
{withoutConsent === 1 ? 'Kontakt ohne Einverständnis' : 'Kontakte ohne Einverständnis'}
{consentWithoutMail > 0 && `, ${consentWithoutMail} mit Einverständnis aber ohne Adresse`}.
</p>
)}
</div>
)}
</Modal>
);
};
+8
View File
@@ -123,6 +123,14 @@ export interface Lead {
* oder im Kalender - erst die Uebernahme macht daraus einen Kontakt.
*/
mail_pool: boolean;
/**
* Einverständnis in Werbemails. Nur damit kommt die Adresse in eine
* Mailingliste. Gilt ausschliesslich für richtige Kontakte - im Vorbereich
* "Aus Mails" gibt es das Kennzeichen nicht.
*/
mail_consent: boolean;
/** Wann der Haken gesetzt wurde - der Nachweis, seit wann es gilt. */
mail_consent_at?: string | null;
tags: string[];
notes: string | null;
avatar: string | null;