Files
skrift-programme/Docker/directus/extensions/directus-extension-skrift-mailer/dist/index.js
Lucas Orth 1d5c1a14d4 Public-API, mehrseitige Schriftstücke, manuelle Aufträge & Mailer
Public-API (neu, directus-extension-skrift-api):
- Versionierter /v1-Namespace: POST /v1/orders, GET /v1/orders/:nr (Status
  pending/processing/shipped/cancelled), POST /v1/files (nur PDF, ≤80 MB).
- Auth über X-Api-Key (Authorization ist von Directus reserviert), SHA-256-Hash.
- Produkt-gebunden; Kuvert-only-Produkttyp; Kuvertformat automatisch (A4→DIN Lang, sonst C6);
  kein Platzhalter, kein Kunden-multipage/font_scale; Versandart englisch (single/bulk).
- Selbst gehostete Swagger-UI-Doku (/docs) + OpenAPI (/v1/openapi.json), englisch/technisch.
- Key-Hook (directus-extension-skrift-apikey): erzeugt Key automatisch, zeigt ihn
  einmalig in key_plain.

Mehrseitige Schriftstücke:
- generateLetterPages (Auto-Fluss, [[seitenumbruch]], Leerseiten-Filter).
- Preview/Order-Generierung schreiben letter_NNN_pM.svg; Seiten-Übersicht im Neuauftrag.
- Agent (skrift-agent): Seiten je Brief absteigend an den Plotter (Stapel-Reihenfolge).
- countLetterPages + /api/order/pagecount für die Seiten-Vorschau.

Manuelle Aufträge:
- Schriftgröße (Brief/Kuvert), Signatur auf dem Brief mit X/Y/Größe-Feinjustierung,
  realistische Handschrift bis in den finalen Druck durchgereicht.
- Spalten-Einfügen in der Empfängertabelle (Excel-Spalte je Zelle).

Mailer: Text-Alternative + Reply-To (Zustellbarkeit).

App: Freitext in Musteranfrage, Konfigurator-Reset, Lightbox-Seitenverhältnis +
Bild-Download, Sticky-Vorschau-Fix, Tab-Eingabe + Rechtsbündigkeit, Leerzeilen-Trim.

Bootstrap: neue Felder (font_scale_*, signatures_manual, multi_page, page_count,
realistic, shipping_day, api_client) + Collections api_clients, Produkttyp envelope.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 10:01:53 +02:00

249 lines
12 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'] });
if (!order) 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}`); }
}
});
};