diff --git a/docker-compose.yml b/docker-compose.yml
index 9a0458c..af57b32 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -13,6 +13,7 @@ services:
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
- BASE_URL=${BASE_URL:-https://docfast.dev}
- PRO_KEYS=${PRO_KEYS}
+ - RESEND_API_KEY=${RESEND_API_KEY:-FILL_IN}
volumes:
- docfast-data:/app/data
mem_limit: 512m
diff --git a/public/app.js b/public/app.js
index 6e99687..6882a11 100644
--- a/public/app.js
+++ b/public/app.js
@@ -9,6 +9,7 @@ function openSignup() {
document.getElementById('signupModal').classList.add('active');
showState('signupInitial');
document.getElementById('signupError').style.display = 'none';
+ document.getElementById('signupEmail').value = '';
}
function closeSignup() {
@@ -18,6 +19,15 @@ function closeSignup() {
async function submitSignup() {
var errEl = document.getElementById('signupError');
var btn = document.getElementById('signupBtn');
+ var emailInput = document.getElementById('signupEmail');
+ var email = emailInput.value.trim();
+
+ if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
+ errEl.textContent = 'Please enter a valid email address.';
+ errEl.style.display = 'block';
+ return;
+ }
+
errEl.style.display = 'none';
btn.disabled = true;
showState('signupLoading');
@@ -26,7 +36,7 @@ async function submitSignup() {
var res = await fetch('/v1/signup/free', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({})
+ body: JSON.stringify({ email: email })
});
var data = await res.json();
@@ -38,7 +48,7 @@ async function submitSignup() {
return;
}
- document.getElementById('apiKeyText').textContent = data.apiKey;
+ // Show "check your email" message
showState('signupResult');
} catch (err) {
showState('signupInitial');
@@ -86,11 +96,9 @@ document.addEventListener('DOMContentLoaded', function() {
document.getElementById('btn-checkout').addEventListener('click', checkout);
document.getElementById('btn-close-signup').addEventListener('click', closeSignup);
document.getElementById('signupBtn').addEventListener('click', submitSignup);
- document.getElementById('copyBtn').addEventListener('click', copyKey);
document.getElementById('signupModal').addEventListener('click', function(e) {
if (e.target === this) closeSignup();
});
- // Smooth scroll for nav links
document.querySelectorAll('a[href^="#"]').forEach(function(a) {
a.addEventListener('click', function(e) {
e.preventDefault();
diff --git a/public/index.html b/public/index.html
index e229cf5..508c659 100644
--- a/public/index.html
+++ b/public/index.html
@@ -357,14 +357,11 @@ html, body {
-
๐ You're in!
+
๐ง Check your email!
+
We've sent a verification link to your email address. Click the link to get your API key.
- โ ๏ธ
- Save your API key now โ we can't recover it later.
-
-
-
-
+ ๐ก
+ The link expires in 24 hours. Check your spam folder if you don't see it.
100 free PDFs/month โข Read the docs โ
diff --git a/src/index.ts b/src/index.ts
index b7e8e91..d1d45bf 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -13,6 +13,7 @@ import { usageMiddleware } from "./middleware/usage.js";
import { getUsageStats } from "./middleware/usage.js";
import { initBrowser, closeBrowser } from "./services/browser.js";
import { loadKeys, getAllKeys } from "./services/keys.js";
+import { verifyToken } from "./services/verification.js";
const app = express();
const PORT = parseInt(process.env.PORT || "3100", 10);
@@ -77,6 +78,63 @@ app.get("/v1/usage", authMiddleware, (_req, res) => {
res.json(getUsageStats());
});
+// Email verification endpoint
+app.get("/verify", (req, res) => {
+ const token = req.query.token as string;
+ if (!token) {
+ res.status(400).send(verifyPage("Invalid Link", "No verification token provided.", null));
+ return;
+ }
+
+ const result = verifyToken(token);
+
+ switch (result.status) {
+ case "ok":
+ res.send(verifyPage("Email Verified! ๐", "Your DocFast API key is ready:", result.verification!.apiKey));
+ break;
+ case "already_verified":
+ res.send(verifyPage("Already Verified", "This email was already verified. Here's your API key:", result.verification!.apiKey));
+ break;
+ case "expired":
+ res.status(410).send(verifyPage("Link Expired", "This verification link has expired (24h). Please sign up again.", null));
+ break;
+ case "invalid":
+ res.status(404).send(verifyPage("Invalid Link", "This verification link is not valid.", null));
+ break;
+ }
+});
+
+function verifyPage(title: string, message: string, apiKey: string | null): string {
+ return `
+
+${title} โ DocFast
+
+
+
+
+
${title}
+
${message}
+${apiKey ? `
+
โ ๏ธ Save your API key now โ we can't recover it later.
+
${apiKey}
+
+` : `
`}
+
`;
+}
+
// Landing page
const __dirname = path.dirname(fileURLToPath(import.meta.url));
app.use(express.static(path.join(__dirname, "../public")));
diff --git a/src/routes/signup.ts b/src/routes/signup.ts
index f98c1ab..508a321 100644
--- a/src/routes/signup.ts
+++ b/src/routes/signup.ts
@@ -1,13 +1,15 @@
import { Router, Request, Response } from "express";
import rateLimit from "express-rate-limit";
import { createFreeKey } from "../services/keys.js";
+import { createVerification, verifyToken, isEmailVerified, getVerifiedApiKey } from "../services/verification.js";
+import { sendVerificationEmail } from "../services/email.js";
const router = Router();
// Rate limiting for signup - 5 signups per IP per hour
const signupLimiter = rateLimit({
- windowMs: 60 * 60 * 1000, // 1 hour
- max: 5, // 5 signups per IP per hour
+ windowMs: 60 * 60 * 1000,
+ max: 5,
message: {
error: "Too many signup attempts. Please try again in 1 hour.",
retryAfter: "1 hour"
@@ -18,29 +20,36 @@ const signupLimiter = rateLimit({
skipFailedRequests: false,
});
-// Self-service free API key signup
-router.post("/free", signupLimiter, (req: Request, res: Response) => {
+// Self-service free API key signup โ now requires email verification
+router.post("/free", signupLimiter, async (req: Request, res: Response) => {
const { email } = req.body || {};
- // Email is optional โ validate only if provided
- let cleanEmail: string | undefined;
- if (email && typeof email === "string") {
- if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
- res.status(400).json({ error: "Invalid email address" });
- return;
- }
- cleanEmail = email.trim().toLowerCase();
+ if (!email || typeof email !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
+ res.status(400).json({ error: "A valid email address is required." });
+ return;
}
+ const cleanEmail = email.trim().toLowerCase();
+
+ // If already verified, tell them
+ if (isEmailVerified(cleanEmail)) {
+ res.status(409).json({ error: "This email is already registered. Check your inbox for the original verification email, or contact support." });
+ return;
+ }
+
+ // Create the API key (but don't reveal it yet)
const keyInfo = createFreeKey(cleanEmail);
+ // Create verification record
+ const verification = createVerification(cleanEmail, keyInfo.key);
+
+ // Send verification email
+ await sendVerificationEmail(cleanEmail, verification.token);
+
res.json({
- message: "Welcome to DocFast! ๐",
- apiKey: keyInfo.key,
- tier: "free",
- limit: "100 PDFs/month",
- docs: "https://docfast.dev/docs",
- note: "Save this API key โ it won't be shown again.",
+ message: "Check your email for a verification link to get your API key.",
+ email: cleanEmail,
+ verified: false,
});
});
diff --git a/src/services/email.ts b/src/services/email.ts
new file mode 100644
index 0000000..d169e3c
--- /dev/null
+++ b/src/services/email.ts
@@ -0,0 +1,52 @@
+const RESEND_API_KEY = process.env.RESEND_API_KEY;
+const BASE_URL = process.env.BASE_URL || "https://docfast.dev";
+
+export async function sendVerificationEmail(email: string, token: string): Promise {
+ const verifyUrl = `${BASE_URL}/verify?token=${token}`;
+
+ if (!RESEND_API_KEY || RESEND_API_KEY === "FILL_IN") {
+ console.log(`\n๐ง VERIFICATION EMAIL (no Resend configured)`);
+ console.log(` To: ${email}`);
+ console.log(` URL: ${verifyUrl}\n`);
+ return true;
+ }
+
+ try {
+ const res = await fetch("https://api.resend.com/emails", {
+ method: "POST",
+ headers: {
+ "Authorization": `Bearer ${RESEND_API_KEY}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ from: "DocFast ",
+ to: [email],
+ subject: "Verify your DocFast account",
+ html: `
+
+
โก Welcome to DocFast
+
Click the button below to verify your email and get your API key:
+
Verify Email โ
+
This link expires in 24 hours. If you didn't sign up for DocFast, ignore this email.
+
+ `,
+ }),
+ });
+
+ 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;
+ } catch (err) {
+ console.error("Email send error:", err);
+ console.log(`\n๐ง VERIFICATION EMAIL (fallback)`);
+ console.log(` To: ${email}`);
+ console.log(` URL: ${verifyUrl}\n`);
+ return true;
+ }
+}
diff --git a/src/services/verification.ts b/src/services/verification.ts
new file mode 100644
index 0000000..6f1f524
--- /dev/null
+++ b/src/services/verification.ts
@@ -0,0 +1,98 @@
+import { randomBytes } from "crypto";
+import { existsSync, mkdirSync } from "fs";
+import path from "path";
+import { fileURLToPath } from "url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const DATA_DIR = path.join(__dirname, "../../data");
+const DB_PATH = path.join(DATA_DIR, "verifications.json");
+
+// Simple JSON-based store (no SQLite dependency needed)
+import { readFileSync, writeFileSync } from "fs";
+
+export interface Verification {
+ email: string;
+ token: string;
+ apiKey: string;
+ createdAt: string;
+ verifiedAt: string | null;
+}
+
+let verifications: Verification[] = [];
+
+function ensureDataDir(): void {
+ if (!existsSync(DATA_DIR)) {
+ mkdirSync(DATA_DIR, { recursive: true });
+ }
+}
+
+function load(): void {
+ ensureDataDir();
+ if (existsSync(DB_PATH)) {
+ try {
+ verifications = JSON.parse(readFileSync(DB_PATH, "utf-8"));
+ } catch {
+ verifications = [];
+ }
+ }
+}
+
+function save(): void {
+ ensureDataDir();
+ writeFileSync(DB_PATH, JSON.stringify(verifications, null, 2));
+}
+
+// Initialize on import
+load();
+
+const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
+
+export function createVerification(email: string, apiKey: string): Verification {
+ // Check for existing pending verification for this email
+ const existing = verifications.find(
+ (v) => v.email === email && !v.verifiedAt
+ );
+ if (existing) {
+ // Check if expired
+ const age = Date.now() - new Date(existing.createdAt).getTime();
+ if (age < TOKEN_EXPIRY_MS) {
+ return existing; // Return existing pending verification
+ }
+ // Expired โ remove it
+ verifications = verifications.filter((v) => v !== existing);
+ }
+
+ const verification: Verification = {
+ email,
+ token: randomBytes(32).toString("hex"),
+ apiKey,
+ createdAt: new Date().toISOString(),
+ verifiedAt: null,
+ };
+ verifications.push(verification);
+ save();
+ return verification;
+}
+
+export function verifyToken(token: string): { status: "ok"; verification: Verification } | { status: "invalid" | "expired" | "already_verified"; verification?: Verification } {
+ const v = verifications.find((v) => v.token === token);
+ if (!v) return { status: "invalid" };
+
+ if (v.verifiedAt) return { status: "already_verified", verification: v };
+
+ const age = Date.now() - new Date(v.createdAt).getTime();
+ if (age > TOKEN_EXPIRY_MS) return { status: "expired" };
+
+ v.verifiedAt = new Date().toISOString();
+ save();
+ return { status: "ok", verification: v };
+}
+
+export function isEmailVerified(email: string): boolean {
+ return verifications.some((v) => v.email === email && v.verifiedAt !== null);
+}
+
+export function getVerifiedApiKey(email: string): string | null {
+ const v = verifications.find((v) => v.email === email && v.verifiedAt !== null);
+ return v?.apiKey ?? null;
+}