// Nodemailer transport + the branded Blut24 HTML mails. // Transport mirrors the ERP: localhost:25, no auth, no TLS verification. // // Mail HTML rules applied here: table layout, inline styles only, max-width 600px, // no external images (SVG is stripped by Gmail), a bulletproof padded-link button, // and a plain-text alternative for every mail. // // LANGUAGE: every visitor-facing mail takes a `lang` and is built from // server/utils/i18n.ts. The value comes from the REGISTRATION ROW, not from the request // that happens to trigger the mail — someone who registered in English gets English mail // even if the confirmation link is later opened in a German browser. `buildInternalMail` // is the exception and stays German: it goes to the operator, not to a visitor. import nodemailer from 'nodemailer' import { asLang, serverMessages, type ApiLang } from './i18n' import { normaliseType } from './registrationTypes' export const escapeHtml = (value: any): string => String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') let transporter: any = null export const getTransport = () => { if (transporter) return transporter const config = useRuntimeConfig() transporter = nodemailer.createTransport({ host: String(config.smtpHost || 'localhost'), port: Number(config.smtpPort || 25), secure: false, tls: { rejectUnauthorized: false }, }) return transporter } export type MailInput = { to: string, subject: string, html: string, text: string, bcc?: string } /** Sends a mail. Never throws — a failed send must not roll back a stored registration. */ export const sendMail = async (input: MailInput): Promise<{ ok: boolean, error: string }> => { const config = useRuntimeConfig() try { await getTransport().sendMail({ from: String(config.mailFrom || 'Blut24 '), to: input.to, bcc: input.bcc || String(config.mailBcc || '') || undefined, subject: input.subject, html: input.html, text: input.text, }) return { ok: true, error: '' } } catch (err: any) { console.error('[blut24] E-Mail-Versand fehlgeschlagen:', err?.message || err) return { ok: false, error: err?.message || String(err) } } } // ------------------------------------------------------------------ layout const RED = '#B71C1C' const RED_MID = '#C62828' const BLUE = '#0D47A1' const INK = '#0F172A' const MUTED = '#475569' const SURFACE = '#F8FAFC' const BORDER = '#E2E8F0' const layout = (opts: { preheader: string, heading: string, body: string, lang?: ApiLang }) => { const lang = asLang(opts.lang) const m = serverMessages(lang).mail return ` Blut24
${escapeHtml(opts.preheader)}
Blut24
${escapeHtml(m.brandTagline)}

${escapeHtml(opts.heading)}

${opts.body}
Blut24 · ${escapeHtml(m.brandTagline)}
${escapeHtml(m.autoNote)}
${m.notRequested}
` } const button = (href: string, label: string) => `
${escapeHtml(label)}
` const infoRow = (label: string, value: string) => value ? ` ${escapeHtml(label)} ${escapeHtml(value)} ` : '' // ------------------------------------------------------------------ mails export type ConfirmationMailInput = { /** Registration type key — selects the wording block below. */ type?: string to: string contactName: string typeLabel: string orgName: string address: string confirmUrl: string needsManualApproval: boolean /** Language stored on the registration row. Defaults to German. */ lang?: string } /** * Double-opt-in mail with the token link. * * What it says about the registration differs per type: a courier is not a clinic — it * has no Standort, no blood stock and no Konserven, so the clinic wording would simply be * wrong for them. The per-type blocks live in server/utils/i18n.ts (`mail.types`). * * The key is normalised before the lookup so a retired type ('abgebende_klinik', * 'beziehende_klinik') still gets the clinic block. Without it the `??` fallback would * silently drop the mail's main explanatory paragraph — the mail would still send, just * with a hole in it, in both the HTML and the plain-text alternative. */ export const buildConfirmationMail = (input: ConfirmationMailInput) => { const lang = asLang(input.lang) const m = serverMessages(lang).mail const c = m.confirmation const greeting = escapeHtml(m.greeting(input.contactName || '')) const rawType = String(input.type ?? '') const meta = m.types[rawType] ?? m.types[normaliseType(rawType)] ?? { label: input.typeLabel, orgRow: m.fallbackOrgRow, text: '', } const manual = input.needsManualApproval ? `

${escapeHtml(c.manualNoticeLabel)}${escapeHtml(c.manualNotice)}

` : '' const body = `

${greeting}

${c.intro}

${escapeHtml(meta.text)}

${button(input.confirmUrl, c.button)}

${escapeHtml(c.linkFallback)}
${escapeHtml(input.confirmUrl)}

${manual} ${infoRow(c.rowType, meta.label)} ${infoRow(meta.orgRow, input.orgName)} ${infoRow(c.rowAddress, input.address)} ${infoRow(c.rowEmail, input.to)}

${escapeHtml(c.validity)}

` const text = [ m.greeting(input.contactName || ''), '', c.textIntro, c.textLinkLead, input.confirmUrl, '', meta.text, meta.text ? '' : null, c.textRegisteredAs(meta.label), `${meta.orgRow}: ${input.orgName}`, input.address ? c.textAddress(input.address) : '', '', input.needsManualApproval ? c.textManual : '', c.validity, '', c.footer, ].filter((l) => l !== '').join('\n') return { subject: c.subject, html: layout({ preheader: c.preheader, heading: c.heading, body, lang }), text, } } export type WelcomeMailInput = { to: string contactName: string orgName: string typeLabel: string needsManualApproval: boolean credentialsSent: boolean /** Language stored on the registration row. Defaults to German. */ lang?: string } /** * Sent after a successful confirmation. * * The "what happens next" sentence has THREE states and the order below is the honest one. * Since registrations are activated automatically wherever the validator can decide them * (see server/api/registration/submit.post.ts), `credentialsSent` is the normal case and * `manual` is the exception — the opposite of how this read before: * * credentialsSent → the ERP created the account and has already mailed the credentials. * Say the access is ready; do NOT promise a manual review that will * never happen. * needsManualApproval → nothing could be verified (clinic without a domain on record, or * a courier). A human really does look at it. * otherwise → auto-activation was intended but the credentials mail has not gone * out (ERP disabled, or the call failed and operations will retry). * Promise the separate mail, not a review. * * `credentialsSent` deliberately outranks `needsManualApproval`: if credentials are already * in the recipient's inbox, telling them to wait for a review would be plainly wrong. */ export const buildWelcomeMail = (input: WelcomeMailInput) => { const lang = asLang(input.lang) const m = serverMessages(lang).mail const w = m.welcome const greeting = escapeHtml(m.greeting(input.contactName || '')) const next = input.credentialsSent ? w.credentials : input.needsManualApproval ? w.manual : w.pending const body = `

${greeting}

${w.confirmed(escapeHtml(input.orgName), escapeHtml(input.typeLabel))}

${escapeHtml(next)}

${escapeHtml(w.listTitle)}

` const text = [ m.greeting(input.contactName || ''), '', `${input.orgName} — ${input.typeLabel}`, input.credentialsSent ? w.textCredentials : input.needsManualApproval ? w.textManual : w.textPending, '', m.confirmation.footer, ].join('\n') return { subject: w.subject, html: layout({ preheader: w.preheader, heading: w.heading, body, lang }), text, } } export type DuplicateAttemptMailInput = { contactName: string orgName: string attemptedEmail: string attemptedAt: string /** Language stored on the EXISTING registration — this mail goes to its owner. */ lang?: string } /** * Sent to the ALREADY-REGISTERED contact when someone tries to register the same * facility again. * * The person attempting registration is deliberately told nothing — they get the * same neutral "check your inbox" response as a first-time registrant, so the form * cannot be used to discover which clinics are already on Blut24. The real account * holder is the one who gets informed, because they are the only party entitled to * know and the only one who can act on it (a colleague signing up, or an abuse * attempt). * * We show the attempted address so the owner can recognise a colleague, but we do * not include any link that would let this mail be used to take over the account. */ export const buildDuplicateAttemptMail = (input: DuplicateAttemptMailInput) => { const lang = asLang(input.lang) const m = serverMessages(lang).mail const d = m.duplicate const greeting = escapeHtml(m.greeting(input.contactName || '')) const body = `

${greeting}

${d.intro(escapeHtml(input.orgName))}

${infoRow(d.rowTime, input.attemptedAt)} ${infoRow(d.rowEmail, input.attemptedEmail)}

${d.reassure}

${escapeHtml(d.unknown)}

` return { subject: d.subject, html: layout({ preheader: d.preheader, heading: d.subject, body, lang }), text: [ m.greeting(input.contactName || ''), '', `${input.orgName} — Blut24`, `${d.rowTime}: ${input.attemptedAt}`, `${d.rowEmail}: ${input.attemptedEmail}`, '', d.reassureLead, d.unknown, ].join('\n'), } } /** * Internal notification (operations inbox) — plain and information-dense. * Deliberately German-only: the recipient is the operator, not a visitor. */ export const buildInternalMail = (subject: string, rows: Array<[string, string]>, note = '') => { const body = ` ${note ? `

${escapeHtml(note)}

` : ''} ${rows.map(([k, v]) => infoRow(k, v)).join('')}
` return { subject, html: layout({ preheader: subject, heading: subject, body, lang: 'de' }), text: [note, ...rows.map(([k, v]) => `${k}: ${v}`)].filter(Boolean).join('\n'), } }