Skip to content

feat(auth): add email verification for signup with 6-digit OTP#152

Merged
mahata merged 6 commits intomainfrom
feat/email-verification
Mar 29, 2026
Merged

feat(auth): add email verification for signup with 6-digit OTP#152
mahata merged 6 commits intomainfrom
feat/email-verification

Conversation

@mahata
Copy link
Copy Markdown
Owner

@mahata mahata commented Mar 28, 2026

Summary

  • Adds email verification to the signup flow: users must enter a 6-digit OTP code sent to their email before their account is created
  • Uses Resend HTTP API for sending verification emails in production; in local dev (NODE_ENV=development), codes are logged to the Wrangler console
  • Pending registrations are stored in a new pending_registrations D1 table (with upsert on email and 10-minute expiry)

Changes

New files

  • hono/auth/emailVerification.ts — verification code generation and Resend API email sending
  • hono/auth/emailVerification.test.ts — 8 unit tests for email verification utilities
  • hono/components/VerifyEmailPage.tsx — server-rendered verification page with code input and resend button
  • hono/db/migrations/0001_nosy_starfox.sql — D1 migration for pending_registrations table

Modified files

  • hono/db/schema.ts — added pendingRegistrations table schema
  • hono/types.ts — added RESEND_API_KEY and RESEND_FROM_EMAIL to Bindings
  • hono/routes/emailAuth.ts — rewritten: POST /auth/register stores pending registration and sends OTP; added GET/POST /auth/verify-email and POST /auth/resend-code
  • hono/routes/emailAuth.test.ts — rewritten with 29 tests covering all new routes
  • hono/components/AuthPage.css — added verification page styles
  • hono/testApp.ts — added Resend mock env vars
  • .env.sample — added RESEND_API_KEY and RESEND_FROM_EMAIL placeholders
  • README.md — documented Resend setup for local dev and production deployment

How to test

  1. pnpm install && pnpm db:migrate
  2. pnpm test:run — 87 tests pass
  3. pnpm lint — clean
  4. pnpm build — clean
  5. pnpm dev — register a new account, check Wrangler console for the verification code

Users must now verify their email address during registration. A 6-digit
code is sent via Resend API, and the account is only created after
successful verification. In local development, codes are logged to the
console instead of sending emails.

- Add pendingRegistrations table and D1 migration
- Add verification code generation and Resend email sending utility
- Add VerifyEmailPage component with code input and resend button
- Rewrite emailAuth routes: register stores pending, verify creates user
- Add resend-code endpoint for re-sending verification emails
- Update README with Resend configuration and setup instructions
- 87 tests passing, lint clean, build clean
Copilot AI review requested due to automatic review settings March 28, 2026 14:08
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an email verification step to the email/password signup flow by creating a pending registration, sending a 6-digit OTP via Resend (or logging in dev), and only creating the user after OTP verification.

Changes:

  • Introduces OTP generation/expiry + Resend email-sending utilities and unit tests.
  • Reworks email auth routes to store pending registrations, verify OTPs, and allow resending codes.
  • Adds a new pending_registrations D1 table/migration and a server-rendered verification page + styles.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
hono/types.ts Adds Resend-related environment bindings.
hono/testApp.ts Adds default Resend env vars for tests.
hono/routes/emailAuth.ts Implements pending registration, OTP verification, and resend endpoints.
hono/routes/emailAuth.test.ts Expands route tests to cover the new OTP flows.
hono/db/schema.ts Adds Drizzle schema for pending_registrations.
hono/db/migrations/0001_nosy_starfox.sql Creates the pending_registrations table + unique email index.
hono/db/migrations/meta/_journal.json Registers the new migration in the migration journal.
hono/db/migrations/meta/0001_snapshot.json Adds Drizzle snapshot metadata for the new table.
hono/components/VerifyEmailPage.tsx Adds the email verification page UI with OTP input + resend action.
hono/components/AuthPage.css Adds styling for the verification page.
hono/auth/emailVerification.ts Adds OTP generation/expiry helpers and Resend email sending.
hono/auth/emailVerification.test.ts Adds unit tests for OTP utilities and Resend request behavior.
README.md Documents email verification and Resend configuration.
.env.sample Adds Resend env placeholders.
Comments suppressed due to low confidence (1)

hono/auth/emailVerification.test.ts:90

  • The tests stub globalThis.fetch and then call vi.unstubAllGlobals() at the end of each test. If an assertion throws before cleanup, the stub can leak into later tests. Consider moving the cleanup into afterEach (or using try/finally) to guarantee restoration.
  it("should call Resend API with correct parameters", async () => {
    const mockFetch = vi.fn().mockResolvedValue({ ok: true });
    vi.stubGlobal("fetch", mockFetch);

    const result = await sendVerificationEmail("re_test_key", "noreply@example.com", "user@example.com", "123456");

    expect(result).toEqual({ success: true });
    expect(mockFetch).toHaveBeenCalledOnce();

    const [url, options] = mockFetch.mock.calls[0];
    expect(url).toBe("https://api.resend.com/emails");
    expect(options.method).toBe("POST");
    expect(options.headers.Authorization).toBe("Bearer re_test_key");

    const body = JSON.parse(options.body);
    expect(body.from).toBe("noreply@example.com");
    expect(body.to).toEqual(["user@example.com"]);
    expect(body.subject).toBe("MLack - Verify your email");
    expect(body.html).toContain("123456");

    vi.unstubAllGlobals();
  });

  it("should return error on API failure", async () => {
    const mockFetch = vi.fn().mockResolvedValue({
      ok: false,
      status: 422,
      text: () => Promise.resolve("validation error"),
    });
    vi.stubGlobal("fetch", mockFetch);

    const result = await sendVerificationEmail("re_test_key", "noreply@example.com", "user@example.com", "123456");

    expect(result).toEqual({ success: false, error: "Failed to send verification email." });

    vi.unstubAllGlobals();
  });

Comment thread hono/components/VerifyEmailPage.tsx Outdated
Comment thread hono/routes/emailAuth.ts Outdated
Comment on lines +102 to +121
const [existingPending] = await db
.select()
.from(pendingRegistrations)
.where(eq(pendingRegistrations.email, email))
.limit(1);

if (existingPending) {
await db
.update(pendingRegistrations)
.set({ name, passwordHash, verificationCode, expiresAt })
.where(eq(pendingRegistrations.email, email));
} else {
await db.insert(pendingRegistrations).values({
email,
name,
passwordHash,
verificationCode,
expiresAt,
});
}
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pending registration “upsert” is implemented as select-then-update/insert, which is not atomic and can race under concurrent requests (both requests can observe no row and then one insert fails due to the unique email constraint). Consider using a single INSERT with an ON CONFLICT(email) DO UPDATE clause (Drizzle onConflictDoUpdate) so behavior matches the PR description and avoids uniqueness races.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 48fe756. Replaced the select-then-update/insert pattern with a single db.insert(pendingRegistrations).values(...).onConflictDoUpdate({ target: pendingRegistrations.email, set: {...} }) call, making the upsert atomic. Updated the test mocks and assertions accordingly.

Comment thread hono/routes/emailAuth.ts
Comment on lines +220 to +270
emailAuth.post("/auth/resend-code", async (c) => {
try {
const body = await c.req.parseBody();
const email = body.email as string;

if (!email) {
return c.redirect("/auth/register");
}

const db = getDb(c.env.DB);
const [pending] = await db
.select()
.from(pendingRegistrations)
.where(eq(pendingRegistrations.email, email))
.limit(1);

if (!pending) {
return c.html(
`<!DOCTYPE html>${await VerifyEmailPage({ email, error: "No pending registration found. Please register again." })}`,
400,
);
}

const verificationCode = generateVerificationCode();
const expiresAt = createExpiresAt();

await db
.update(pendingRegistrations)
.set({ verificationCode, expiresAt })
.where(eq(pendingRegistrations.email, email));

if (c.env.NODE_ENV === "development") {
console.log(`[DEV] Verification code for ${email}: ${verificationCode}`);
} else {
const emailResult = await sendVerificationEmail(
c.env.RESEND_API_KEY,
c.env.RESEND_FROM_EMAIL,
email,
verificationCode,
);
if (!emailResult.success) {
return c.html(
`<!DOCTYPE html>${await VerifyEmailPage({ email, error: "Failed to resend verification email. Please try again." })}`,
500,
);
}
}

return c.html(
`<!DOCTYPE html>${await VerifyEmailPage({ email, success: "A new verification code has been sent." })}`,
);
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/auth/resend-code (and /auth/verify-email) can be called repeatedly without any throttling or attempt limit, which makes it easy to spam Resend emails and brute-force 6-digit OTPs. Consider tracking lastSentAt/sendCount and attemptCount in pending_registrations and enforcing a cooldown (e.g. 30–60s between sends) + max attempts before invalidating the pending registration.

Copilot uses AI. Check for mistakes.
Comment thread hono/db/schema.ts Outdated
Comment thread hono/db/migrations/0001_nosy_starfox.sql Outdated
Comment thread hono/auth/emailVerification.test.ts Outdated
Comment on lines +22 to +55
export async function sendVerificationEmail(
apiKey: string,
fromEmail: string,
toEmail: string,
code: string,
): Promise<SendEmailResult> {
const response = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: fromEmail,
to: [toEmail],
subject: "MLack - Verify your email",
html: `
<div style="font-family: sans-serif; max-width: 480px; margin: 0 auto; padding: 24px;">
<h2 style="color: #333;">Verify your email</h2>
<p style="color: #555; font-size: 16px;">Your verification code is:</p>
<div style="background: #f4f4f4; padding: 16px; border-radius: 8px; text-align: center; margin: 24px 0;">
<span style="font-size: 32px; font-weight: bold; letter-spacing: 8px; color: #111;">${code}</span>
</div>
<p style="color: #888; font-size: 14px;">This code expires in 10 minutes. If you didn't request this, you can safely ignore this email.</p>
</div>
`,
}),
});

if (!response.ok) {
const body = await response.text();
console.error("Resend API error:", response.status, body);
return { success: false, error: "Failed to send verification email." };
}
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sendVerificationEmail does not handle fetch throwing (network/DNS/runtime errors). In those cases the exception bubbles up and the caller returns a generic registration/verification 500. Consider wrapping the fetch in try/catch and returning { success: false, error: ... } so callers can show a consistent “failed to send email” message and avoid unhandled promise rejections in this utility.

Copilot uses AI. Check for mistakes.
mahata and others added 4 commits March 28, 2026 23:27
Add 11 tests covering HTML structure, form actions, input validation
attributes, conditional error/success messages, and navigation link.

Change pattern="\d{6}" to pattern="[0-9]{6}" for unambiguous
digit matching without backslash escaping concerns.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@mahata mahata merged commit f76a45c into main Mar 29, 2026
3 checks passed
@mahata mahata deleted the feat/email-verification branch March 29, 2026 12:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants