'),
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) => `
`
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)}
${w.list.map((item) => `- ${item}
`).join('\n ')}
`
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'),
}
}