dorfteich/apps/backup/src/mail.ts
Claude Fable 5 52192eb05f
All checks were successful
CD / Build and push images (push) Successful in 3m51s
CI / Lint, typecheck, test (push) Successful in 4m5s
CD / Deploy to Test (push) Successful in 11s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m11s
CD / Promote to Int (push) Successful in 12s
CI / Auth e2e pack (push) Successful in 5m52s
CI / Import/export fidelity gate (push) Successful in 47s
Backup mirror to BASEL: rsync of the sets after every successful run (#84)
The operator-level extra beside the admin-configured Nextcloud target
(#103), unblocked now that the ONE→BASEL tunnel is stable again.

- sidecar: optional mirror step (mirror.ts) driven purely by env —
  BACKUP_MIRROR_TARGET (rsync-over-ssh), BACKUP_MIRROR_SSH_KEY (private
  key on the secrets volume, never in image or repo),
  BACKUP_MIRROR_SSH_PORT. Runs after the prune of every successful run,
  so --delete aligns the remote retention with the local one (the
  newest-complete-set guarantee carries over). Only set files travel
  (db-*.dump, files-*.tar.gz); status files and bundles stay local.
  Host key pinned via accept-new into .mirror_known_hosts on the backups
  volume; fixed remote modes (dirs 750, files 640, symbolic --chmod —
  octal needs rsync ≥ 3, macOS dev machines ship 2.6.9). rsync +
  openssh-client added to the sidecar image.
- status: additive `mirror` block in status.json (outcome, transferred
  count, lastSuccessAt carried across failures) — shown on the admin
  backup card; failures alert via a new backupMirrorFailed mail (de+en)
  while the local run still counts as succeeded.
- deploy/backup-basel.md: complete BASEL-side walkthrough — dedicated
  user dorfteich-backup with a /home/ home and a bash login shell,
  explicitly avoiding the Debian backup-user (UID 34) pitfalls
  (nologin shell rejects rsync sessions, /var/backups home), key
  placement through the api container onto the secrets volume, .env
  values, on-demand verification.
- tests: rsync-arg/stats-parsing units plus an integration suite against
  the real rsync binary (local target; skips where rsync is absent) —
  transfer, idempotent re-run (0 files), retention alignment, failure
  path carrying lastSuccessAt.

Verified live against the real BASEL host from a native sidecar run:
initial transfer, host-key pinning, retention alignment after a local
prune, idempotency, and the failure path (surfaced in status.json while
the local run stayed green). BASEL side provisioned per the doc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-12 12:20:32 +02:00

141 lines
4.3 KiB
TypeScript

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, string> = {},
): 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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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();
}
}