|
| 1 | +import Logger from "./logger"; |
| 2 | + |
| 3 | +interface PendingCode { |
| 4 | + code: string; |
| 5 | + expiresAt: number; |
| 6 | +} |
| 7 | + |
| 8 | +const pendingCodes = new Map<string, PendingCode>(); |
| 9 | +const OTP_TTL_MS = 10 * 60 * 1000; // 10 minutes |
| 10 | + |
| 11 | +export function generateOtp(): string { |
| 12 | + return Math.floor(100000 + Math.random() * 900000).toString(); |
| 13 | +} |
| 14 | + |
| 15 | +export function storeOtp(email: string, code: string): void { |
| 16 | + pendingCodes.set(email.toLowerCase(), { |
| 17 | + code, |
| 18 | + expiresAt: Date.now() + OTP_TTL_MS, |
| 19 | + }); |
| 20 | +} |
| 21 | + |
| 22 | +export function verifyOtp(email: string, code: string): boolean { |
| 23 | + const entry = pendingCodes.get(email.toLowerCase()); |
| 24 | + if (!entry) return false; |
| 25 | + if (Date.now() > entry.expiresAt) { |
| 26 | + pendingCodes.delete(email.toLowerCase()); |
| 27 | + return false; |
| 28 | + } |
| 29 | + if (entry.code !== code) return false; |
| 30 | + pendingCodes.delete(email.toLowerCase()); |
| 31 | + return true; |
| 32 | +} |
| 33 | + |
| 34 | +export async function sendEmailOtp(toEmail: string, code: string): Promise<void> { |
| 35 | + const apiKey = process.env.SENDGRID_API_KEY; |
| 36 | + const fromEmail = process.env.SENDGRID_FROM_EMAIL || "noreply@inblock.io"; |
| 37 | + const appUrl = process.env.FRONTEND_URL || "https://aquafier.inblock.io"; |
| 38 | + |
| 39 | + if (!apiKey) { |
| 40 | + throw new Error("Email delivery not configured (SENDGRID_API_KEY missing)"); |
| 41 | + } |
| 42 | + |
| 43 | + const year = new Date().getFullYear(); |
| 44 | + const appUrlShort = appUrl.replace(/^https?:\/\//, ""); |
| 45 | + |
| 46 | + const html = `<!DOCTYPE html> |
| 47 | +<html lang="en"> |
| 48 | +<head><meta charset="UTF-8"><title>Aquafier — Email verification</title></head> |
| 49 | +<body style="margin:0;padding:0;background:#F4F4F5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#1F2937"> |
| 50 | + <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F4F4F5;padding:32px 16px"> |
| 51 | + <tr><td align="center"> |
| 52 | + <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="560" style="max-width:560px;width:100%;background:#FFFFFF;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(15,23,42,0.08)"> |
| 53 | + <tr><td style="background:#E55B1F;background-image:linear-gradient(135deg,#E55B1F 0%,#F37C30 100%);padding:36px 24px;text-align:center;color:#FFFFFF"> |
| 54 | + <div style="font-size:26px;font-weight:700;letter-spacing:0.2px;margin:0">Aquafier</div> |
| 55 | + <div style="font-size:14px;font-weight:500;margin-top:6px;opacity:0.95">Verify your email identity claim</div> |
| 56 | + </td></tr> |
| 57 | + <tr><td style="padding:32px 36px 8px 36px"> |
| 58 | + <p style="margin:0 0 14px 0;font-size:15px;line-height:1.55;color:#1F2937">Thank you for using <strong>Aquafier</strong>.</p> |
| 59 | + <p style="margin:0 0 6px 0;font-size:15px;line-height:1.55;color:#374151">To complete your email verification and create a verified email identity claim, please use the one-time password (OTP) below:</p> |
| 60 | + </td></tr> |
| 61 | + <tr><td align="center" style="padding:8px 36px 4px 36px"> |
| 62 | + <div style="display:inline-block;font-family:'SF Mono','Menlo','Consolas',monospace;font-size:38px;font-weight:700;letter-spacing:10px;color:#E55B1F;background:#FFF4EE;border:1px solid #FBD3B9;border-radius:10px;padding:18px 28px;margin:18px 0">${code}</div> |
| 63 | + </td></tr> |
| 64 | + <tr><td style="padding:8px 36px 28px 36px"> |
| 65 | + <p style="margin:0 0 10px 0;font-size:13px;line-height:1.55;color:#4B5563">This OTP is valid for the next <strong>10 minutes</strong>. If you didn't request this verification, please ignore this email or contact support.</p> |
| 66 | + <p style="margin:14px 0 0 0;font-size:13px;line-height:1.55;color:#9CA3AF">Do not share this code with anyone for security reasons.</p> |
| 67 | + </td></tr> |
| 68 | + </table> |
| 69 | + <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="560" style="max-width:560px;width:100%;margin-top:18px"> |
| 70 | + <tr><td align="center" style="padding:0 24px;font-size:12px;color:#6B7280"> |
| 71 | + <div>© ${year} Aquafier. All rights reserved.</div> |
| 72 | + <div style="margin-top:6px"><a href="${appUrl}" style="color:#E55B1F;text-decoration:none">${appUrlShort}</a></div> |
| 73 | + <div style="margin-top:6px;color:#9CA3AF">This is an automated email. Please do not reply.</div> |
| 74 | + </td></tr> |
| 75 | + </table> |
| 76 | + </td></tr> |
| 77 | + </table> |
| 78 | +</body> |
| 79 | +</html>`; |
| 80 | + |
| 81 | + const payload = { |
| 82 | + personalizations: [{ to: [{ email: toEmail }], subject: `Your Aquafier verification code: ${code}` }], |
| 83 | + from: { email: fromEmail, name: "Aquafier" }, |
| 84 | + content: [{ type: "text/html", value: html }], |
| 85 | + }; |
| 86 | + |
| 87 | + const resp = await fetch("https://api.sendgrid.com/v3/mail/send", { |
| 88 | + method: "POST", |
| 89 | + headers: { |
| 90 | + Authorization: `Bearer ${apiKey}`, |
| 91 | + "Content-Type": "application/json", |
| 92 | + }, |
| 93 | + body: JSON.stringify(payload), |
| 94 | + }); |
| 95 | + |
| 96 | + if (!resp.ok) { |
| 97 | + const body = await resp.text(); |
| 98 | + Logger.error(`SendGrid returned ${resp.status}: ${body}`); |
| 99 | + throw new Error(`SendGrid returned ${resp.status}`); |
| 100 | + } |
| 101 | + |
| 102 | + Logger.info(`Email OTP sent to ${toEmail} via SendGrid`); |
| 103 | +} |
0 commit comments