rebreak-monorepo/backend/server/utils/magic-removal-email.ts
chahinebrini a95e66560d feat(magic): Hard-Lock + Geräte-UX (Push, Realtime, Detail-Sheet, Offline-Removal)
Devices/Magic:
- Offline-Profil-Enroll deaktiviert (410) — Lock-PW würde im Klartext im
  Download landen; stationärer Schutz läuft jetzt nur über Rebreak Magic
- Mac-DNS-Template: ProhibitDisablement (Filter nicht abschaltbar)
- Push "Neues Gerät verbunden" an mobile Geräte bei neuer Bindung
- Realtime auf user_devices → Settings aktualisiert Magic-Bindings live
- Geräte-Detail-Sheet (Tap auf Gerät): Status, verbunden-seit, Schutz-Donut

Hard-Lock (server-gehaltenes Removal-PW, User sieht es nie):
- magic_removal_password generiert/gespeichert + in Profil injiziert (Lazy-Backfill)
- Reveal NUR bei Account-Löschung (user/delete) + Kündigung (stripe webhook),
  per Resend-Mail + in-Response
- Signing config-gated (inaktiv ohne Cert; Lock greift auch unsigniert)

Migrations: user_devices-Realtime-Publication + magic_removal_password-Spalten

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:26:25 +02:00

99 lines
4.1 KiB
TypeScript

/**
* Magic Removal-Password Reveal-Email.
*
* Wird versendet wenn der User KÜNDIGT oder seinen ACCOUNT LÖSCHT — dann (und
* nur dann) bekommt er die server-gehaltenen Removal-Passwörter, um die
* gesperrten DNS-Profile von seinen Mac/Windows-Geräten zu entfernen.
*
* Im Normalbetrieb bleibt das Passwort serverseitig — der User sieht es nie
* (Hard-Lock, gegen impulsives Abschalten). Siehe memory/mac-magic-profile-lock.
*
* Fire-and-forget: Fehler werden geloggt, nie geworfen.
*/
import { Resend } from "resend";
import type { MagicRemovalCredential } from "../db/devices";
export interface MagicRemovalEmailOpts {
recipientEmail: string;
/** Nickname für die Anrede (NIE firstName/email im Body außer Empfänger). */
recipientNickname?: string | null;
credentials: MagicRemovalCredential[];
reason: "cancellation" | "account_deletion";
resendApiKey: string;
}
export async function sendMagicRemovalEmail(
opts: MagicRemovalEmailOpts,
): Promise<void> {
if (!opts.resendApiKey) {
console.warn("[magic-removal-email] resendApiKey not provided — skipping mail");
return;
}
if (opts.credentials.length === 0) return;
const resend = new Resend(opts.resendApiKey);
const greeting = opts.recipientNickname ? `Hallo ${opts.recipientNickname},` : "Hallo,";
const reasonLine =
opts.reason === "account_deletion"
? "Du hast deinen ReBreak-Account gelöscht."
: "Dein ReBreak-Abo wurde beendet.";
const rows = opts.credentials
.map((c) => {
const label = c.hostname || c.model || "Computer";
return `
<tr>
<td style="padding:10px 14px;border-bottom:1px solid #f0f0f0;font-size:14px;color:#3a3a3a;">${label}</td>
<td style="padding:10px 14px;border-bottom:1px solid #f0f0f0;font-size:16px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:1px;color:#1a1a1a;"><strong>${c.removalPassword}</strong></td>
</tr>`;
})
.join("");
const subject = "Dein Entfern-Passwort für den ReBreak-Schutz";
const html = `
<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>${subject}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #1a1a1a; background: #f5f5f7; margin: 0; padding: 0; }
.container { max-width: 560px; margin: 32px auto; background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.header { background: #1a1a1a; padding: 24px 32px; }
.header h1 { color: #fff; font-size: 18px; font-weight: 600; margin: 0; letter-spacing: -0.3px; }
.body { padding: 28px 32px; }
.body p { font-size: 15px; line-height: 1.6; color: #3a3a3a; margin: 0 0 16px; }
table { width: 100%; border-collapse: collapse; margin: 8px 0 20px; }
.steps { background: #f5f5f7; border-radius: 8px; padding: 14px 18px; font-size: 14px; color: #555; line-height: 1.6; }
.footer { padding: 16px 32px; font-size: 12px; color: #888; border-top: 1px solid #f0f0f0; }
</style></head>
<body>
<div class="container">
<div class="header"><h1>ReBreak — Schutz entfernen</h1></div>
<div class="body">
<p>${greeting}</p>
<p>${reasonLine} Damit du den ReBreak-Schutz von deinen Geräten entfernen kannst, hier deine Entfern-Passwörter:</p>
<table>${rows}</table>
<div class="steps">
<strong>So entfernst du den Schutz (Mac):</strong><br>
Systemeinstellungen → Allgemein → Geräteverwaltung → „ReBreak Schutz" → Entfernen → Passwort eingeben.
</div>
<p style="margin-top:20px;font-size:13px;color:#888;">Bewahre diese Mail auf, bis du den Schutz entfernt hast. Das Passwort wird aus Sicherheitsgründen nur hier herausgegeben.</p>
</div>
<div class="footer">Fragen? support@rebreak.org</div>
</div>
</body>
</html>`.trim();
try {
await resend.emails.send({
from: "ReBreak <noreply@rebreak.org>",
to: opts.recipientEmail,
subject,
html,
});
} catch (err: any) {
console.error("[magic-removal-email] Failed to send:", err?.message ?? err);
}
}