/** * 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: `
Hallo {{name}},
vielen Dank für Ihre Bestellung {{order_id}}. Wir haben Ihren Auftrag erhalten und melden uns mit den nächsten Schritten.
`, }, bezahlt: { subject: 'Zahlung erhalten – Bestellung {{order_id}}', body: `Hallo {{name}},
Ihre Zahlung zur Bestellung {{order_id}} ist bei uns eingegangen. Wir starten nun mit der Bearbeitung.
`, }, versendet: { subject: 'Ihre Bestellung {{order_id}} ist unterwegs', body: `Hallo {{name}},
Ihre Bestellung {{order_id}} wurde versendet.
`, }, abgeschlossen: { subject: 'Bestellung {{order_id}} abgeschlossen', body: `Hallo {{name}},
Ihre Bestellung {{order_id}} ist abgeschlossen. Vielen Dank für Ihr Vertrauen!
`, }, storniert: { subject: 'Bestellung {{order_id}} storniert', body: `Hallo {{name}},
Ihre Bestellung {{order_id}} wurde storniert. Bei Fragen antworten Sie einfach auf diese E-Mail.
`, }, }; 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(`${esc(a.company)}`); 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('Neue Bestellung ${order.order_number}${order.product?.name ? ` (${order.product.name})` : ''} – im Produktions-Board unter „Neu".
`; 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') ? `|
Umschlag Format: ${ENVELOPE_LABEL[order.envelope_format] || '—'} · Beschriftung: ${LABELING_LABEL[order.envelope_labeling] || '—'} |
${liefer}
Heute über die API eingegangen von ${esc(g.name)}: ${g.orders.length} Auftrag/Aufträge.
` + `Nach Versandtag:
Im Produktions-Board unter „Neu" als Charge(n) gruppiert.
'; 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}`); } }); };