import nodemailer, { type Transporter } from "nodemailer"; import { BRAND } from "@app/shared-types"; /** * E-postutskick med utbytbar transport. * * - EMAIL_MODE=log (dev/test): skriver mejlet till loggen i stället för att skicka. * - EMAIL_MODE=smtp (produktion): kopplas till vald leverantör (SES/Postmark/Resend) * i lanseringsfasen – transporten är en funktion, leverantörsbytet är en fil. * * Innehållet byggs som mallnyckel + variabler per språk (i18n-spec §26) – * aldrig inbakad text i anropskoden. */ export interface MailMessage { to: string; subject: string; text: string; } export interface Mailer { send(message: MailMessage): Promise; } class LogMailer implements Mailer { async send(message: MailMessage): Promise { console.log( `[mailer:log] till=${message.to} ämne="${message.subject}"\n${message.text .split("\n") .map((l) => ` | ${l}`) .join("\n")}`, ); } } export interface SmtpSettings { host: string; port: number; secure: boolean; user: string; pass: string; from: string; } /** * Riktig SMTP via nodemailer. Leverantörsoberoende: fungerar med SES, * Postmark, Resend, Brevo, egen mailserver – allt som talar standard-SMTP. * Verifierad med riktig SMTP-rundtur (HELO → MAIL FROM → DATA) mot lokal server. */ class SmtpMailer implements Mailer { private readonly transporter: Transporter; private readonly from: string; constructor(settings: SmtpSettings) { if (!settings.host) { // Högljutt fel i stället för tyst tappade mejl. throw new Error("EMAIL_MODE=smtp kräver SMTP_HOST (samt SMTP_PORT/USER/PASS/MAIL_FROM)."); } if (!settings.from) { throw new Error("EMAIL_MODE=smtp kräver MAIL_FROM (avsändaradress)."); } this.from = settings.from; this.transporter = nodemailer.createTransport({ host: settings.host, port: settings.port, secure: settings.secure, // true = SMTPS (465); false = STARTTLS/klartext (587/25) ...(settings.user ? { auth: { user: settings.user, pass: settings.pass } } : {}), }); } /** Verifiera anslutning + inloggning vid uppstart. */ async verify(): Promise { await this.transporter.verify(); } async send(message: MailMessage): Promise { await this.transporter.sendMail({ from: this.from, to: message.to, subject: message.subject, text: message.text, }); } } export function createMailer(mode: string, smtp?: SmtpSettings): Mailer { if (mode === "smtp") { if (!smtp) throw new Error("EMAIL_MODE=smtp kräver SMTP-inställningar."); return new SmtpMailer(smtp); } return new LogMailer(); } // --------------------------------------------------------------------------- // Mallar (nyckel + variabler, per språk – samma princip som notismallarna) // --------------------------------------------------------------------------- type MailTemplate = (vars: Record) => { subject: string; text: string }; const TEMPLATES: Record> = { sv: { "auth.verify_email": (v) => ({ subject: `Bekräfta din e-postadress – ${BRAND.name}`, text: `Välkommen till ${BRAND.name}!\n\n` + `Bekräfta att ${v.email} är din adress genom att öppna länken i appen:\n${v.link}\n\n` + `Länken gäller i 24 timmar. Appen fungerar även utan bekräftelse, men vissa ` + `funktioner (t.ex. lösenordsåterställning) blir säkrare med verifierad adress.\n\n${BRAND.name}`, }), "auth.password_reset": (v) => ({ subject: `Återställ ditt lösenord – ${BRAND.name}`, text: `Hej!\n\nDu (eller någon annan) har begärt att återställa lösenordet för ${v.email}.\n\n` + `Öppna länken i appen inom 30 minuter:\n${v.link}\n\n` + `Om du inte begärde detta kan du ignorera mejlet – lösenordet ändras inte.\n\n${BRAND.name}`, }), }, en: { "auth.verify_email": (v) => ({ subject: `Confirm your email – ${BRAND.name}`, text: `Welcome to ${BRAND.name}!\n\n` + `Confirm that ${v.email} is your address by opening this link in the app:\n${v.link}\n\n` + `The link is valid for 24 hours. The app works without confirmation, but some ` + `features (like password reset) are safer with a verified address.\n\n${BRAND.name}`, }), "auth.password_reset": (v) => ({ subject: `Reset your password – ${BRAND.name}`, text: `Hi!\n\nA password reset was requested for ${v.email}.\n\n` + `Open this link in the app within 30 minutes:\n${v.link}\n\n` + `If you didn't request this, you can ignore this email – your password stays unchanged.\n\n${BRAND.name}`, }), }, es: { "auth.verify_email": (v) => ({ subject: `Confirma tu correo – ${BRAND.name}`, text: `¡Bienvenido a ${BRAND.name}!\n\n` + `Confirma que ${v.email} es tu dirección abriendo este enlace en la app:\n${v.link}\n\n` + `El enlace es válido durante 24 horas. La app funciona sin confirmación, pero algunas ` + `funciones (como restablecer la contraseña) son más seguras con la dirección verificada.\n\n${BRAND.name}`, }), "auth.password_reset": (v) => ({ subject: `Restablece tu contraseña – ${BRAND.name}`, text: `¡Hola!\n\nSe ha solicitado restablecer la contraseña de ${v.email}.\n\n` + `Abre este enlace en la app en un plazo de 30 minutos:\n${v.link}\n\n` + `Si no lo solicitaste, ignora este correo: tu contraseña no cambiará.\n\n${BRAND.name}`, }), }, it: { "auth.verify_email": (v) => ({ subject: `Conferma la tua email – ${BRAND.name}`, text: `Benvenuto su ${BRAND.name}!\n\n` + `Conferma che ${v.email} è il tuo indirizzo aprendo questo link nell'app:\n${v.link}\n\n` + `Il link è valido per 24 ore. L'app funziona anche senza conferma, ma alcune ` + `funzioni (come il ripristino della password) sono più sicure con l'indirizzo verificato.\n\n${BRAND.name}`, }), "auth.password_reset": (v) => ({ subject: `Reimposta la tua password – ${BRAND.name}`, text: `Ciao!\n\nÈ stato richiesto il ripristino della password per ${v.email}.\n\n` + `Apri questo link nell'app entro 30 minuti:\n${v.link}\n\n` + `Se non l'hai richiesto, ignora questa email: la password non cambierà.\n\n${BRAND.name}`, }), }, de: { "auth.verify_email": (v) => ({ subject: `Bestätige deine E-Mail-Adresse – ${BRAND.name}`, text: `Willkommen bei ${BRAND.name}!\n\n` + `Bestätige, dass ${v.email} deine Adresse ist, indem du diesen Link in der App öffnest:\n${v.link}\n\n` + `Der Link ist 24 Stunden gültig. Die App funktioniert auch ohne Bestätigung, aber manche ` + `Funktionen (z. B. Passwort-Zurücksetzen) sind mit verifizierter Adresse sicherer.\n\n${BRAND.name}`, }), "auth.password_reset": (v) => ({ subject: `Setze dein Passwort zurück – ${BRAND.name}`, text: `Hallo!\n\nFür ${v.email} wurde ein Passwort-Reset angefordert.\n\n` + `Öffne diesen Link innerhalb von 30 Minuten in der App:\n${v.link}\n\n` + `Falls du das nicht warst, ignoriere diese E-Mail – dein Passwort bleibt unverändert.\n\n${BRAND.name}`, }), }, fr: { "auth.verify_email": (v) => ({ subject: `Confirmez votre e-mail – ${BRAND.name}`, text: `Bienvenue sur ${BRAND.name} !\n\n` + `Confirmez que ${v.email} est bien votre adresse en ouvrant ce lien dans l'app :\n${v.link}\n\n` + `Le lien est valable 24 heures. L'app fonctionne sans confirmation, mais certaines ` + `fonctions (comme la réinitialisation du mot de passe) sont plus sûres avec une adresse vérifiée.\n\n${BRAND.name}`, }), "auth.password_reset": (v) => ({ subject: `Réinitialisez votre mot de passe – ${BRAND.name}`, text: `Bonjour !\n\nUne réinitialisation du mot de passe a été demandée pour ${v.email}.\n\n` + `Ouvrez ce lien dans l'app sous 30 minutes :\n${v.link}\n\n` + `Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail : le mot de passe ne changera pas.\n\n${BRAND.name}`, }), }, da: { "auth.verify_email": (v) => ({ subject: `Bekræft din e-mailadresse – ${BRAND.name}`, text: `Velkommen til ${BRAND.name}!\n\n` + `Bekræft, at ${v.email} er din adresse ved at åbne linket i appen:\n${v.link}\n\n` + `Linket gælder i 24 timer. Appen fungerer også uden bekræftelse, men nogle funktioner (fx nulstilling af adgangskode) er sikrere med bekræftet adresse.\n\n${BRAND.name}`, }), "auth.password_reset": (v) => ({ subject: `Nulstil din adgangskode – ${BRAND.name}`, text: `Hej!\n\nDer er anmodet om nulstilling af adgangskoden for ${v.email}.\n\n` + `Åbn linket i appen inden for 30 minutter:\n${v.link}\n\n` + `Hvis du ikke har anmodet om dette, kan du ignorere denne mail – adgangskoden ændres ikke.\n\n${BRAND.name}`, }), }, nb: { "auth.verify_email": (v) => ({ subject: `Bekreft e-postadressen din – ${BRAND.name}`, text: `Velkommen til ${BRAND.name}!\n\n` + `Bekreft at ${v.email} er adressen din ved å åpne lenken i appen:\n${v.link}\n\n` + `Lenken gjelder i 24 timer. Appen fungerer også uten bekreftelse, men noen funksjoner (f.eks. tilbakestilling av passord) er tryggere med bekreftet adresse.\n\n${BRAND.name}`, }), "auth.password_reset": (v) => ({ subject: `Tilbakestill passordet ditt – ${BRAND.name}`, text: `Hei!\n\nDet er bedt om tilbakestilling av passordet for ${v.email}.\n\n` + `Åpne lenken i appen innen 30 minutter:\n${v.link}\n\n` + `Hvis du ikke ba om dette, kan du ignorere denne e-posten – passordet endres ikke.\n\n${BRAND.name}`, }), }, fi: { "auth.verify_email": (v) => ({ subject: `Vahvista sähköpostiosoitteesi – ${BRAND.name}`, text: `Tervetuloa palveluun ${BRAND.name}!\n\n` + `Vahvista, että ${v.email} on osoitteesi avaamalla linkki sovelluksessa:\n${v.link}\n\n` + `Linkki on voimassa 24 tuntia. Sovellus toimii ilman vahvistustakin, mutta jotkin toiminnot (kuten salasanan nollaus) ovat turvallisempia vahvistetulla osoitteella.\n\n${BRAND.name}`, }), "auth.password_reset": (v) => ({ subject: `Nollaa salasanasi – ${BRAND.name}`, text: `Hei!\n\nOsoitteelle ${v.email} on pyydetty salasanan nollausta.\n\n` + `Avaa linkki sovelluksessa 30 minuutin kuluessa:\n${v.link}\n\n` + `Jos et pyytänyt tätä, voit ohittaa viestin – salasana ei muutu.\n\n${BRAND.name}`, }), }, nl: { "auth.verify_email": (v) => ({ subject: `Bevestig je e-mailadres – ${BRAND.name}`, text: `Welkom bij ${BRAND.name}!\n\n` + `Bevestig dat ${v.email} jouw adres is door deze link in de app te openen:\n${v.link}\n\n` + `De link is 24 uur geldig. De app werkt ook zonder bevestiging, maar sommige functies (zoals wachtwoordherstel) zijn veiliger met een bevestigd adres.\n\n${BRAND.name}`, }), "auth.password_reset": (v) => ({ subject: `Stel je wachtwoord opnieuw in – ${BRAND.name}`, text: `Hoi!\n\nEr is een wachtwoordherstel aangevraagd voor ${v.email}.\n\n` + `Open deze link binnen 30 minuten in de app:\n${v.link}\n\n` + `Als jij dit niet was, kun je deze e-mail negeren – het wachtwoord verandert niet.\n\n${BRAND.name}`, }), }, pl: { "auth.verify_email": (v) => ({ subject: `Potwierdź swój adres e-mail – ${BRAND.name}`, text: `Witamy w ${BRAND.name}!\n\n` + `Potwierdź, że ${v.email} to Twój adres, otwierając link w aplikacji:\n${v.link}\n\n` + `Link jest ważny 24 godziny. Aplikacja działa bez potwierdzenia, ale niektóre funkcje (np. resetowanie hasła) są bezpieczniejsze ze zweryfikowanym adresem.\n\n${BRAND.name}`, }), "auth.password_reset": (v) => ({ subject: `Zresetuj swoje hasło – ${BRAND.name}`, text: `Cześć!\n\nPoproszono o zresetowanie hasła dla ${v.email}.\n\n` + `Otwórz link w aplikacji w ciągu 30 minut:\n${v.link}\n\n` + `Jeśli to nie Ty, zignoruj tę wiadomość – hasło się nie zmieni.\n\n${BRAND.name}`, }), }, pt: { "auth.verify_email": (v) => ({ subject: `Confirme o seu e-mail – ${BRAND.name}`, text: `Bem-vindo ao ${BRAND.name}!\n\n` + `Confirme que ${v.email} é o seu endereço abrindo este link na app:\n${v.link}\n\n` + `O link é válido por 24 horas. A app funciona sem confirmação, mas algumas funções (como repor a palavra-passe) são mais seguras com o endereço verificado.\n\n${BRAND.name}`, }), "auth.password_reset": (v) => ({ subject: `Reponha a sua palavra-passe – ${BRAND.name}`, text: `Olá!\n\nFoi pedida a reposição da palavra-passe de ${v.email}.\n\n` + `Abra este link na app no prazo de 30 minutos:\n${v.link}\n\n` + `Se não fez este pedido, ignore este e-mail – a palavra-passe não será alterada.\n\n${BRAND.name}`, }), }, }; export function renderMail( templateKey: string, languageTag: string, vars: Record, ): { subject: string; text: string } { const lang = languageTag.split("-")[0] ?? "sv"; const template = TEMPLATES[lang]?.[templateKey] ?? TEMPLATES.sv?.[templateKey]; if (!template) throw new Error(`Okänd mejlmall: ${templateKey}`); return template(vars); }