fix(BUG-021): remove verification code from API response, send via email
- Replace Resend email service with nodemailer via local postfix relay - Remove code field from POST /v1/signup/free response - Send 6-digit verification code via email only (noreply@docfast.dev) - Add extra_hosts for Docker-to-host SMTP relay - Fire-and-forget email sending to avoid blocking API response
This commit is contained in:
parent
a67c16cd0f
commit
210fb26ec1
5 changed files with 50 additions and 54 deletions
|
|
@ -5,6 +5,8 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:3100:3100"
|
- "127.0.0.1:3100:3100"
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
environment:
|
environment:
|
||||||
- API_KEYS=${API_KEYS}
|
- API_KEYS=${API_KEYS}
|
||||||
- PORT=3100
|
- PORT=3100
|
||||||
|
|
@ -13,7 +15,8 @@ services:
|
||||||
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
|
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
|
||||||
- BASE_URL=${BASE_URL:-https://docfast.dev}
|
- BASE_URL=${BASE_URL:-https://docfast.dev}
|
||||||
- PRO_KEYS=${PRO_KEYS}
|
- PRO_KEYS=${PRO_KEYS}
|
||||||
- RESEND_API_KEY=${RESEND_API_KEY:-FILL_IN}
|
- SMTP_HOST=host.docker.internal
|
||||||
|
- SMTP_PORT=25
|
||||||
volumes:
|
volumes:
|
||||||
- docfast-data:/app/data
|
- docfast-data:/app/data
|
||||||
mem_limit: 512m
|
mem_limit: 512m
|
||||||
|
|
|
||||||
19
package-lock.json
generated
19
package-lock.json
generated
|
|
@ -13,12 +13,14 @@
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
"marked": "^15.0.0",
|
"marked": "^15.0.0",
|
||||||
"nanoid": "^5.0.0",
|
"nanoid": "^5.0.0",
|
||||||
|
"nodemailer": "^8.0.1",
|
||||||
"puppeteer": "^24.0.0",
|
"puppeteer": "^24.0.0",
|
||||||
"stripe": "^20.3.1"
|
"stripe": "^20.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/nodemailer": "^7.0.9",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
"vitest": "^3.0.0"
|
"vitest": "^3.0.0"
|
||||||
|
|
@ -984,6 +986,15 @@
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/nodemailer": {
|
||||||
|
"version": "7.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.9.tgz",
|
||||||
|
"integrity": "sha512-vI8oF1M+8JvQhsId0Pc38BdUP2evenIIys7c7p+9OZXSPOH5c1dyINP1jT8xQ2xPuBUXmIC87s+91IZMDjH8Ow==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/qs": {
|
"node_modules/@types/qs": {
|
||||||
"version": "6.14.0",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||||
|
|
@ -2572,6 +2583,14 @@
|
||||||
"node": ">= 0.4.0"
|
"node": ">= 0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-inspect": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,14 @@
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
"marked": "^15.0.0",
|
"marked": "^15.0.0",
|
||||||
"nanoid": "^5.0.0",
|
"nanoid": "^5.0.0",
|
||||||
|
"nodemailer": "^8.0.1",
|
||||||
"puppeteer": "^24.0.0",
|
"puppeteer": "^24.0.0",
|
||||||
"stripe": "^20.3.1"
|
"stripe": "^20.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/nodemailer": "^7.0.9",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
"vitest": "^3.0.0"
|
"vitest": "^3.0.0"
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ const verifyLimiter = rateLimit({
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pre-check: reject already-registered emails BEFORE rate limiting (BUG-022)
|
|
||||||
function rejectDuplicateEmail(req: Request, res: Response, next: Function) {
|
function rejectDuplicateEmail(req: Request, res: Response, next: Function) {
|
||||||
const { email } = req.body || {};
|
const { email } = req.body || {};
|
||||||
if (email && typeof email === "string") {
|
if (email && typeof email === "string") {
|
||||||
|
|
@ -35,7 +34,7 @@ function rejectDuplicateEmail(req: Request, res: Response, next: Function) {
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Request signup — generates 6-digit code
|
// Step 1: Request signup — generates 6-digit code, sends via email
|
||||||
router.post("/free", rejectDuplicateEmail, signupLimiter, async (req: Request, res: Response) => {
|
router.post("/free", rejectDuplicateEmail, signupLimiter, async (req: Request, res: Response) => {
|
||||||
const { email } = req.body || {};
|
const { email } = req.body || {};
|
||||||
|
|
||||||
|
|
@ -53,15 +52,14 @@ router.post("/free", rejectDuplicateEmail, signupLimiter, async (req: Request, r
|
||||||
|
|
||||||
const pending = createPendingVerification(cleanEmail);
|
const pending = createPendingVerification(cleanEmail);
|
||||||
|
|
||||||
// TODO: Send code via email once SMTP is configured
|
// Send verification code via email (fire-and-forget, don't block response)
|
||||||
// For now, return code in response for testing
|
sendVerificationEmail(cleanEmail, pending.code).catch(err => {
|
||||||
console.log(`📧 Verification code for ${cleanEmail}: ${pending.code}`);
|
console.error(`Failed to send verification email to ${cleanEmail}:`, err);
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
status: "verification_required",
|
status: "verification_required",
|
||||||
message: "Check your email for a verification code",
|
message: "Check your email for the verification code.",
|
||||||
email: cleanEmail,
|
|
||||||
code: pending.code, // TEMP: remove once email infra is live
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -87,7 +85,6 @@ router.post("/verify", verifyLimiter, (req: Request, res: Response) => {
|
||||||
switch (result.status) {
|
switch (result.status) {
|
||||||
case "ok": {
|
case "ok": {
|
||||||
const keyInfo = createFreeKey(cleanEmail);
|
const keyInfo = createFreeKey(cleanEmail);
|
||||||
// Mark as verified via legacy system too
|
|
||||||
const verification = createVerification(cleanEmail, keyInfo.key);
|
const verification = createVerification(cleanEmail, keyInfo.key);
|
||||||
verification.verifiedAt = new Date().toISOString();
|
verification.verifiedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,27 @@
|
||||||
const RESEND_API_KEY = process.env.RESEND_API_KEY;
|
import nodemailer from "nodemailer";
|
||||||
const BASE_URL = process.env.BASE_URL || "https://docfast.dev";
|
|
||||||
|
|
||||||
export async function sendVerificationEmail(email: string, token: string): Promise<boolean> {
|
const transporter = nodemailer.createTransport({
|
||||||
const verifyUrl = `${BASE_URL}/verify?token=${token}`;
|
host: process.env.SMTP_HOST || "host.docker.internal",
|
||||||
|
port: Number(process.env.SMTP_PORT || 25),
|
||||||
if (!RESEND_API_KEY || RESEND_API_KEY === "FILL_IN") {
|
secure: false,
|
||||||
console.log(`\n📧 VERIFICATION EMAIL (no Resend configured)`);
|
connectionTimeout: 5000,
|
||||||
console.log(` To: ${email}`);
|
greetingTimeout: 5000,
|
||||||
console.log(` URL: ${verifyUrl}\n`);
|
socketTimeout: 10000,
|
||||||
return true;
|
tls: { rejectUnauthorized: false },
|
||||||
}
|
});
|
||||||
|
|
||||||
|
export async function sendVerificationEmail(email: string, code: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("https://api.resend.com/emails", {
|
const info = await transporter.sendMail({
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Authorization": `Bearer ${RESEND_API_KEY}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
from: "DocFast <noreply@docfast.dev>",
|
from: "DocFast <noreply@docfast.dev>",
|
||||||
to: [email],
|
to: email,
|
||||||
subject: "Verify your DocFast account",
|
subject: "DocFast - Verify your email",
|
||||||
html: `
|
text: `Your DocFast verification code is: ${code}\n\nThis code expires in 15 minutes.\n\nIf you didn't sign up for DocFast, ignore this email.`,
|
||||||
<div style="font-family: -apple-system, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
|
|
||||||
<h1 style="font-size: 24px; margin-bottom: 16px;">⚡ Welcome to DocFast</h1>
|
|
||||||
<p style="color: #555; line-height: 1.6;">Click the button below to verify your email and get your API key:</p>
|
|
||||||
<a href="${verifyUrl}" style="display: inline-block; background: #34d399; color: #0b0d11; padding: 14px 28px; border-radius: 8px; font-weight: 600; text-decoration: none; margin: 24px 0;">Verify Email →</a>
|
|
||||||
<p style="color: #999; font-size: 13px; margin-top: 24px;">This link expires in 24 hours. If you didn't sign up for DocFast, ignore this email.</p>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
console.log(`📧 Verification email sent to ${email}: ${info.messageId}`);
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.text();
|
|
||||||
console.error(`Resend API error: ${res.status} ${err}`);
|
|
||||||
// Fallback to console
|
|
||||||
console.log(`\n📧 VERIFICATION EMAIL (Resend failed, logging)`);
|
|
||||||
console.log(` To: ${email}`);
|
|
||||||
console.log(` URL: ${verifyUrl}\n`);
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Email send error:", err);
|
console.error(`📧 Failed to send verification email to ${email}:`, err);
|
||||||
console.log(`\n📧 VERIFICATION EMAIL (fallback)`);
|
return false;
|
||||||
console.log(` To: ${email}`);
|
|
||||||
console.log(` URL: ${verifyUrl}\n`);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue