Skip to content

Commit 438018b

Browse files
committed
[Feat]: enhance OTP email functionality with improved error handling and SMTP configuration options
1 parent b8488d5 commit 438018b

File tree

4 files changed

+141
-13
lines changed

4 files changed

+141
-13
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.env
1+
.env*
22
node_modules/
33
dist/
44
.env.local

src/commands/verify.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
import { SlashCommandBuilder, PermissionsBitField } from 'discord.js';
22
import { buildEmbed } from '../utils/embed';
33
import { getGuildConfig, upsertGuildConfig } from '../config/store';
4-
import { generateOtp, verifyOtp, pendingOtp } from '../utils/otp';
4+
import { generateOtp, verifyOtp, pendingOtp, clearOtp } from '../utils/otp';
55
import { sendOtpEmail } from '../utils/mailer';
66

77
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
88

9+
function maskEmail(email: string) {
10+
const [local, domain] = email.split('@');
11+
if (!domain) return email;
12+
const visible = local.slice(0, 2);
13+
return `${visible}${local.length > 2 ? '***' : ''}@${domain}`;
14+
}
15+
916
const data = new SlashCommandBuilder()
1017
.setName('verify')
1118
.setDescription('Verifica tu correo con un OTP')
@@ -65,21 +72,36 @@ async function execute(interaction: any) {
6572
});
6673
}
6774

75+
try {
76+
await interaction.deferReply({ ephemeral: true });
77+
} catch (deferErr) {
78+
console.error('No se pudo deferir la respuesta de /verify start', deferErr);
79+
return interaction.reply({
80+
content: 'No pude preparar la verificación, intenta nuevamente en unos segundos.',
81+
flags: 1 << 6,
82+
});
83+
}
84+
6885
const code = generateOtp(guildId, member.id, email);
86+
const startedAt = Date.now();
6987
try {
7088
await sendOtpEmail(email, code);
89+
console.info(
90+
`[verify] OTP enviado a ${maskEmail(email)} para ${member.id} en ${Date.now() - startedAt}ms`,
91+
);
7192
} catch (err) {
93+
clearOtp(guildId, member.id);
7294
console.error('Error enviando OTP', err);
73-
return interaction.reply({
74-
content: 'No se pudo enviar el correo. Revisa la configuración SMTP.',
75-
flags: 1 << 6,
95+
const reason = err instanceof Error ? err.message : 'No se pudo enviar el correo.';
96+
return interaction.editReply({
97+
content: `No se pudo enviar el correo. ${reason}`,
7698
});
7799
}
78100
const embed = buildEmbed({
79101
title: 'Verificación iniciada',
80102
description: `Enviamos un código a ${email}. Usa /verify code <OTP>.`,
81103
});
82-
return interaction.reply({ embeds: [embed], flags: 1 << 6 });
104+
return interaction.editReply({ embeds: [embed] });
83105
}
84106

85107
if (sub === 'code') {

src/utils/mailer.ts

Lines changed: 104 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ interface SmtpConfig {
77
pass: string;
88
from: string;
99
secure: boolean;
10+
connectionTimeout: number;
11+
socketTimeout: number;
12+
greetingTimeout: number;
13+
pool: boolean;
14+
maxConnections: number;
15+
maxMessages: number;
16+
requireTLS: boolean;
17+
rejectUnauthorized: boolean;
1018
}
1119

1220
function getConfig(): SmtpConfig {
@@ -20,7 +28,70 @@ function getConfig(): SmtpConfig {
2028
'SMTP configuration is missing. Set SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM',
2129
);
2230
}
23-
return { host, port, user, pass, from, secure: port === 465 };
31+
const num = (value: string | undefined, fallback: number) => {
32+
const parsed = Number(value);
33+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
34+
};
35+
const bool = (value: string | undefined, fallback: boolean) => {
36+
if (value === undefined) return fallback;
37+
return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase());
38+
};
39+
return {
40+
host,
41+
port,
42+
user,
43+
pass,
44+
from,
45+
secure: port === 465,
46+
connectionTimeout: num(process.env.SMTP_CONNECTION_TIMEOUT, 10000),
47+
socketTimeout: num(process.env.SMTP_SOCKET_TIMEOUT, 10000),
48+
greetingTimeout: num(process.env.SMTP_GREETING_TIMEOUT, 10000),
49+
pool: bool(process.env.SMTP_POOL, true),
50+
maxConnections: num(process.env.SMTP_MAX_CONNECTIONS, 1),
51+
maxMessages: num(process.env.SMTP_MAX_MESSAGES, 20),
52+
requireTLS: bool(process.env.SMTP_REQUIRE_TLS, false),
53+
rejectUnauthorized: bool(process.env.SMTP_TLS_REJECT_UNAUTHORIZED, true),
54+
};
55+
}
56+
57+
function maskEmail(email: string) {
58+
const [local, domain] = email.split('@');
59+
if (!domain) return email;
60+
const visible = local.slice(0, 2);
61+
return `${visible}${local.length > 2 ? '***' : ''}@${domain}`;
62+
}
63+
64+
function describeSmtpError(err: unknown) {
65+
if (!err || typeof err !== 'object') {
66+
return {
67+
logMessage: 'Unknown SMTP error',
68+
userMessage: 'No se pudo enviar el correo (error desconocido).',
69+
};
70+
}
71+
const error = err as { code?: string; command?: string; responseCode?: number; message?: string };
72+
if (error.code === 'ETIMEDOUT') {
73+
return {
74+
logMessage: 'SMTP connection timed out',
75+
userMessage: 'No se pudo contactar con el servidor SMTP (timeout).',
76+
};
77+
}
78+
if (error.code === 'EAUTH') {
79+
return {
80+
logMessage: 'SMTP authentication failed',
81+
userMessage: 'Credenciales SMTP inválidas.',
82+
};
83+
}
84+
if (error.code === 'ECONNECTION') {
85+
return {
86+
logMessage: 'SMTP connection refused',
87+
userMessage: 'El servidor SMTP rechazó la conexión.',
88+
};
89+
}
90+
const base = error.message || 'Error SMTP no especificado.';
91+
return {
92+
logMessage: base,
93+
userMessage: base,
94+
};
2495
}
2596

2697
export async function sendOtpEmail(to: string, code: string) {
@@ -33,15 +104,41 @@ export async function sendOtpEmail(to: string, code: string) {
33104
user: cfg.user,
34105
pass: cfg.pass,
35106
},
107+
pool: cfg.pool,
108+
maxConnections: cfg.maxConnections,
109+
maxMessages: cfg.maxMessages,
110+
connectionTimeout: cfg.connectionTimeout,
111+
socketTimeout: cfg.socketTimeout,
112+
greetingTimeout: cfg.greetingTimeout,
113+
requireTLS: cfg.requireTLS,
114+
tls: {
115+
rejectUnauthorized: cfg.rejectUnauthorized,
116+
},
36117
});
37118

38119
const text = `Tu código de verificación es: ${code}`;
39120
const subject = 'Código de verificación';
121+
const startedAt = Date.now();
40122

41-
await transporter.sendMail({
42-
from: cfg.from,
43-
to,
44-
subject,
45-
text,
46-
});
123+
try {
124+
const info = await transporter.sendMail({
125+
from: cfg.from,
126+
to,
127+
subject,
128+
text,
129+
});
130+
console.info(
131+
`[mailer] OTP sent to ${maskEmail(to)} via ${cfg.host}:${cfg.port} in ${Date.now() - startedAt}ms (id=${info.messageId})`,
132+
);
133+
return info;
134+
} catch (error) {
135+
const { logMessage, userMessage } = describeSmtpError(error);
136+
console.error(
137+
`[mailer] Failed to send OTP to ${maskEmail(to)} via ${cfg.host}:${cfg.port} after ${Date.now() - startedAt}ms - ${logMessage}`,
138+
error,
139+
);
140+
const wrapped = new Error(userMessage);
141+
(wrapped as any).cause = error;
142+
throw wrapped;
143+
}
47144
}

src/utils/otp.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,12 @@ export function verifyOtp(
4444
export function pendingOtp(guildId: string, userId: string): OtpEntry | undefined {
4545
return store.get(guildId)?.get(userId);
4646
}
47+
48+
export function clearOtp(guildId: string, userId: string) {
49+
const guildStore = store.get(guildId);
50+
if (!guildStore) return;
51+
guildStore.delete(userId);
52+
if (!guildStore.size) {
53+
store.delete(guildId);
54+
}
55+
}

0 commit comments

Comments
 (0)