import { createTransport } from 'nodemailer'; import type { BackupEnv } from '@dorfteich/shared'; import deMails from '@dorfteich/shared/i18n/de/mails.json' with { type: 'json' }; import enMails from '@dorfteich/shared/i18n/en/mails.json' with { type: 'json' }; /** * The failure alert goes out through nodemailer directly instead of the * api's mail outbox — when backups fail, the api may be the broken part * (issue #83). Texts live in the shared `mails` i18n namespace (ADR 0012, * de + en); the operator picks the language via BACKUP_MAIL_LOCALE. The * catalog uses i18next's `{{var}}` syntax, interpolated here without * pulling i18next into the sidecar. */ export interface FailureMailInput { backupId: string; error: string; lastSuccessAt: string | null; } export interface RenderedFailureMail { subject: string; text: string; } const CATALOGS = { de: deMails, en: enMails } as const; /** Both alert mails share one key shape; the namespace picks the texts. */ type AlertNamespace = 'backupFailed' | 'backupUploadFailed' | 'backupMirrorFailed'; function t( locale: 'de' | 'en', namespace: AlertNamespace, key: keyof (typeof CATALOGS)['en']['backupFailed'], params: Record = {}, ): string { let text: string = CATALOGS[locale][namespace][key]; for (const [name, value] of Object.entries(params)) { text = text.replaceAll(`{{${name}}}`, value); } return text; } function renderAlertMail( namespace: AlertNamespace, input: FailureMailInput, locale: 'de' | 'en', instanceLabel: string, ): RenderedFailureMail { const lastSuccess = input.lastSuccessAt ? t(locale, namespace, 'lastSuccess', { finishedAt: input.lastSuccessAt }) : t(locale, namespace, 'lastSuccessNever'); return { subject: t(locale, namespace, 'subject', { instance: instanceLabel, backupId: input.backupId, }), text: [ t(locale, namespace, 'intro', { instance: instanceLabel }), '', t(locale, namespace, 'backupId', { backupId: input.backupId }), t(locale, namespace, 'error', { error: input.error }), lastSuccess, '', t(locale, namespace, 'hint'), ].join('\n'), }; } export function renderFailureMail( input: FailureMailInput, locale: 'de' | 'en', instanceLabel: string, ): RenderedFailureMail { return renderAlertMail('backupFailed', input, locale, instanceLabel); } /** Alert for a failed Nextcloud upload after a successful local run (#103). */ export function renderUploadFailureMail( input: FailureMailInput, locale: 'de' | 'en', instanceLabel: string, ): RenderedFailureMail { return renderAlertMail('backupUploadFailed', input, locale, instanceLabel); } /** Sends the alert; returns false (after logging upstream) when no relay or * recipient is configured — a missing mail must never fail the run. */ export async function sendFailureMail(env: BackupEnv, input: FailureMailInput): Promise { return sendAlertMail(env, renderFailureMail(input, env.BACKUP_MAIL_LOCALE, label(env))); } /** Same delivery path for the upload alert (issue #103). */ export async function sendUploadFailureMail( env: BackupEnv, input: FailureMailInput, ): Promise { return sendAlertMail(env, renderUploadFailureMail(input, env.BACKUP_MAIL_LOCALE, label(env))); } /** Same delivery path for the rsync-mirror alert (issue #84). */ export async function sendMirrorFailureMail( env: BackupEnv, input: FailureMailInput, ): Promise { return sendAlertMail( env, renderAlertMail('backupMirrorFailed', input, env.BACKUP_MAIL_LOCALE, label(env)), ); } function label(env: BackupEnv): string { return env.BACKUP_INSTANCE_LABEL || 'Dorfteich'; } async function sendAlertMail(env: BackupEnv, mail: RenderedFailureMail): Promise { if (!env.BACKUP_MAIL_TO) return false; const transport = createTransport({ host: env.SMTP_HOST, port: env.SMTP_PORT, secure: env.SMTP_SECURE, auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASS } : undefined, connectionTimeout: 10_000, greetingTimeout: 10_000, socketTimeout: 20_000, }); try { await transport.sendMail({ from: env.SMTP_FROM, to: env.BACKUP_MAIL_TO, subject: mail.subject, text: mail.text, }); return true; } finally { transport.close(); } }