diff --git a/web-next/lib/change-email.ts b/web-next/lib/change-email.ts
index dfc45ad..acd5043 100644
--- a/web-next/lib/change-email.ts
+++ b/web-next/lib/change-email.ts
@@ -5,6 +5,7 @@ import { authenticate } from './auth'
import { bnetMakeRegistration, normalizeEmail } from './bnet'
import { checkSecurityToken } from './security-token'
import { sendMail } from './mail'
+import { confirmNewEmailHtml, confirmOldEmailHtml } from './emails'
import type { SessionData } from './session'
const GMAIL_RE = /^[a-zA-Z0-9._%+-]+@gmail\.com$/
@@ -14,12 +15,6 @@ export interface Result {
error?: string
}
-function shell(title: string, body: string): string {
- return `
-
-
Nova WoW
${title}
${body}`
-}
-
export async function requestEmailChange(
session: SessionData,
curPassword: string,
@@ -49,14 +44,14 @@ export async function requestEmailChange(
await sendMail(
current,
`Confirma el cambio de correo - Nova WoW`,
- shell('Confirma el cambio de correo', `Has solicitado cambiar tu correo a ${newEmail}. Confirma desde tu correo actual:
Confirmar
${link}
`),
+ confirmOldEmailHtml(session.username ?? current, newEmail, link),
)
return { success: true }
}
export async function confirmOldEmail(hash: string): Promise {
const [rows] = await db(DB.default).query(
- 'SELECT id, email, hash FROM home_accountactivation WHERE old_email_hash = ? AND is_used = 0',
+ 'SELECT id, email, hash, username FROM home_accountactivation WHERE old_email_hash = ? AND is_used = 0',
[hash],
)
const act = rows[0]
@@ -68,7 +63,7 @@ export async function confirmOldEmail(hash: string): Promise {
await sendMail(
act.email,
`Confirma tu nuevo correo - Nova WoW`,
- shell('Confirma tu nuevo correo', `Confirma este correo como el nuevo de tu cuenta:
Confirmar nuevo correo
${link}
`),
+ confirmNewEmailHtml(act.username ?? act.email, link),
)
return { success: true }
}
diff --git a/web-next/lib/emails.ts b/web-next/lib/emails.ts
new file mode 100644
index 0000000..4d4fdaf
--- /dev/null
+++ b/web-next/lib/emails.ts
@@ -0,0 +1,352 @@
+/**
+ * Correos con el diseño del tema — portado de `home/templates/emails/`.
+ *
+ * De las 7 plantillas de Django solo `security_token` y `activation` traían el
+ * diseño; las otras 5 eran HTML soso, de ahí que aquí TODAS usen la misma
+ * maqueta: la de `security_token`.
+ *
+ * La plantilla de Django era un volcado del DOM de Gmail. Al portarla se quitó
+ * lo que no era diseño: los enlaces iban envueltos en el rastreador de Mailjet
+ * (`079xk.mjt.lu`), que además redirigía a **ultimowow.com** en vez de a
+ * nosotros; había un píxel de apertura, un "Click on me" oculto y atributos que
+ * mete Gmail al mostrar el correo. Los 9 enlaces reales se reapuntan aquí.
+ *
+ * Ninguna URL va a fuego: todas cuelgan de SITE_URL (la variable que ya usa el
+ * resto del proyecto), así que cambiar de dominio es tocar el .env. Ojo: en un
+ * correo los enlaces han de ser ABSOLUTOS — se abre desde Gmail, no desde
+ * nuestro dominio, así que "/ruta" no valdría.
+ *
+ * El contenido es el nuestro, no el de Django: el `activation` de allí mandaba
+ * la contraseña en texto plano y aquí no se manda (además, con Battle.net se
+ * entra con el correo, no con un usuario).
+ */
+
+const SERVER_NAME = 'NovaWoW'
+
+/** Raíz del sitio, sin barra final. De aquí salen todos los enlaces e imágenes. */
+function siteUrl(): string {
+ return (process.env.SITE_URL || 'https://nightspire.gg').replace(/\/+$/, '')
+}
+
+/** Los correos son HTML crudo: hay que escapar todo lo que venga de fuera. */
+export function esc(s: string): string {
+ return String(s)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+}
+
+// Estilos calcados del diseño original.
+const P_MAIN = "Margin:0;font-size:14px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji','Segoe UI Symbol';line-height:21px;color:#ebdec2"
+// Igual que el principal pero en el tono apagado: es lo que hace el diseño.
+const P_DATA = P_MAIN.replace('color:#ebdec2', 'color:#b1997f')
+const BTN = "text-decoration:none;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji','Segoe UI Symbol';font-size:16px;color:#ebdec2;border-style:solid;border-color:#211917;border-width:10px 20px 10px 20px;display:inline-block;background:#211917;border-radius:0px;font-weight:normal;font-style:normal;line-height:19px;width:auto;text-align:center"
+const H1 = "Margin:0;line-height:45px;font-family:georgia,times,'times new roman',serif;font-size:30px;font-style:normal;font-weight:normal;color:#b17c02"
+const TD = 'padding:0;Margin:0'
+const TD_LAST = 'padding:0;Margin:0;padding-top:5px;padding-bottom:20px'
+
+/** Párrafo normal (crema). El HTML que se le pase ya debe ir escapado. */
+function p(html: string, td: string = TD): string {
+ return `${html} |
`
+}
+
+/** Párrafo de datos (cuenta, token…), en el tono apagado del diseño. */
+function pData(html: string): string {
+ return `${html} |
`
+}
+
+/** La línea separadora. */
+function divider(): string {
+ return ` |
`
+}
+
+/** El botón del diseño (el mismo que traía `activation`). */
+function button(href: string, text: string): string {
+ return `| ${text} |
`
+}
+
+/**
+ * Enlace de reserva: si el botón no tira, el usuario copia y pega. Lo hacía el
+ * `activation` original y lo hacen ya nuestros correos.
+ */
+function fallbackLink(link: string): string {
+ return p(
+ `Si el botón no funciona, copia y pega este enlace en tu navegador:
${esc(link)}`,
+ )
+}
+
+/** Cierre común a todas las plantillas. */
+function signature(): string {
+ return p(`
Atentamente, el Equipo de ${SERVER_NAME}.`, TD_LAST)
+}
+
+/**
+ * La maqueta: cabecera + + filas de contenido + pie.
+ * @param label rótulo superior en mayúsculas (p.ej. TOKEN DE SEGURIDAD)
+ * @param title el grande
+ * @param rows filas hechas con p()/pData()/divider()/button()
+ */
+function layout(label: string, title: string, rows: string[], locale = 'es'): string {
+ const site = siteUrl()
+ const year = new Date().getFullYear()
+ const head = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+  |
+
+ |
+
+ |
+
+
+ |
+ |
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SÍGUENOS EN |
+
+
+ |
+ |
+
+ |
+
+ |
+
+
+ |
+ |
+
+ |
+
+ |
+
+
+
+
+
+
+
+
`
+ return head + `` + esc(title) + '
' + rows.join('') + foot
+}
+
+// ─── Las 7 plantillas ────────────────────────────────────────────────────────
+
+/** `activation.html` — alta de cuenta. */
+export function activationEmailHtml(email: string, link: string, locale = 'es'): string {
+ return layout('ACTIVACIÓN DE CUENTA', 'Bienvenido', [
+ p(
+ `Gracias por crear una cuenta en ${SERVER_NAME}.
Para empezar a usarla, actívala pulsando el botón ACTIVAR LA CUENTA.
Tus datos de cuenta son:`,
+ ),
+ pData(`Cuenta: ${esc(email)}
`),
+ p(
+ 'Recuerda que se entra tanto a la web como al servidor con tu correo y tu contraseña.
Puedes usar el mismo correo para registrar hasta 10 cuentas de juego.',
+ ),
+ button(link, 'ACTIVAR LA CUENTA'),
+ fallbackLink(link),
+ divider(),
+ signature(),
+ ], locale)
+}
+
+/** `security_token.html` — el diseño de referencia. */
+export function securityTokenEmailHtml(username: string, token: string, ip: string, locale = 'es'): string {
+ return layout('TOKEN DE SEGURIDAD', 'Token de Seguridad', [
+ p(
+ `Alguien desde la IP ${esc(ip)} ha solicitado el Token de seguridad para la cuenta ${esc(username)}.
Si no has sido tú, te recomendamos cambiar la contraseña de tu cuenta.
Tus datos de cuenta son:`,
+ ),
+ pData(`Cuenta: ${esc(username)}
Token de Seguridad: ${esc(token)}
`),
+ p(
+ 'Recuerda que este Token es sensible a mayúsculas y minúsculas.
Al usarlo, escríbelo exactamente de la misma forma que aparece aquí.',
+ ),
+ divider(),
+ p(`¡No compartas el Token de Seguridad con nadie!
Atentamente, el Equipo de ${SERVER_NAME}.`, TD_LAST),
+ ], locale)
+}
+
+/** `password_reset.html` — restablecer contraseña. */
+export function passwordResetEmailHtml(email: string, link: string, locale = 'es'): string {
+ return layout('RESTABLECER CONTRASEÑA', 'Restablecer Contraseña', [
+ p(
+ `Hemos recibido una solicitud para restablecer la contraseña de la cuenta asociada a ${esc(email)}.
Pulsa el botón para elegir una nueva. El enlace caduca en 1 hora.`,
+ ),
+ button(link, 'RESTABLECER CONTRASEÑA'),
+ fallbackLink(link),
+ divider(),
+ p('Si no has solicitado este cambio, ignora este correo: tu contraseña no se modificará.', TD_LAST),
+ ], locale)
+}
+
+/** `account_names.html` — recordatorio de cuentas de juego. */
+export function accountNamesEmailHtml(email: string, accounts: string[], locale = 'es'): string {
+ const list = accounts.length
+ ? pData(accounts.map((a) => `Cuenta: ${esc(a)}`).join('
') + '
')
+ : pData('No hay cuentas de juego asociadas todavía.
')
+ return layout('CUENTAS DE JUEGO', 'Tus Cuentas de Juego', [
+ p(
+ `Estas son las cuentas de juego asociadas a tu cuenta Battle.net (${esc(email)}).
Recuerda que para entrar a la web usas tu correo electrónico.
Tus datos de cuenta son:`,
+ ),
+ list,
+ divider(),
+ p('Si no has solicitado esta información, ignora este correo.', TD_LAST),
+ ], locale)
+}
+
+/** `confirm_old_email.html` — se confirma desde el correo ACTUAL. */
+export function confirmOldEmailHtml(username: string, newEmail: string, link: string, locale = 'es'): string {
+ return layout('CAMBIO DE CORREO', 'Confirmación de Cambio de Correo', [
+ p(
+ `Hola ${esc(username)}, has solicitado cambiar tu correo en ${SERVER_NAME} a ${esc(newEmail)}.
Para confirmar el cambio, pulsa el botón.`,
+ ),
+ button(link, 'CONFIRMAR CAMBIO'),
+ fallbackLink(link),
+ divider(),
+ p('Si no has solicitado este cambio, ignora este correo.', TD_LAST),
+ ], locale)
+}
+
+/** `confirm_new_email.html` — se confirma desde el correo NUEVO. */
+export function confirmNewEmailHtml(username: string, link: string, locale = 'es'): string {
+ return layout('NUEVO CORREO', 'Activación de Nuevo Correo', [
+ p(
+ `Hola ${esc(username)}, tu solicitud para cambiar el correo en ${SERVER_NAME} ha sido recibida.
Pulsa el botón para confirmar este correo como el nuevo de tu cuenta.`,
+ ),
+ button(link, 'ACTIVAR NUEVO CORREO'),
+ fallbackLink(link),
+ divider(),
+ p('Si no has realizado esta solicitud, contacta con soporte.', TD_LAST),
+ ], locale)
+}
+
+/** `old_email_notification.html` — aviso al correo antiguo, ya sin enlaces. */
+export function oldEmailNotificationHtml(username: string, newEmail: string, locale = 'es'): string {
+ return layout('CAMBIO DE CORREO', 'Cambio de Correo Realizado', [
+ p(
+ `Hola ${esc(username)}, te avisamos de que el correo de tu cuenta en ${SERVER_NAME} se ha cambiado correctamente al siguiente:`,
+ ),
+ pData(`Nuevo correo: ${esc(newEmail)}
`),
+ divider(),
+ p('Si no has realizado este cambio, contacta con soporte inmediatamente.', TD_LAST),
+ ], locale)
+}
+
+/**
+ * Variante de `security_token` para el recordatorio de `/recover`: allí se
+ * mandan de golpe los tokens de todas las cuentas de juego del correo, cosa que
+ * la plantilla de Django (de una sola cuenta) no contemplaba.
+ */
+export function securityTokensListEmailHtml(
+ email: string,
+ items: { username: string; token: string }[],
+ locale = 'es',
+): string {
+ return layout(
+ 'TOKEN DE SEGURIDAD',
+ 'Token de Seguridad',
+ [
+ p(`Estos son los Token de seguridad de las cuentas de juego asociadas a ${esc(email)}.
Tus datos de cuenta son:`),
+ pData(
+ items.map((i) => `Cuenta: ${esc(i.username)}
Token de Seguridad: ${esc(i.token)}`).join('
') +
+ '
',
+ ),
+ p(
+ 'Recuerda que este Token es sensible a mayúsculas y minúsculas.
Al usarlo, escríbelo exactamente de la misma forma que aparece aquí.',
+ ),
+ divider(),
+ p(`¡No compartas el Token de Seguridad con nadie!
Atentamente, el Equipo de ${SERVER_NAME}.`, TD_LAST),
+ ],
+ locale,
+ )
+}
diff --git a/web-next/lib/mail.ts b/web-next/lib/mail.ts
index d79b480..b510cfb 100644
--- a/web-next/lib/mail.ts
+++ b/web-next/lib/mail.ts
@@ -1,6 +1,6 @@
import nodemailer from 'nodemailer'
-// Transporte SMTP (mismas credenciales que usaba Django: Gmail por defecto).
+// Transporte SMTP (mismas variables que Django; hoy: Proton Mail).
let transporter: nodemailer.Transporter | null = null
function getTransporter(): nodemailer.Transporter {
@@ -9,6 +9,9 @@ function getTransporter(): nodemailer.Transporter {
host: process.env.EMAIL_HOST,
port: Number(process.env.EMAIL_PORT || 587),
secure: process.env.EMAIL_USE_SSL === 'True', // true para 465, false para 587 (STARTTLS)
+ // Sin esto el STARTTLS del 587 es OPORTUNISTA: si el servidor no lo
+ // ofrece, nodemailer manda en claro (y ahí van tokens y contraseñas).
+ requireTLS: process.env.EMAIL_USE_TLS === 'True',
auth: {
user: process.env.EMAIL_HOST_USER,
pass: process.env.EMAIL_HOST_PASSWORD,
diff --git a/web-next/lib/recover.ts b/web-next/lib/recover.ts
index ecc5dbd..c67770d 100644
--- a/web-next/lib/recover.ts
+++ b/web-next/lib/recover.ts
@@ -3,6 +3,12 @@ import type { RowDataPacket } from 'mysql2'
import { db, DB } from './db'
import { normalizeEmail, bnetMakeRegistration } from './bnet'
import { sendMail } from './mail'
+import {
+ activationEmailHtml,
+ accountNamesEmailHtml,
+ passwordResetEmailHtml,
+ securityTokensListEmailHtml,
+} from './emails'
const GMAIL_RE = /^[a-zA-Z0-9._%+-]+@gmail\.com$/
@@ -12,13 +18,6 @@ export interface Result {
redirect?: string
}
-function emailShell(title: string, bodyHtml: string): string {
- return `
-
-
Nova WoW
${title}
${bodyHtml}
- `
-}
-
/**
* Recuperación por email/usuario (anti-enumeración: respuesta genérica siempre).
* `value` es el nombre de usuario (type=password) o el correo (resto).
@@ -53,12 +52,7 @@ export async function requestRecovery(type: string, value: string): PromisePulsa para elegir una nueva contraseña (válido 1 hora):
- Restablecer
- ${link}
`,
- ),
+ passwordResetEmailHtml(email, link),
)
}
}
@@ -92,16 +86,12 @@ export async function requestRecovery(type: string, value: string): Promise [g.id, g.username]))
- const list = toks
- .map((r) => `${nameById.get(r.user_id) ?? r.user_id}: ${r.token}`)
- .join('')
- await sendMail(
+ await sendMail(
email,
'Tu Token de seguridad - Nova WoW',
- emailShell(
- 'Tu Token de seguridad',
- `Token(s) de seguridad de las cuentas asociadas a ${email}:
- Consérvalo en un lugar seguro y no lo compartas.
`,
+ securityTokensListEmailHtml(
+ email,
+ toks.map((r) => ({ username: String(nameById.get(r.user_id) ?? r.user_id), token: r.token })),
),
)
}
@@ -124,11 +114,13 @@ export async function requestRecovery(type: string, value: string): Promise `${g.username}`).join('') || '—'
await sendMail(
email,
'Tus cuentas de juego - Nova WoW',
- emailShell('Tus cuentas de juego', `Cuentas asociadas a ${email}:
`),
+ accountNamesEmailHtml(
+ email,
+ games.map((g) => String(g.username)),
+ ),
)
}
} catch {
@@ -148,7 +140,7 @@ export async function requestRecovery(type: string, value: string): PromiseActivar cuenta${link}
`),
+ activationEmailHtml(email, link),
)
}
} catch {
diff --git a/web-next/lib/register.ts b/web-next/lib/register.ts
index cdcf258..75cbf35 100644
--- a/web-next/lib/register.ts
+++ b/web-next/lib/register.ts
@@ -3,6 +3,7 @@ import type { ResultSetHeader, RowDataPacket } from 'mysql2'
import { db, DB } from './db'
import { normalizeEmail, bnetMakeRegistration, gameMakeRegistration, makeGameAccountUsername } from './bnet'
import { sendMail } from './mail'
+import { activationEmailHtml } from './emails'
const GMAIL_RE = /^[a-zA-Z0-9._%+-]+@gmail\.com$/
@@ -19,17 +20,6 @@ export interface Result {
error?: string
}
-function activationEmailHtml(email: string, link: string): string {
- return `
-
-
Nova WoW
-
Activa tu cuenta
-
La cuenta ${email} se ha creado. Pulsa para activarla (enlace válido 1 hora):
-
Activar cuenta
-
${link}
-
`
-}
-
export async function registerAccount(input: RegisterInput): Promise {
const password = (input.password || '').trim()
const confPassword = (input.confPassword || '').trim()
diff --git a/web-next/lib/security-token.ts b/web-next/lib/security-token.ts
index c75b9ca..7dc7cfd 100644
--- a/web-next/lib/security-token.ts
+++ b/web-next/lib/security-token.ts
@@ -2,6 +2,7 @@ import crypto from 'node:crypto'
import type { RowDataPacket } from 'mysql2'
import { db, DB } from './db'
import { sendMail } from './mail'
+import { securityTokenEmailHtml } from './emails'
import type { SessionData } from './session'
export interface Result {
@@ -34,17 +35,8 @@ export async function requestSecurityToken(session: SessionData, ip: string): Pr
[token, expiresAt, ip || '0.0.0.0', userId],
)
- await sendMail(
- email,
- 'Token de seguridad - Nova WoW',
- `
-
-
Nova WoW
Token de seguridad
-
Tu token de seguridad es:
-
${token}
-
Consérvalo en un lugar seguro. Se solicitará para acciones importantes de la cuenta.
-
`,
- )
+ // La cuenta es el correo: con Battle.net se entra con él, no con un usuario.
+ await sendMail(email, 'Token de seguridad - Nova WoW', securityTokenEmailHtml(email, token, ip || '0.0.0.0'))
const [created] = await db(DB.default).query(
'SELECT created_at FROM home_securitytoken WHERE user_id = ? ORDER BY created_at DESC LIMIT 1',
diff --git a/web-next/public/nw-themes/nw-ryu/nw-images/nw-mails/nw-mail-logo.png b/web-next/public/nw-themes/nw-ryu/nw-images/nw-mails/nw-mail-logo.png
new file mode 100644
index 0000000..046ffdc
Binary files /dev/null and b/web-next/public/nw-themes/nw-ryu/nw-images/nw-mails/nw-mail-logo.png differ