Kontakte/CRM (Directus): - Neue Collections `contacts` (Leads) + `contact_actions` (Rückruf/Angebot/Mail). - Hook `skrift-contacts`: Kontakte werden nur auf Anforderung angelegt (Auftrag "In Kontakte speichern"); Rückruf-Erinnerung (Zeitplan) und Angebots-/Mail-an-mich-Mails; Auto-Löschung nur bei Lead-Status "kein_interesse" nach 30 Tagen; optionaler DSGVO-Strip (per Env, Dry-Run). - Modul `skrift-kontakte`: handytaugliches Board (Anruf via groundwire:, Notizen mit Autosave, Durchblättern, CSV-Export, Aktionen als Buttons). API-Chargen & PDF-Sammeldruck: - Produktions-Board gruppiert API-Aufträge unter "Neu" zu Chargen (Kunde + Typ + Versandtag + Eingangstag), aufklappbar, mit "fällig bis" (Versandtag der übernächsten Woche). - Mehrfachauswahl -> Sammel-PDF (Backend `pdf-lib`, /api/charge/merge-pdf, Directus-Proxy /skrift-orders/charge/pdf), Kuverts an Plotter und Sammel-Status; PDF- und Kuvert-Reihenfolge nach Auftragsnummer (Kollation). - Mailer: keine Einzelmail je API-Auftrag mehr, stattdessen Tagesübersicht (18:00) pro Kunde, nach Versandtag aufgeschlüsselt. Status-API & Generierung: - Public-API-Status liest jetzt production_status (Board) statt nur kaufmännischem Status; neues Feld `status_since` in der Antwort. - Auftrag stempelt `production_status_changed_at` bei jedem Statuswechsel. - Generierung wird IMMER beim Eingang angestoßen (auch Datei-Produkte mit handgeschriebenem Kuvert), nicht nur bei letter/postcard. - Versandtag im Produktionsdashboard sichtbar. Rechte: Service-Rolle darf directus_files lesen (Backend lädt PDFs für den Sammeldruck über /assets). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
292 lines
14 KiB
JavaScript
292 lines
14 KiB
JavaScript
/**
|
||
* Skrift – Mail-Hook
|
||
* ---------------------------------------------------------------------------
|
||
* Versendet Kunden-E-Mails auf Basis editierbarer Vorlagen (`email_templates`)
|
||
* mit gemeinsamer Signatur/Footer (`email_settings`) und optionalem AGB-Anhang.
|
||
*
|
||
* orders.items.create → Vorlage „order_created" (reiche Bestellbestätigung)
|
||
* orders.items.update → Vorlage mit dem Schlüssel des neuen Status
|
||
* (z. B. „bezahlt", „versendet"), falls vorhanden+aktiv
|
||
*
|
||
* Der Login-Code wird bewusst in skrift-auth versendet (dort entsteht er).
|
||
*/
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
|
||
const STATUS_LABEL = {
|
||
wartet_auf_zahlung: 'Wartet auf Zahlung', bezahlt: 'Bezahlt',
|
||
material_erwartet: 'Material erwartet', material_eingegangen: 'Material eingegangen',
|
||
in_queue: 'In Warteschlange', in_produktion: 'In Produktion', gedruckt: 'Gedruckt',
|
||
versendet: 'Versendet', abgeschlossen: 'Abgeschlossen', storniert: 'Storniert',
|
||
};
|
||
const ENVELOPE_LABEL = { dinlang: 'DIN Lang', c6: 'C6' };
|
||
const LABELING_LABEL = { empfaenger: 'Empfängeradresse', freitext: 'Individueller Text', keine: 'Keine' };
|
||
const UPLOAD_ROOT = process.env.STORAGE_LOCAL_ROOT || '/directus/uploads';
|
||
|
||
// Eingebaute Fallback-Vorlagen: greifen NUR, wenn in `email_templates` keine
|
||
// aktive Vorlage mit passendem Schlüssel existiert. So gehen Bestätigung und
|
||
// die kundenrelevanten Status-Mails auch ohne konfigurierte Vorlage sicher raus
|
||
// (analog zum Login-Fallback in skrift-auth). Interne Status (in_queue, gedruckt …)
|
||
// haben bewusst KEINEN Fallback → für die braucht es eine aktive Vorlage.
|
||
const FALLBACK_TEMPLATES = {
|
||
order_created: {
|
||
subject: 'Bestellbestätigung {{order_id}}',
|
||
body: `<p>Hallo {{name}},</p>
|
||
<p>vielen Dank für Ihre Bestellung <strong>{{order_id}}</strong>. Wir haben Ihren Auftrag erhalten und melden uns mit den nächsten Schritten.</p>`,
|
||
},
|
||
bezahlt: {
|
||
subject: 'Zahlung erhalten – Bestellung {{order_id}}',
|
||
body: `<p>Hallo {{name}},</p>
|
||
<p>Ihre Zahlung zur Bestellung <strong>{{order_id}}</strong> ist bei uns eingegangen. Wir starten nun mit der Bearbeitung.</p>`,
|
||
},
|
||
versendet: {
|
||
subject: 'Ihre Bestellung {{order_id}} ist unterwegs',
|
||
body: `<p>Hallo {{name}},</p>
|
||
<p>Ihre Bestellung <strong>{{order_id}}</strong> wurde versendet.</p>`,
|
||
},
|
||
abgeschlossen: {
|
||
subject: 'Bestellung {{order_id}} abgeschlossen',
|
||
body: `<p>Hallo {{name}},</p>
|
||
<p>Ihre Bestellung <strong>{{order_id}}</strong> ist abgeschlossen. Vielen Dank für Ihr Vertrauen!</p>`,
|
||
},
|
||
storniert: {
|
||
subject: 'Bestellung {{order_id}} storniert',
|
||
body: `<p>Hallo {{name}},</p>
|
||
<p>Ihre Bestellung <strong>{{order_id}}</strong> wurde storniert. Bei Fragen antworten Sie einfach auf diese E-Mail.</p>`,
|
||
},
|
||
};
|
||
|
||
const render = (str, vars) =>
|
||
String(str || '').replace(/\{\{(\w+)\}\}/g, (_, k) => (vars[k] != null ? String(vars[k]) : ''));
|
||
const eur = (n) => (Number(n) || 0).toFixed(2).replace('.', ',');
|
||
const esc = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c]));
|
||
|
||
// Antwortadresse für Kundenmails – echte, überwachte Adresse statt „noreply".
|
||
const REPLY_TO = 'hello@skrift.de';
|
||
|
||
/** Grobe Text-Alternative aus dem HTML. Reine HTML-Mails ohne Text-Teil kosten
|
||
* bei Spamfiltern (SpamAssassin) sichere Punkte – der Text-Part senkt die Wertung. */
|
||
const htmlZuText = (html) => String(html || '')
|
||
.replace(/<\s*br\s*\/?>/gi, '\n')
|
||
.replace(/<\/(p|div|tr|h[1-6]|li|table)>/gi, '\n')
|
||
.replace(/<[^>]+>/g, '')
|
||
.replace(/ /gi, ' ')
|
||
.replace(/&/gi, '&').replace(/</gi, '<').replace(/>/gi, '>')
|
||
.replace(/[ \t]+\n/g, '\n')
|
||
.replace(/\n{3,}/g, '\n\n')
|
||
.trim();
|
||
|
||
/** Formatiert einen Adress-Snapshot (billing/shipping) als HTML. */
|
||
function adresseHtml(a) {
|
||
if (!a || typeof a !== 'object') return '';
|
||
const zeilen = [];
|
||
if (a.company) zeilen.push(`<strong>${esc(a.company)}</strong>`);
|
||
zeilen.push(esc([a.first_name, a.last_name].filter(Boolean).join(' ')));
|
||
if (a.street) zeilen.push(esc(a.street));
|
||
zeilen.push(esc([a.zip, a.city].filter(Boolean).join(' ')));
|
||
if (a.country) zeilen.push(esc(a.country));
|
||
let html = zeilen.filter(Boolean).join('<br>');
|
||
if (a.email) html += `<br><br>E-Mail: ${esc(a.email)}`;
|
||
if (a.phone) html += `<br>Telefon: ${esc(a.phone)}`;
|
||
return html;
|
||
}
|
||
|
||
export default (register, { services, getSchema, logger }) => {
|
||
const { ItemsService, MailService } = services;
|
||
|
||
async function loadTemplate(key, schema) {
|
||
const rows = await new ItemsService('email_templates', { schema, accountability: null }).readByQuery({
|
||
filter: { key: { _eq: key }, enabled: { _eq: true } },
|
||
limit: 1, fields: ['subject', 'body', 'append_signature', 'attach_agb'],
|
||
});
|
||
return rows?.[0] || null;
|
||
}
|
||
|
||
async function loadSettings(schema) {
|
||
try {
|
||
return await new ItemsService('email_settings', { schema, accountability: null })
|
||
.readSingleton({ fields: ['signature', 'portal_url', 'company_footer', 'agb_file', 'sender_name', 'sender_email'] });
|
||
} catch { return {}; }
|
||
}
|
||
|
||
/** Konfigurierter Team-Empfänger (separat gelesen, damit ein evtl. noch nicht
|
||
* gebootstraptes Feld die Kundenmails nicht beeinträchtigt). */
|
||
async function teamEmpfaenger(schema) {
|
||
try {
|
||
const es = await new ItemsService('email_settings', { schema, accountability: null })
|
||
.readSingleton({ fields: ['team_email'] });
|
||
return (es?.team_email || '').trim();
|
||
} catch { return ''; }
|
||
}
|
||
|
||
/** Schlanke interne Benachrichtigung „Neue Bestellung …" (Empfänger konfigurierbar). */
|
||
async function teamNotify(orderId) {
|
||
try {
|
||
const schema = await getSchema();
|
||
const to = await teamEmpfaenger(schema);
|
||
if (!to) return;
|
||
const settings = await loadSettings(schema);
|
||
const order = await new ItemsService('orders', { schema, accountability: null })
|
||
.readOne(orderId, { fields: ['order_number', 'product.name', 'source'] });
|
||
if (!order) return;
|
||
// API-Aufträge NICHT einzeln melden – dafür gibt es die Tagesübersicht (18:00).
|
||
if (order.source === 'api') return;
|
||
const html = `<p>Neue Bestellung <strong>${order.order_number}</strong>${order.product?.name ? ` (${order.product.name})` : ''} – im Produktions-Board unter „Neu".</p>`;
|
||
const nachricht = {
|
||
to,
|
||
subject: `Neue Bestellung ${order.order_number}`,
|
||
html,
|
||
text: htmlZuText(html),
|
||
replyTo: REPLY_TO,
|
||
};
|
||
if (settings.sender_email) {
|
||
nachricht.from = settings.sender_name ? `${settings.sender_name} <${settings.sender_email}>` : settings.sender_email;
|
||
}
|
||
await new MailService({ schema, accountability: null }).send(nachricht);
|
||
} catch (e) {
|
||
logger.warn(`[skrift-mailer] Team-Benachrichtigung: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
/** AGB-PDF (aus email_settings.agb_file) als Nodemailer-Anhang, direkt von Platte. */
|
||
async function agbAnhang(schema, settings) {
|
||
if (!settings?.agb_file) return null;
|
||
try {
|
||
const file = await new ItemsService('directus_files', { schema, accountability: null })
|
||
.readOne(settings.agb_file, { fields: ['filename_disk', 'filename_download', 'type'] });
|
||
if (!file?.filename_disk) return null;
|
||
const buf = fs.readFileSync(path.join(UPLOAD_ROOT, file.filename_disk));
|
||
return { filename: file.filename_download || 'AGB.pdf', content: buf, contentType: file.type || 'application/pdf' };
|
||
} catch (e) {
|
||
logger.warn(`[skrift-mailer] AGB-Anhang übersprungen: ${e.message}`);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function sendForOrder(orderId, key, extraVars = {}) {
|
||
const schema = await getSchema();
|
||
const tpl = await loadTemplate(key, schema) || FALLBACK_TEMPLATES[key];
|
||
if (!tpl) return; // weder aktive Vorlage noch Fallback → keine Mail
|
||
|
||
const order = await new ItemsService('orders', { schema, accountability: null }).readOne(orderId, {
|
||
fields: [
|
||
'order_number', 'customer_email', 'entries_count', 'shipping_type',
|
||
'needs_envelope', 'envelope_labeling', 'envelope_format',
|
||
'net_total', 'vat_amount', 'gross_total', 'billing', 'shipping_address',
|
||
'customer.first_name', 'customer.last_name', 'customer.company',
|
||
'product.name', 'product.input_mode', 'format.name',
|
||
],
|
||
});
|
||
if (!order?.customer_email) return;
|
||
// Datei-Produkte (Unterschriftenservice) erhalten KEINE Standard-Bestell-
|
||
// bestätigung – dafür geht eine eigene Vorlage aus dem Bestell-Endpunkt raus.
|
||
if (key === 'order_created' && order.product?.input_mode === 'datei') return;
|
||
|
||
const settings = await loadSettings(schema);
|
||
const name = [order.customer?.first_name, order.customer?.last_name].filter(Boolean).join(' ')
|
||
|| order.customer?.company || '';
|
||
|
||
const net = Number(order.net_total) || 0;
|
||
const vat = Number(order.vat_amount) || 0;
|
||
const mwstSatz = net > 0 ? Math.round((vat / net) * 100) : 19;
|
||
|
||
const kuvertBlock = (order.needs_envelope && order.envelope_labeling && order.envelope_labeling !== 'keine')
|
||
? `<table role="presentation" width="100%" style="background:#fafafa;border-radius:6px;margin-top:12px;"><tr><td style="padding:12px 15px;">
|
||
<p style="margin:0 0 6px;font-weight:600;color:#1a365d;">Umschlag</p>
|
||
<p style="margin:0;font-size:14px;color:#666;">Format: ${ENVELOPE_LABEL[order.envelope_format] || '—'} · Beschriftung: ${LABELING_LABEL[order.envelope_labeling] || '—'}</p>
|
||
</td></tr></table>`
|
||
: '';
|
||
|
||
const liefer = adresseHtml(order.shipping_address);
|
||
const lieferBlock = liefer
|
||
? `<tr><td style="padding:16px 40px 4px;"><h2 style="margin:0 0 12px;font-size:18px;color:#1a365d;">Lieferadresse</h2><p style="margin:0;line-height:1.6;">${liefer}</p></td></tr>`
|
||
: '';
|
||
|
||
const vars = {
|
||
order_id: order.order_number, name, link: settings.portal_url || '',
|
||
product: esc(order.product?.name || ''), menge: order.entries_count ?? '',
|
||
format: esc(order.format?.name || ''),
|
||
versand: order.shipping_type === 'einzeln' ? 'Einzelversand durch Skrift' : 'Sammellieferung',
|
||
kuvert_block: kuvertBlock,
|
||
netto: eur(net), mwst_satz: mwstSatz, mwst_betrag: eur(vat), brutto: eur(order.gross_total), waehrung: 'EUR',
|
||
rechnungsadresse: adresseHtml(order.billing),
|
||
lieferadresse_block: lieferBlock,
|
||
footer: settings.company_footer || '',
|
||
...extraVars,
|
||
};
|
||
|
||
let html = render(tpl.body, vars);
|
||
if (tpl.append_signature !== false && settings.signature) html += render(settings.signature, vars);
|
||
|
||
const nachricht = { to: order.customer_email, subject: render(tpl.subject, vars), html, text: htmlZuText(html), replyTo: REPLY_TO };
|
||
// Absender aus den Einstellungen (z. B. „Skrift <noreply@skrift.de>").
|
||
if (settings.sender_email) {
|
||
nachricht.from = settings.sender_name ? `${settings.sender_name} <${settings.sender_email}>` : settings.sender_email;
|
||
}
|
||
if (tpl.attach_agb) {
|
||
const anhang = await agbAnhang(schema, settings);
|
||
if (anhang) nachricht.attachments = [anhang];
|
||
}
|
||
await new MailService({ schema, accountability: null }).send(nachricht);
|
||
logger.info(`[skrift-mailer] „${key}" an ${order.customer_email} (${order.order_number}).`);
|
||
}
|
||
|
||
register.action('orders.items.create', async ({ key, payload }) => {
|
||
if (payload?.status === 'entwurf') return;
|
||
try { await sendForOrder(key, 'order_created'); }
|
||
catch (e) { logger.warn(`[skrift-mailer] order_created: ${e.message}`); }
|
||
// Schlanke interne Benachrichtigung „Neue Bestellung …" an das Team.
|
||
await teamNotify(key);
|
||
});
|
||
|
||
register.action('orders.items.update', async ({ keys, payload }) => {
|
||
if (!payload || payload.status === undefined) return;
|
||
const status = payload.status;
|
||
for (const id of keys || []) {
|
||
try { await sendForOrder(id, status, { status: STATUS_LABEL[status] || status }); }
|
||
catch (e) { logger.warn(`[skrift-mailer] status ${status}: ${e.message}`); }
|
||
}
|
||
});
|
||
|
||
// ── Tagesübersicht der API-Aufträge (statt Einzelmail je Auftrag) ───────────
|
||
// Einmal täglich (Default 18:00): pro API-Kunde eine Zusammenfassung der heute
|
||
// eingegangenen Aufträge, aufgeschlüsselt nach Versandtag.
|
||
const API_DIGEST_CRON = process.env.SKRIFT_API_DIGEST_CRON || '0 18 * * *';
|
||
register.schedule(API_DIGEST_CRON, async () => {
|
||
try {
|
||
const schema = await getSchema();
|
||
const to = await teamEmpfaenger(schema);
|
||
if (!to) return;
|
||
const settings = await loadSettings(schema);
|
||
const start = new Date(); start.setHours(0, 0, 0, 0);
|
||
const orders = await new ItemsService('orders', { schema, accountability: null }).readByQuery({
|
||
filter: { _and: [{ source: { _eq: 'api' } }, { date_created: { _gte: start.toISOString() } }] },
|
||
fields: ['order_number', 'shipping_day', 'api_client.id', 'api_client.name', 'product.name'],
|
||
limit: -1, sort: ['order_number'],
|
||
});
|
||
if (!orders || !orders.length) return;
|
||
|
||
const byClient = new Map();
|
||
for (const o of orders) {
|
||
const kid = o.api_client?.id ?? 'x';
|
||
let g = byClient.get(kid);
|
||
if (!g) { g = { name: o.api_client?.name || 'API-Kunde', orders: [] }; byClient.set(kid, g); }
|
||
g.orders.push(o);
|
||
}
|
||
for (const g of byClient.values()) {
|
||
const byDay = {};
|
||
for (const o of g.orders) { const d = o.shipping_day || 'ohne Versandtag'; byDay[d] = (byDay[d] || 0) + 1; }
|
||
const zeilen = Object.entries(byDay).map(([d, n]) => `<li>${esc(d)}: <strong>${n}</strong></li>`).join('');
|
||
const html = `<p>Heute über die API eingegangen von <strong>${esc(g.name)}</strong>: <strong>${g.orders.length}</strong> Auftrag/Aufträge.</p>`
|
||
+ `<p>Nach Versandtag:</p><ul>${zeilen}</ul>`
|
||
+ '<p>Im Produktions-Board unter „Neu" als Charge(n) gruppiert.</p>';
|
||
const nachricht = { to, subject: `API-Tagesübersicht ${g.name} – ${g.orders.length} neue`, html, text: htmlZuText(html), replyTo: REPLY_TO };
|
||
if (settings.sender_email) nachricht.from = settings.sender_name ? `${settings.sender_name} <${settings.sender_email}>` : settings.sender_email;
|
||
try { await new MailService({ schema, accountability: null }).send(nachricht); }
|
||
catch (e) { logger.warn(`[skrift-mailer] API-Tagesmail ${g.name}: ${e.message}`); }
|
||
}
|
||
logger.info(`[skrift-mailer] API-Tagesübersicht verschickt: ${byClient.size} Kunde(n)`);
|
||
} catch (e) { logger.warn(`[skrift-mailer] API-Tageslauf: ${e.message}`); }
|
||
});
|
||
};
|