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>
249 lines
12 KiB
JavaScript
249 lines
12 KiB
JavaScript
/**
|
||
* 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) => ({ '<': '<', '>': '>', '&': '&' }[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(`<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}`); }
|
||
}
|
||
});
|
||
};
|