/** * 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('
'); if (a.email) html += `

E-Mail: ${esc(a.email)}`; if (a.phone) html += `
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 = `

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] || '—'}

` : ''; const liefer = adresseHtml(order.shipping_address); const lieferBlock = liefer ? `

Lieferadresse

${liefer}

` : ''; 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 "). 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]) => `
  • ${esc(d)}: ${n}
  • `).join(''); const html = `

    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}`); } }); };