Correos: portar el diseño del tema y unificarlos en lib/emails.ts

Los correos usaban un shell inline distinto en cada remitente. Ahora salen
todos de una sola maqueta, la de security_token, portada de las plantillas de
Django. De las 7 de Django solo security_token y activation traían el diseño;
las otras 5 eran HTML soso.

Las plantillas de Django eran un volcado del DOM de Gmail. Al portarlas 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. Las imágenes apuntaban al proxy de Gmail
(ci3.googleusercontent.com), no a nuestro /static/, así que el diseño se
rompía el día que Google dejara de servirlas.

Ninguna URL va a fuego: todas cuelgan de SITE_URL, así que cambiar de dominio
es tocar el .env. En un correo han de ser absolutas (se abre desde Gmail), así
que se construyen a partir de esa variable.

El logo nw-mail-logo.png daba 404: solo existía el .webp del tema, que es el de
UltimoWoW (león negro + letras "UW"). El auténtico de NovaWoW, un león de acero
sin letras, sobrevivía únicamente en la caché de Gmail y se recuperó de ahí
(600x320 RGBA, íntegro).

Verificado renderizando el original y el nuestro en un navegador y comparando:
cuadran pixel a pixel. Eso destapó dos fallos que se corrigen aquí: faltaba la
etiqueta <h1> de apertura, y el estilo de los párrafos de datos era el de la
letra pequeña del pie (11px/17px) en vez del real (14px/21px).

El activation de Django mandaba la contraseña en texto plano; aquí no se manda.

mail.ts: leía EMAIL_USE_SSL pero ignoraba EMAIL_USE_TLS, así que en el puerto
587 el STARTTLS era oportunista y, si el servidor no lo ofrecía, nodemailer
enviaba en claro. Ahora requireTLS lo hace obligatorio.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 14:33:32 +00:00
parent 57dd48e308
commit 9390eefa93
7 changed files with 380 additions and 56 deletions
+16 -24
View File
@@ -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 `<!DOCTYPE html><html lang="es"><body style="font-family:Arial,sans-serif;background:#1b120b;color:#e8dccb;padding:24px">
<div style="max-width:600px;margin:0 auto;background:#241812;border:1px solid #4a3320;border-radius:8px;padding:28px">
<h1 style="color:#d79602;margin:0 0 8px">Nova WoW</h1><h2 style="margin:0 0 16px">${title}</h2>${bodyHtml}
</div></body></html>`
}
/**
* 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): Promise<Resu
await sendMail(
email,
'Restablecer contraseña - Nova WoW',
emailShell(
'Restablecer contraseña',
`<p>Pulsa para elegir una nueva contraseña (válido 1 hora):</p>
<p><a href="${link}" style="display:inline-block;padding:12px 22px;background:#d79602;color:#1b120b;text-decoration:none;border-radius:6px;font-weight:bold">Restablecer</a></p>
<p style="font-size:13px;word-break:break-all"><a href="${link}" style="color:#2471a9">${link}</a></p>`,
),
passwordResetEmailHtml(email, link),
)
}
}
@@ -92,16 +86,12 @@ export async function requestRecovery(type: string, value: string): Promise<Resu
)
if (toks.length) {
const nameById = new Map(games.map((g) => [g.id, g.username]))
const list = toks
.map((r) => `<li><strong>${nameById.get(r.user_id) ?? r.user_id}</strong>: <code>${r.token}</code></li>`)
.join('')
await sendMail(
await sendMail(
email,
'Tu Token de seguridad - Nova WoW',
emailShell(
'Tu Token de seguridad',
`<p>Token(s) de seguridad de las cuentas asociadas a <strong>${email}</strong>:</p><ul>${list}</ul>
<p style="font-size:13px;color:#b1997f">Consérvalo en un lugar seguro y no lo compartas.</p>`,
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<Resu
'SELECT username FROM account WHERE battlenet_account = ? ORDER BY battlenet_index',
[acc[0].id],
)
const list = games.map((g) => `<li>${g.username}</li>`).join('') || '<li>—</li>'
await sendMail(
email,
'Tus cuentas de juego - Nova WoW',
emailShell('Tus cuentas de juego', `<p>Cuentas asociadas a <strong>${email}</strong>:</p><ul>${list}</ul>`),
accountNamesEmailHtml(
email,
games.map((g) => String(g.username)),
),
)
}
} catch {
@@ -148,7 +140,7 @@ export async function requestRecovery(type: string, value: string): Promise<Resu
await sendMail(
email,
`Activación de la cuenta ${email} - Nova WoW`,
emailShell('Activa tu cuenta', `<p><a href="${link}" style="color:#d79602">Activar cuenta</a></p><p style="font-size:13px;word-break:break-all">${link}</p>`),
activationEmailHtml(email, link),
)
}
} catch {