Files
skrift-programme/Docker/directus/extensions/directus-extension-skrift-mailer/dist/index.js
Lucas Orth 57d48409e6 Kontakt-CRM, Chargen-Sammeldruck, API-Status & Auto-Generierung
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>
2026-08-21 08:53:08 +02:00

292 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[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(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&').replace(/&lt;/gi, '<').replace(/&gt;/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}`); }
});
};