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:
@@ -5,6 +5,7 @@ import { authenticate } from './auth'
|
|||||||
import { bnetMakeRegistration, normalizeEmail } from './bnet'
|
import { bnetMakeRegistration, normalizeEmail } from './bnet'
|
||||||
import { checkSecurityToken } from './security-token'
|
import { checkSecurityToken } from './security-token'
|
||||||
import { sendMail } from './mail'
|
import { sendMail } from './mail'
|
||||||
|
import { confirmNewEmailHtml, confirmOldEmailHtml } from './emails'
|
||||||
import type { SessionData } from './session'
|
import type { SessionData } from './session'
|
||||||
|
|
||||||
const GMAIL_RE = /^[a-zA-Z0-9._%+-]+@gmail\.com$/
|
const GMAIL_RE = /^[a-zA-Z0-9._%+-]+@gmail\.com$/
|
||||||
@@ -14,12 +15,6 @@ export interface Result {
|
|||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function shell(title: string, body: 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">Nova WoW</h1><h2>${title}</h2>${body}</div></body></html>`
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function requestEmailChange(
|
export async function requestEmailChange(
|
||||||
session: SessionData,
|
session: SessionData,
|
||||||
curPassword: string,
|
curPassword: string,
|
||||||
@@ -49,14 +44,14 @@ export async function requestEmailChange(
|
|||||||
await sendMail(
|
await sendMail(
|
||||||
current,
|
current,
|
||||||
`Confirma el cambio de correo - Nova WoW`,
|
`Confirma el cambio de correo - Nova WoW`,
|
||||||
shell('Confirma el cambio de correo', `<p>Has solicitado cambiar tu correo a <strong>${newEmail}</strong>. Confirma desde tu correo actual:</p><p><a href="${link}" style="color:#d79602">Confirmar</a></p><p style="font-size:13px">${link}</p>`),
|
confirmOldEmailHtml(session.username ?? current, newEmail, link),
|
||||||
)
|
)
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function confirmOldEmail(hash: string): Promise<Result> {
|
export async function confirmOldEmail(hash: string): Promise<Result> {
|
||||||
const [rows] = await db(DB.default).query<RowDataPacket[]>(
|
const [rows] = await db(DB.default).query<RowDataPacket[]>(
|
||||||
'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],
|
[hash],
|
||||||
)
|
)
|
||||||
const act = rows[0]
|
const act = rows[0]
|
||||||
@@ -68,7 +63,7 @@ export async function confirmOldEmail(hash: string): Promise<Result> {
|
|||||||
await sendMail(
|
await sendMail(
|
||||||
act.email,
|
act.email,
|
||||||
`Confirma tu nuevo correo - Nova WoW`,
|
`Confirma tu nuevo correo - Nova WoW`,
|
||||||
shell('Confirma tu nuevo correo', `<p>Confirma este correo como el nuevo de tu cuenta:</p><p><a href="${link}" style="color:#d79602">Confirmar nuevo correo</a></p><p style="font-size:13px">${link}</p>`),
|
confirmNewEmailHtml(act.username ?? act.email, link),
|
||||||
)
|
)
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, '>')
|
||||||
|
.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 `<tr style="border-collapse:collapse"><td align="left" style="${td}"><p style="${P_MAIN}">${html}</p></td></tr>`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Párrafo de datos (cuenta, token…), en el tono apagado del diseño. */
|
||||||
|
function pData(html: string): string {
|
||||||
|
return `<tr style="border-collapse:collapse"><td align="left" style="${TD}"><p style="${P_DATA}">${html}</p></td></tr>`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** La línea separadora. */
|
||||||
|
function divider(): string {
|
||||||
|
return `<tr style="border-collapse:collapse"><td align="center" style="padding:20px;Margin:0;font-size:0"><table border="0" width="100%" height="100%" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px"><tbody><tr style="border-collapse:collapse"><td style="padding:0;Margin:0;border-bottom:2px solid #5c4c44;background:none;height:1px;width:100%;margin:0px"></td></tr></tbody></table></td></tr>`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** El botón del diseño (el mismo que traía `activation`). */
|
||||||
|
function button(href: string, text: string): string {
|
||||||
|
return `<tr style="border-collapse:collapse"><td align="center" style="padding:0;Margin:0;padding-top:10px;padding-bottom:20px"><span style="border-style:solid;border-color:#211917;background:#211917;border-width:0px;display:inline-block;border-radius:0px;width:auto"><a href="${href}" target="_blank" style="${BTN}">${text}</a></span></td></tr>`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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:<br><span style="word-break:break-all;color:#2471a9">${esc(link)}</span>`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cierre común a todas las plantillas. */
|
||||||
|
function signature(): string {
|
||||||
|
return p(`<br><br>Atentamente, el Equipo de ${SERVER_NAME}.`, TD_LAST)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La maqueta: cabecera + <h1> + filas de contenido + pie.
|
||||||
|
* @param label rótulo superior en mayúsculas (p.ej. TOKEN DE SEGURIDAD)
|
||||||
|
* @param title el <h1> 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 = `<div style="width:100%;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji','Segoe UI Symbol';padding:0;Margin:0">
|
||||||
|
<div style="background-color:#0f0f0f">
|
||||||
|
|
||||||
|
<table width="100%" cellspacing="0" cellpadding="0" style="border-collapse:collapse;border-spacing:0px;padding:0;Margin:0;width:100%;height:100%;background-repeat:repeat;background-position:center top">
|
||||||
|
<tbody><tr style="border-collapse:collapse">
|
||||||
|
<td valign="top" style="padding:0;Margin:0">
|
||||||
|
<table cellpadding="0" cellspacing="0" align="center" style="border-collapse:collapse;border-spacing:0px;table-layout:fixed!important;width:100%;background-color:transparent;background-repeat:repeat;background-position:center top">
|
||||||
|
<tbody><tr style="border-collapse:collapse">
|
||||||
|
<td align="center" style="padding:0;Margin:0">
|
||||||
|
<table cellspacing="0" cellpadding="0" align="center" style="border-collapse:collapse;border-spacing:0px;background-color:transparent;width:600px">
|
||||||
|
<tbody><tr style="border-collapse:collapse">
|
||||||
|
<td align="left" background="${site}/nw-themes/nw-ryu/nw-images/nw-mails/nw-mail-footer.jpg" style="Margin:0;padding-top:10px;padding-bottom:10px;padding-left:10px;padding-right:10px;background-image:url(${site}/nw-themes/nw-ryu/nw-images/nw-mails/nw-mail-footer.jpg);background-repeat:no-repeat;background-position:left top">
|
||||||
|
|
||||||
|
<table cellspacing="0" cellpadding="0" align="left" style="border-collapse:collapse;border-spacing:0px;float:left">
|
||||||
|
<tbody><tr style="border-collapse:collapse">
|
||||||
|
<td align="left" style="padding:0;Margin:0;width:280px">
|
||||||
|
<table width="100%" cellspacing="0" cellpadding="0" role="presentation" style="border-collapse:collapse;border-spacing:0px">
|
||||||
|
<tbody><tr style="border-collapse:collapse">
|
||||||
|
<td align="left" id="title-mail-header" style="padding:0;Margin:0"><p style="Margin:0;font-size:11px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji','Segoe UI Symbol';line-height:17px;color:#b1997f">${label}</p></td>
|
||||||
|
</tr>
|
||||||
|
</tbody></table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody></table>
|
||||||
|
|
||||||
|
<table cellspacing="0" cellpadding="0" align="right" style="border-collapse:collapse;border-spacing:0px;float:right">
|
||||||
|
<tbody><tr style="border-collapse:collapse">
|
||||||
|
<td align="left" style="padding:0;Margin:0">
|
||||||
|
<table width="100%" cellspacing="0" cellpadding="0" role="presentation" style="border-collapse:collapse;border-spacing:0px">
|
||||||
|
<tbody><tr style="border-collapse:collapse">
|
||||||
|
<td style="padding:0;Margin:0">
|
||||||
|
<table width="100%" cellspacing="0" cellpadding="0" role="presentation" style="border-collapse:collapse;border-spacing:0px">
|
||||||
|
<tbody><tr id="header-mail-links" style="border-collapse:collapse">
|
||||||
|
<td style="Margin:0;padding-left:5px;padding-right:5px;padding-top:3px;padding-bottom:0px;border:0" width="50%" bgcolor="transparent" align="center"><a style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji','Segoe UI Symbol';font-size:14px;text-decoration:none;display:block;color:#2471a9" href="${site}/${locale}/" target="_blank">Ir al sitio</a></td>
|
||||||
|
</tr>
|
||||||
|
</tbody></table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody></table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody></table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody></table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody></table>
|
||||||
|
<table cellspacing="0" cellpadding="0" align="center" style="border-collapse:collapse;border-spacing:0px;table-layout:fixed!important;width:100%">
|
||||||
|
<tbody><tr style="border-collapse:collapse">
|
||||||
|
<td align="center" style="padding:0;Margin:0">
|
||||||
|
<table style="border-collapse:collapse;border-spacing:0px;background-color:transparent;width:600px" cellspacing="0" cellpadding="0" align="center">
|
||||||
|
<tbody><tr style="border-collapse:collapse">
|
||||||
|
<td align="left" style="padding:0;Margin:0">
|
||||||
|
<table cellpadding="0" cellspacing="0" width="100%" style="border-collapse:collapse;border-spacing:0px">
|
||||||
|
<tbody><tr style="border-collapse:collapse">
|
||||||
|
<td align="center" valign="top" style="padding:0;Margin:0;width:600px">
|
||||||
|
<table cellpadding="0" cellspacing="0" width="100%" role="presentation" style="border-collapse:collapse;border-spacing:0px">
|
||||||
|
<tbody><tr style="border-collapse:collapse">
|
||||||
|
<td align="center" style="padding:0;Margin:0;font-size:0px"><a href="${site}/${locale}/" style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji','Segoe UI Symbol';font-size:14px;text-decoration:underline;color:#b58a60" target="_blank"><img src="${site}/nw-themes/nw-ryu/nw-images/nw-mails/nw-mail-logo.png" alt="" style="display:block;border:0;outline:none;text-decoration:none" width="600" class="CToWUd" data-bit="iit"></a></td>
|
||||||
|
</tr>
|
||||||
|
</tbody></table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody></table></td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-collapse:collapse">
|
||||||
|
<td align="left" background="${site}/nw-themes/nw-ryu/nw-images/nw-mails/nw-mail-body.jpg" style="Margin:0;padding-top:40px;padding-bottom:40px;padding-left:40px;padding-right:40px;background-image:url(${site}/nw-themes/nw-ryu/nw-images/nw-mails/nw-mail-body.jpg);background-repeat:repeat;background-position:left top">
|
||||||
|
<table width="100%" cellspacing="0" cellpadding="0" style="border-collapse:collapse;border-spacing:0px">
|
||||||
|
<tbody><tr style="border-collapse:collapse">
|
||||||
|
<td valign="top" align="center" style="padding:0;Margin:0;width:520px">
|
||||||
|
<table width="100%" cellspacing="0" cellpadding="0" role="presentation" style="border-collapse:collapse;border-spacing:0px">
|
||||||
|
<tbody><tr style="border-collapse:collapse">
|
||||||
|
<td align="left" style="padding:0;Margin:0">`
|
||||||
|
const foot = `
|
||||||
|
</tbody></table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody></table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody></table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody></table>
|
||||||
|
<table cellpadding="0" cellspacing="0" align="center" style="border-collapse:collapse;border-spacing:0px;table-layout:fixed!important;width:100%;background-color:transparent;background-repeat:repeat;background-position:center top">
|
||||||
|
<tbody><tr style="border-collapse:collapse">
|
||||||
|
<td align="center" style="padding:0;Margin:0">
|
||||||
|
<table cellspacing="0" cellpadding="0" align="center" style="background-color:transparent;border-collapse:collapse;border-spacing:0px;width:600px">
|
||||||
|
<tbody><tr style="border-collapse:collapse">
|
||||||
|
<td align="left" style="padding:0;Margin:0">
|
||||||
|
<table width="100%" cellspacing="0" cellpadding="0" style="border-collapse:collapse;border-spacing:0px">
|
||||||
|
<tbody><tr style="border-collapse:collapse">
|
||||||
|
<td valign="top" align="center" style="padding:0;Margin:0;width:600px">
|
||||||
|
<table width="100%" cellspacing="0" cellpadding="0" background="${site}/nw-themes/nw-ryu/nw-images/nw-mails/nw-mail-footer.jpg" style="border-collapse:collapse;border-spacing:0px;background-image:url(${site}/nw-themes/nw-ryu/nw-images/nw-mails/nw-mail-footer.jpg);background-repeat:no-repeat;background-position:left top" role="presentation">
|
||||||
|
<tbody><tr style="border-collapse:collapse">
|
||||||
|
<td align="center" style="padding:0;Margin:0;padding-top:10px"><p style="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:#ffffff">SÍGUENOS EN</p></td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-collapse:collapse">
|
||||||
|
<td align="center" style="padding:0;Margin:0;padding-top:15px;padding-bottom:15px;font-size:0">
|
||||||
|
<table cellspacing="0" cellpadding="0" role="presentation" style="border-collapse:collapse;border-spacing:0px">
|
||||||
|
<tbody><tr style="border-collapse:collapse">
|
||||||
|
<td valign="top" align="center" style="padding:0;Margin:0;padding-right:20px"><a href="https://www.facebook.com/NovaWoW/" style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji','Segoe UI Symbol';font-size:14px;text-decoration:underline;color:#333333" target="_blank"><img title="Facebook" src="${site}/nw-themes/nw-ryu/nw-images/nw-mails/facebook-logo-gray.png" alt="Fb" width="32" height="32" style="display:block;border:0;outline:none;text-decoration:none" class="CToWUd" data-bit="iit"></a></td>
|
||||||
|
<td valign="top" align="center" style="padding:0;Margin:0;padding-right:20px"><a href="https://www.instagram.com/NovaWoW/" style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji','Segoe UI Symbol';font-size:14px;text-decoration:underline;color:#333333" target="_blank"><img title="Instagram" src="${site}/nw-themes/nw-ryu/nw-images/nw-mails/instagram-logo-gray.png" alt="Ig" width="32" height="32" style="display:block;border:0;outline:none;text-decoration:none" class="CToWUd" data-bit="iit"></a></td>
|
||||||
|
<td valign="top" align="center" style="padding:0;Margin:0;padding-right:20px"><a href="https://twitter.com/NovaWoW" style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji','Segoe UI Symbol';font-size:14px;text-decoration:underline;color:#333333" target="_blank"><img title="Twitter" src="${site}/nw-themes/nw-ryu/nw-images/nw-mails/twitter-logo-gray.png" alt="Tw" width="32" height="32" style="display:block;border:0;outline:none;text-decoration:none" class="CToWUd" data-bit="iit"></a></td>
|
||||||
|
<td valign="top" align="center" style="padding:0;Margin:0"><a href="https://www.youtube.com/channel/UCaHL8BZcho8AkeM9wckQbyg" style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji','Segoe UI Symbol';font-size:14px;text-decoration:underline;color:#333333" target="_blank"><img title="Youtube" src="${site}/nw-themes/nw-ryu/nw-images/nw-mails/youtube-logo-gray.png" alt="Yt" width="32" height="32" style="display:block;border:0;outline:none;text-decoration:none" class="CToWUd" data-bit="iit"></a></td>
|
||||||
|
</tr>
|
||||||
|
</tbody></table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody></table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody></table></td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-collapse:collapse">
|
||||||
|
<td style="padding:0;Margin:0" align="left">
|
||||||
|
<table width="100%" cellspacing="0" cellpadding="0" style="border-collapse:collapse;border-spacing:0px">
|
||||||
|
<tbody><tr style="border-collapse:collapse">
|
||||||
|
<td valign="top" align="center" style="padding:0;Margin:0;width:600px">
|
||||||
|
<table width="100%" cellspacing="0" cellpadding="0" background="${site}/nw-themes/nw-ryu/nw-images/nw-mails/nw-mail-body.jpg" style="border-collapse:collapse;border-spacing:0px;background-image:url(${site}/nw-themes/nw-ryu/nw-images/nw-mails/nw-mail-body.jpg);background-repeat:no-repeat;background-position:left top" role="presentation">
|
||||||
|
<tbody><tr style="border-collapse:collapse">
|
||||||
|
<td align="center" style="padding:0;Margin:0;padding-top:10px;padding-bottom:15px"><p style="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:#5c4c44"><a href="${site}/${locale}/terms-and-conditions" style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji','Segoe UI Symbol';font-size:14px;text-decoration:none;color:#2471a9" target="_blank">Términos y Condiciones</a> | <a href="${site}/${locale}/refund-policy" style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji','Segoe UI Symbol';font-size:14px;text-decoration:none;color:#2471a9" target="_blank">Política de Reembolso</a> | <a href="${site}/${locale}/privacy-policy" style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji','Segoe UI Symbol';font-size:14px;text-decoration:none;color:#2471a9" target="_blank">Política de Privacidad</a><br>${SERVER_NAME} © ${year}</p></td>
|
||||||
|
</tr>
|
||||||
|
</tbody></table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody></table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody></table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody></table></td>
|
||||||
|
</tr>
|
||||||
|
</tbody></table>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
</div><div class="yj6qo"></div><div>
|
||||||
|
|
||||||
|
</div></div></div><div class="WhmR8e" data-hash="0"></div></div>`
|
||||||
|
return head + `<h1 style="${H1}">` + esc(title) + '</h1></td></tr>' + 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}.<br>Para empezar a usarla, actívala pulsando el botón ACTIVAR LA CUENTA.<br><br>Tus datos de cuenta son:`,
|
||||||
|
),
|
||||||
|
pData(`Cuenta: ${esc(email)}<br><br>`),
|
||||||
|
p(
|
||||||
|
'Recuerda que se entra tanto a la web como al servidor con tu correo y tu contraseña.<br>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)}.<br>Si no has sido tú, te recomendamos cambiar la contraseña de tu cuenta.<br><br>Tus datos de cuenta son:`,
|
||||||
|
),
|
||||||
|
pData(`Cuenta: ${esc(username)}<br>Token de Seguridad: ${esc(token)}<br><br>`),
|
||||||
|
p(
|
||||||
|
'Recuerda que este Token es sensible a mayúsculas y minúsculas.<br>Al usarlo, escríbelo exactamente de la misma forma que aparece aquí.',
|
||||||
|
),
|
||||||
|
divider(),
|
||||||
|
p(`¡No compartas el Token de Seguridad con nadie!<br><br><br>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)}.<br>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('<br>') + '<br><br>')
|
||||||
|
: pData('No hay cuentas de juego asociadas todavía.<br><br>')
|
||||||
|
return layout('CUENTAS DE JUEGO', 'Tus Cuentas de Juego', [
|
||||||
|
p(
|
||||||
|
`Estas son las cuentas de juego asociadas a tu cuenta Battle.net (${esc(email)}).<br>Recuerda que para entrar a la web usas tu correo electrónico.<br><br>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)}.<br>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.<br>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)}<br><br>`),
|
||||||
|
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)}.<br><br>Tus datos de cuenta son:`),
|
||||||
|
pData(
|
||||||
|
items.map((i) => `Cuenta: ${esc(i.username)}<br>Token de Seguridad: ${esc(i.token)}`).join('<br><br>') +
|
||||||
|
'<br><br>',
|
||||||
|
),
|
||||||
|
p(
|
||||||
|
'Recuerda que este Token es sensible a mayúsculas y minúsculas.<br>Al usarlo, escríbelo exactamente de la misma forma que aparece aquí.',
|
||||||
|
),
|
||||||
|
divider(),
|
||||||
|
p(`¡No compartas el Token de Seguridad con nadie!<br><br><br>Atentamente, el Equipo de ${SERVER_NAME}.`, TD_LAST),
|
||||||
|
],
|
||||||
|
locale,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import nodemailer from 'nodemailer'
|
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
|
let transporter: nodemailer.Transporter | null = null
|
||||||
|
|
||||||
function getTransporter(): nodemailer.Transporter {
|
function getTransporter(): nodemailer.Transporter {
|
||||||
@@ -9,6 +9,9 @@ function getTransporter(): nodemailer.Transporter {
|
|||||||
host: process.env.EMAIL_HOST,
|
host: process.env.EMAIL_HOST,
|
||||||
port: Number(process.env.EMAIL_PORT || 587),
|
port: Number(process.env.EMAIL_PORT || 587),
|
||||||
secure: process.env.EMAIL_USE_SSL === 'True', // true para 465, false para 587 (STARTTLS)
|
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: {
|
auth: {
|
||||||
user: process.env.EMAIL_HOST_USER,
|
user: process.env.EMAIL_HOST_USER,
|
||||||
pass: process.env.EMAIL_HOST_PASSWORD,
|
pass: process.env.EMAIL_HOST_PASSWORD,
|
||||||
|
|||||||
+16
-24
@@ -3,6 +3,12 @@ import type { RowDataPacket } from 'mysql2'
|
|||||||
import { db, DB } from './db'
|
import { db, DB } from './db'
|
||||||
import { normalizeEmail, bnetMakeRegistration } from './bnet'
|
import { normalizeEmail, bnetMakeRegistration } from './bnet'
|
||||||
import { sendMail } from './mail'
|
import { sendMail } from './mail'
|
||||||
|
import {
|
||||||
|
activationEmailHtml,
|
||||||
|
accountNamesEmailHtml,
|
||||||
|
passwordResetEmailHtml,
|
||||||
|
securityTokensListEmailHtml,
|
||||||
|
} from './emails'
|
||||||
|
|
||||||
const GMAIL_RE = /^[a-zA-Z0-9._%+-]+@gmail\.com$/
|
const GMAIL_RE = /^[a-zA-Z0-9._%+-]+@gmail\.com$/
|
||||||
|
|
||||||
@@ -12,13 +18,6 @@ export interface Result {
|
|||||||
redirect?: string
|
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).
|
* Recuperación por email/usuario (anti-enumeración: respuesta genérica siempre).
|
||||||
* `value` es el nombre de usuario (type=password) o el correo (resto).
|
* `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(
|
await sendMail(
|
||||||
email,
|
email,
|
||||||
'Restablecer contraseña - Nova WoW',
|
'Restablecer contraseña - Nova WoW',
|
||||||
emailShell(
|
passwordResetEmailHtml(email, link),
|
||||||
'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>`,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,16 +86,12 @@ export async function requestRecovery(type: string, value: string): Promise<Resu
|
|||||||
)
|
)
|
||||||
if (toks.length) {
|
if (toks.length) {
|
||||||
const nameById = new Map(games.map((g) => [g.id, g.username]))
|
const nameById = new Map(games.map((g) => [g.id, g.username]))
|
||||||
const list = toks
|
await sendMail(
|
||||||
.map((r) => `<li><strong>${nameById.get(r.user_id) ?? r.user_id}</strong>: <code>${r.token}</code></li>`)
|
|
||||||
.join('')
|
|
||||||
await sendMail(
|
|
||||||
email,
|
email,
|
||||||
'Tu Token de seguridad - Nova WoW',
|
'Tu Token de seguridad - Nova WoW',
|
||||||
emailShell(
|
securityTokensListEmailHtml(
|
||||||
'Tu Token de seguridad',
|
email,
|
||||||
`<p>Token(s) de seguridad de las cuentas asociadas a <strong>${email}</strong>:</p><ul>${list}</ul>
|
toks.map((r) => ({ username: String(nameById.get(r.user_id) ?? r.user_id), token: r.token })),
|
||||||
<p style="font-size:13px;color:#b1997f">Consérvalo en un lugar seguro y no lo compartas.</p>`,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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',
|
'SELECT username FROM account WHERE battlenet_account = ? ORDER BY battlenet_index',
|
||||||
[acc[0].id],
|
[acc[0].id],
|
||||||
)
|
)
|
||||||
const list = games.map((g) => `<li>${g.username}</li>`).join('') || '<li>—</li>'
|
|
||||||
await sendMail(
|
await sendMail(
|
||||||
email,
|
email,
|
||||||
'Tus cuentas de juego - Nova WoW',
|
'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 {
|
} catch {
|
||||||
@@ -148,7 +140,7 @@ export async function requestRecovery(type: string, value: string): Promise<Resu
|
|||||||
await sendMail(
|
await sendMail(
|
||||||
email,
|
email,
|
||||||
`Activación de la cuenta ${email} - Nova WoW`,
|
`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 {
|
} catch {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { ResultSetHeader, RowDataPacket } from 'mysql2'
|
|||||||
import { db, DB } from './db'
|
import { db, DB } from './db'
|
||||||
import { normalizeEmail, bnetMakeRegistration, gameMakeRegistration, makeGameAccountUsername } from './bnet'
|
import { normalizeEmail, bnetMakeRegistration, gameMakeRegistration, makeGameAccountUsername } from './bnet'
|
||||||
import { sendMail } from './mail'
|
import { sendMail } from './mail'
|
||||||
|
import { activationEmailHtml } from './emails'
|
||||||
|
|
||||||
const GMAIL_RE = /^[a-zA-Z0-9._%+-]+@gmail\.com$/
|
const GMAIL_RE = /^[a-zA-Z0-9._%+-]+@gmail\.com$/
|
||||||
|
|
||||||
@@ -19,17 +20,6 @@ export interface Result {
|
|||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function activationEmailHtml(email: string, link: 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">Activa tu cuenta</h2>
|
|
||||||
<p>La cuenta <strong>${email}</strong> se ha creado. Pulsa para activarla (enlace 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">Activar cuenta</a></p>
|
|
||||||
<p style="font-size:13px;word-break:break-all"><a href="${link}" style="color:#2471a9">${link}</a></p>
|
|
||||||
</div></body></html>`
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function registerAccount(input: RegisterInput): Promise<Result> {
|
export async function registerAccount(input: RegisterInput): Promise<Result> {
|
||||||
const password = (input.password || '').trim()
|
const password = (input.password || '').trim()
|
||||||
const confPassword = (input.confPassword || '').trim()
|
const confPassword = (input.confPassword || '').trim()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import crypto from 'node:crypto'
|
|||||||
import type { RowDataPacket } from 'mysql2'
|
import type { RowDataPacket } from 'mysql2'
|
||||||
import { db, DB } from './db'
|
import { db, DB } from './db'
|
||||||
import { sendMail } from './mail'
|
import { sendMail } from './mail'
|
||||||
|
import { securityTokenEmailHtml } from './emails'
|
||||||
import type { SessionData } from './session'
|
import type { SessionData } from './session'
|
||||||
|
|
||||||
export interface Result {
|
export interface Result {
|
||||||
@@ -34,17 +35,8 @@ export async function requestSecurityToken(session: SessionData, ip: string): Pr
|
|||||||
[token, expiresAt, ip || '0.0.0.0', userId],
|
[token, expiresAt, ip || '0.0.0.0', userId],
|
||||||
)
|
)
|
||||||
|
|
||||||
await sendMail(
|
// La cuenta es el correo: con Battle.net se entra con él, no con un usuario.
|
||||||
email,
|
await sendMail(email, 'Token de seguridad - Nova WoW', securityTokenEmailHtml(email, token, ip || '0.0.0.0'))
|
||||||
'Token de seguridad - Nova WoW',
|
|
||||||
`<!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">Nova WoW</h1><h2>Token de seguridad</h2>
|
|
||||||
<p>Tu token de seguridad es:</p>
|
|
||||||
<p style="font-size:28px;letter-spacing:4px;color:#d79602;font-weight:bold">${token}</p>
|
|
||||||
<p style="font-size:13px;color:#b1997f">Consérvalo en un lugar seguro. Se solicitará para acciones importantes de la cuenta.</p>
|
|
||||||
</div></body></html>`,
|
|
||||||
)
|
|
||||||
|
|
||||||
const [created] = await db(DB.default).query<RowDataPacket[]>(
|
const [created] = await db(DB.default).query<RowDataPacket[]>(
|
||||||
'SELECT created_at FROM home_securitytoken WHERE user_id = ? ORDER BY created_at DESC LIMIT 1',
|
'SELECT created_at FROM home_securitytoken WHERE user_id = ? ORDER BY created_at DESC LIMIT 1',
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 260 KiB |
Reference in New Issue
Block a user