Manual Recipes Signup with Email Confirmation

Signup with Email Confirmation

elements man recipes/signup-confirmation Read as markdown

A signup flow that creates an unverified user, emails them a one-time verification link, and logs them in when they click it. A separate userVerifications table tracks tokens (the row's id is the token). A background job sends the email asynchronously. A /verify page redeems the token.

Every file in this recipe starts from an elements create scaffold. Fill in the parts the recipe shows; leave the rest of the scaffold as-is.

Migration

elements create migration 'add users and verifications' -tables=users,userVerifications

Open the generated file and add the columns specific to this feature.

app/migrations/<timestamp>-add-users-and-verifications.migration.sql:

-- add users and verifications

-- Auto-update updatedAt on row changes.
create or replace function touchUpdatedAt()
returns trigger
language plpgsql
as $$
begin
  new.updatedAt = now();
  return new;
end;
$$;

create table users (
  id uuid primary key default uuidGenerateV7(),
  createdAt timestamptz not null default now(),
  updatedAt timestamptz not null default now(),
  email text not null unique,
  handle text not null unique,
  passwordHash text not null,
  verifiedAt timestamptz
);

create trigger usersTouchUpdatedAt
  before update on users
  for each row execute function touchUpdatedAt();

create table userVerifications (
  id uuid primary key default uuidGenerateV7(),
  createdAt timestamptz not null default now(),
  updatedAt timestamptz not null default now(),
  userId uuid not null references users(id) on delete cascade,
  expiresAt timestamptz not null
);

create trigger userVerificationsTouchUpdatedAt
  before update on userVerifications
  for each row execute function touchUpdatedAt();

users.verifiedAt is null until the user clicks the verification link. userVerifications.id is the token: a random uuidv7, unique by primary key, embedded in the verification URL. expiresAt lets the verify route reject stale links.

Signup services

elements create page signup

That writes app/pages/signup/{index.ts, template.html, style.css, test.ts} and prints the /signup route line to add to index.ts. Add a services.ts sibling by hand for the SignupForm interface and the signupUser rpc.

app/pages/signup/services.ts:

import { sql, tx, ValidationError, FieldErrors } from "@elements/app";
import { SendVerificationEmailJob } from "#app/jobs/send-verification-email";

export interface SignupForm {
  email: string;
  handle: string;
  password: string;
}

/** @rpc */
export function signupUser(form: SignupForm) {
  let errors = validateSignup(form);
  if (Object.keys(errors).length > 0) {
    throw new ValidationError(errors);
  }

  let verificationId = tx(() => {
    let user = sql<{ id: string }>(
      `insert into users (email, handle, passwordHash)
       values (${form.email}, ${form.handle}, crypt(${form.password}, genSalt('bf', 12)))
       returning id`,
    ).firstOrThrow();

    let verification = sql<{ id: string }>(
      `insert into userVerifications (userId, expiresAt)
       values (${user.id}, now() + interval '24 hours')
       returning id`,
    ).firstOrThrow();

    return verification.id;
  });

  new SendVerificationEmailJob({ to: form.email, verificationId }).schedule();
}

function validateSignup(form: SignupForm): FieldErrors<SignupForm> {
  let errors: FieldErrors<SignupForm> = {};

  if (!form.email.includes("@")) {
    errors.email = ["must be a valid email"];
  } else if (!sql(`select 1 from users where email = ${form.email}`).empty()) {
    errors.email = ["already taken"];
  }

  if (!/^[a-z0-9_]+$/.test(form.handle)) {
    errors.handle = ["letters, numbers, and underscores only"];
  } else if (!sql(`select 1 from users where handle = ${form.handle}`).empty()) {
    errors.handle = ["already taken"];
  }

  if (form.password.length < 8) {
    errors.password = ["must be at least 8 characters"];
  }

  return errors;
}

The tx() wraps the two inserts so a failure leaves no half-state behind. Scheduling the job outside the tx() is intentional: the job row commits with the surrounding transaction; the worker picks it up after the user and verification rows are durable.

For richer per-field validation (live blur, multiple errors per field), see elements man recipes form-validation.

Signup route

app/pages/signup/index.ts:

import signup from "./template";

export default function route(req, res) {
  return new signup();
}

Signup template

app/pages/signup/template.html:

import "./style.css";
import { ValidationError, FieldErrors } from "@elements/app";
import { SignupForm, signupUser } from "./services";

interface UiState {
  submitted: boolean;
}

function emptyForm(): SignupForm {
  return { email: "", handle: "", password: "" };
}

function submit(form: SignupForm, ui: UiState): FieldErrors<SignupForm> {
  try {
    signupUser(form);
    ui.submitted = true;
    return {};
  } catch (err: any) {
    if (err instanceof ValidationError) {
      return err.errors;
    }

    throw err;
  }
}

<html class="signup"
      (private form: SignupForm = emptyForm(),
       private errors: FieldErrors<SignupForm> = {},
       private ui: UiState = { submitted: false })>
  <div e:if={ui.submitted}>
    <h1>check your email</h1>
    <p>we've sent a verification link to <strong>{form.email}</strong>. click the link to finish creating your account.</p>
  </div>

  <form e:else onsubmit={() => errors = submit(form, ui)}>
    <h1>sign up</h1>

    <div class="field">
      <label>email</label>
      <input type="email" value={form.email} class={errors.email?.length && "is-error"}>
      <p e:for={msg of errors.email ?? []} class="error">{msg}</p>
    </div>

    <div class="field">
      <label>handle</label>
      <input type="text" value={form.handle} class={errors.handle?.length && "is-error"}>
      <p e:for={msg of errors.handle ?? []} class="error">{msg}</p>
    </div>

    <div class="field">
      <label>password</label>
      <input type="password" value={form.password} class={errors.password?.length && "is-error"}>
      <p e:for={msg of errors.password ?? []} class="error">{msg}</p>
    </div>

    <button type="submit">create account</button>
  </form>
</html>

After a successful submit, ui.submitted flips and the "check your email" branch renders. The form is still reactively bound, so a server-side rejection (for example, the email turns out to be taken after passing the client check) populates errors and the form re-renders with inline messages.

Email template

elements create email verify

That writes app/emails/verify/{index.html, style.css}. Update index.html to:

app/emails/verify/index.html:

import "./style.css";

/** @email */
<html (verifyUrl: string)>
  <body class="email verify">
    <h1>verify your email</h1>
    <p>click the link below to finish creating your account. the link expires in 24 hours.</p>
    <p>
      <a href={verifyUrl}>{verifyUrl}</a>
    </p>
    <p>if you didn't sign up, you can safely ignore this email.</p>
  </body>
</html>

The scaffolder marks email templates server-only and inlines reachable CSS into a single <style> tag at render time. The email has no browser bundle and no external resources, so it renders the same in every client.

Verification email job

elements create job SendVerificationEmail -fields='to: string, verificationId: string'

That writes app/jobs/send-verification-email.ts with a typed payload. Update the run() body:

app/jobs/send-verification-email.ts:

import { Job, email, getAppUrl } from "@elements/app";
import verifyEmail from "#app/emails/verify";

export interface SendVerificationEmailJobFields {
  to: string;
  verificationId: string;
}

/** @job */
export class SendVerificationEmailJob extends Job<SendVerificationEmailJobFields> {
  run() {
    let verifyUrl = `${getAppUrl()}/verify?token=${this.fields.verificationId}`;

    email({
      to: this.fields.to,
      subject: "verify your email",
      body: new verifyEmail({ verifyUrl }),
    });
  }
}

getAppUrl() resolves the app's external URL from the active environment: https://<domain> when a domain is set for the environment (with the scheme following the ssl setting), http://localhost:<port> in development with no domain, or http://<first-deploy-box-ip> on a deployment machine with no domain. run() is sync-style; Elements propagates async, so the email(...) call awaits internally.

The signup handler in app/pages/signup/services.ts schedules an instance with new SendVerificationEmailJob({ to, verificationId }).schedule();. The job's row commits with the surrounding transaction. The worker picks it up after the user and verification rows are durable.

Verify route

elements create page verify

The verify page has no rpc; everything happens in the route handler. The Verification interface is local to index.ts (the template does not need it).

app/pages/verify/index.ts:

import { sql, tx, session, NotAcceptableError, AuthError } from "@elements/app";
import verify from "./template";

interface Verification {
  id: string;
  userId: string;
  expiresAt: Date;
  email: string;
  handle: string;
}

export default function route(req, res) {
  let token = req.query.token as string | undefined;

  if (!token) {
    throw new NotAcceptableError("missing token");
  }

  let v = sql<Verification>(
    `select v.id, v.userId, v.expiresAt, u.email, u.handle
     from userVerifications v
     join users u on u.id = v.userId
     where v.id = ${token}`,
  ).firstOrThrow("verification token not found");

  if (v.expiresAt < new Date()) {
    throw new AuthError("link expired, please sign up again");
  }

  tx(() => {
    sql(`update users set verifiedAt = now() where id = ${v.userId}`);
    sql(`delete from userVerifications where id = ${v.id}`);
  });

  session.login({ userId: v.userId, userName: v.handle });

  return new verify({ handle: v.handle });
}

NotAcceptableError (safe 406) covers the case where the request is missing the token query parameter; the request shape is wrong. NotFoundError (safe 404) covers a token that doesn't match any userVerifications row. AuthError (safe 401) covers an expired link. The tx() makes the mark-verified and delete-token atomic, so the token is consumed exactly once.

app/pages/verify/template.html:

import "./style.css";

<html class="verify" (handle: string)>
  <h1>welcome, {handle}!</h1>
  <p>your email is verified.</p>
  <p><a href="/">go to your dashboard</a></p>
</html>

Routes

Register the pages and the job in index.ts:

import signup from "#app/pages/signup";
import verify from "#app/pages/verify";

// ...
app.route("/signup", signup);
app.route("/verify", verify);

The SendVerificationEmailJob class carries the /** @job */ build tag, so it's discovered and registered with the worker pool automatically. No app.job(...) call is needed.

Notes

  • The verification id is the token. There's no separate random-string column. uuidv7 is unguessable (122 bits of entropy) and unique by primary key. One-time use comes from the delete from userVerifications inside the verify tx().
  • expiresAt is 24 hours by default in this recipe; tune in the signup rpc if you want a shorter or longer window.
  • The email is sent from a job, not synchronously inside the signup rpc. The rpc returns immediately so the browser shows "check your email" without a slow SMTP round-trip on the request thread. Transient mail-server failures retry automatically through the job queue. The email is never sent for a signup that rolled back, because the job row commits with the surrounding transaction.
  • For a friendlier "this email is already taken, did you mean to sign in?" UX, catch the ValidationError carrying errors.email = ["already taken"] in the template and render a "sign in" link.
  • getAppUrl() resolves the app's external URL from the active environment. Set domain and ssl per environment in config.jsoc so the link in the email points at the right host in development, staging, and production.