Initial commit (unpacked platform)

This commit is contained in:
Sven (AAMOS AI)
2026-08-05 19:21:11 +07:00
commit ac5340195a
314 changed files with 57584 additions and 0 deletions
+310
View File
@@ -0,0 +1,310 @@
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);
}