Files
skrift-directus/extensions/directus-extension-skrift-mailer/dist/index.js
s4luorth 6a6b09d49d Uebersicht: Sidebar Suche/Uebersicht, API-Gruppierung, Versanddatum, 3-Uhr-Mail
uebersicht:
- Secondary-Sidebar: "Uebersicht" + "Suche". Suche wie frueher (Empfaenger
  suchen -> Schriftstueckart -> In Warteschlange, fuer Nachdruck).
- API-Auftraege als aufklappbare Charge-Gruppe (Kopf mit Summen: Schriftstuecke,
  Kuverts, Format, Versand). Versandfertig unterteilt: schnellstmoeglich / Datum.
- Versanddatum je Auftrag editierbar (leer = schnellstmoeglich).

bootstrap: orders.versanddatum (date).
produktion: Charge-Kopf zeigt Summen (Schriftstuecke/Kuverts/Format/Versand).
mailer: 03:00-Mail listet zusaetzlich "heute zu versenden" (faelliges Versanddatum).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-01 11:21:49 +02:00

334 lines
17 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', 'bcc_team'],
});
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', 'team_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];
}
// „Kopie an mich" (pro Vorlage): Team-Adresse als BCC (Kunde sieht sie nicht).
if (tpl.bcc_team && settings.team_email) nachricht.bcc = String(settings.team_email).trim();
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;
// Manuelle Aufträge (im Admin angelegt, source=operator): KEINE Mail – weder
// Bestellbestätigung an den Kunden noch Team-Benachrichtigung.
if (payload?.source === 'operator') 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) return;
// Kaufmännischer Status (bezahlt/versendet/…) → passende Vorlage.
// ODER Produktionsstatus „versendet" (Board) → Versandbenachrichtigung – auch
// für manuelle Aufträge; nur beim ERSTEN Versand (erkennbar am gleichzeitig
// gesetzten versandt_am), damit keine Doppel-Mail entsteht.
let mailKey = null;
if (payload.status !== undefined) mailKey = payload.status;
else if (payload.production_status === 'versendet' && payload.versandt_am) mailKey = 'versendet';
if (!mailKey) return;
for (const id of keys || []) {
try { await sendForOrder(id, mailKey, { status: STATUS_LABEL[mailKey] || mailKey }); }
catch (e) { logger.warn(`[skrift-mailer] status ${mailKey}: ${e.message}`); }
}
});
// ── Tägliche Gesamtübersicht (03:00) – alle Aufträge nach Produktionsstatus ──
// Ersetzt die frühere API-Einzelzusammenfassung: jeden Morgen eine Übersicht
// ALLER Bestellungen inkl. Mengen (Schriftstücke) je Status. Keine separate
// API-Zusammenfassung mehr.
const UEBERSICHT_CRON = process.env.SKRIFT_UEBERSICHT_CRON || '0 3 * * *';
const PSTATUS_ORDER = [
['eingegangen', 'Eingegangen / Neu'],
['im_druck', 'Im Druck'],
['kuvertieren', 'Kuvertierung'],
['versandfertig', 'Versandfertig'],
['versendet', 'Versendet'],
['storniert', 'Storniert'],
];
register.schedule(UEBERSICHT_CRON, async () => {
try {
const schema = await getSchema();
const to = await teamEmpfaenger(schema);
if (!to) return;
const settings = await loadSettings(schema);
const orders = await new ItemsService('orders', { schema, accountability: null }).readByQuery({
fields: ['production_status', 'entries_count'], limit: -1,
});
const agg = {};
let gesamtA = 0, gesamtM = 0;
for (const o of (orders || [])) {
const st = o.production_status || 'eingegangen';
if (!agg[st]) agg[st] = { auftraege: 0, menge: 0 };
agg[st].auftraege += 1; agg[st].menge += Number(o.entries_count) || 0;
gesamtA += 1; gesamtM += Number(o.entries_count) || 0;
}
// Bekannte Status in sinnvoller Reihenfolge, unbekannte hinten anhängen.
const reihen = [...PSTATUS_ORDER];
for (const st of Object.keys(agg)) if (!reihen.some(([k]) => k === st)) reihen.push([st, st]);
const td = 'padding:4px 10px;';
const zeilen = reihen.filter(([k]) => agg[k]).map(([k, label]) =>
`<tr><td style="${td}">${esc(label)}</td>`
+ `<td style="${td}text-align:right;"><strong>${agg[k].auftraege}</strong></td>`
+ `<td style="${td}text-align:right;">${agg[k].menge}</td></tr>`).join('');
const heute = new Date().toLocaleDateString('de-DE');
const html = `<p>Gesamtübersicht aller Aufträge (Stand ${esc(heute)}):</p>`
+ '<table style="border-collapse:collapse;font-size:14px;">'
+ `<thead><tr><th style="text-align:left;${td}border-bottom:1px solid #ccc;">Status</th>`
+ `<th style="text-align:right;${td}border-bottom:1px solid #ccc;">Aufträge</th>`
+ `<th style="text-align:right;${td}border-bottom:1px solid #ccc;">Schriftstücke</th></tr></thead>`
+ `<tbody>${zeilen || `<tr><td style="${td}" colspan="3">Keine Aufträge.</td></tr>`}</tbody>`
+ `<tfoot><tr><td style="${td}border-top:2px solid #333;"><strong>Gesamt</strong></td>`
+ `<td style="${td}text-align:right;border-top:2px solid #333;"><strong>${gesamtA}</strong></td>`
+ `<td style="${td}text-align:right;border-top:2px solid #333;"><strong>${gesamtM}</strong></td></tr></tfoot>`
+ '</table>';
// Heute zu versenden: versandfertige Aufträge mit fälligem (≤ heute) Versanddatum.
let faelligHtml = ''; let faelligN = 0;
try {
const heuteStr = new Date().toISOString().slice(0, 10);
const faellig = await new ItemsService('orders', { schema, accountability: null }).readByQuery({
filter: { _and: [{ production_status: { _eq: 'versandfertig' } }, { versanddatum: { _nnull: true } }, { versanddatum: { _lte: heuteStr } }] },
fields: ['order_number', 'order_name', 'versanddatum', 'entries_count'], limit: -1, sort: ['versanddatum'],
});
faelligN = (faellig || []).length;
if (faelligN) {
const z = faellig.map((o) => `<li><strong>${esc(o.order_name || o.order_number)}</strong> – ${o.entries_count || 0} Stk., Versand ${esc(o.versanddatum)}</li>`).join('');
faelligHtml = `<p style="margin-top:18px;color:#a32d2d;"><strong>Heute zu versenden (${faelligN}):</strong></p><ul>${z}</ul>`;
}
} catch (e) { logger.warn(`[skrift-mailer] Fälligkeits-Check: ${e.message}`); }
const htmlFull = html + faelligHtml;
const nachricht = { to, subject: `Auftragsübersicht ${heute} – ${gesamtA} Aufträge${faelligN ? ` · ${faelligN} heute versenden` : ''}`, html: htmlFull, text: htmlZuText(htmlFull), 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);
logger.info(`[skrift-mailer] Auftragsübersicht verschickt: ${gesamtA} Aufträge in ${Object.keys(agg).length} Status`);
} catch (e) { logger.warn(`[skrift-mailer] Übersicht-Tageslauf: ${e.message}`); }
});
};