Files
Cibello-app/apps/api/src/lib/mailer.ts
T
2026-08-05 19:21:11 +07:00

311 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void>;
}
class LogMailer implements Mailer {
async send(message: MailMessage): Promise<void> {
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<void> {
await this.transporter.verify();
}
async send(message: MailMessage): Promise<void> {
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<string, string>) => { subject: string; text: string };
const TEMPLATES: Record<string, Record<string, MailTemplate>> = {
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<string, string>,
): { 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);
}