Skip to main content

Command Palette

Search for a command to run...

Stop one person claiming your Node.js free tier twice: Gmail dots, plus tags and googlemail.com

A small TypeScript email normaliser, a trial_claims table with a unique key, and one transaction around the credit grant, with Prisma, Drizzle, node-postgres, better-sqlite3 and a Next.js route handler

Updated
•12 min read•View as Markdown
E
I'm building Orisift, a developer API that checks phone numbers, email domains and IP addresses against reference data, and says what it couldn't find out. I write about the small standards details that decide how forms behave.

If your app gives credits to every new account, at some point one person signs up as jane.doe@gmail.com, then janedoe+2@gmail.com, then Jane.Doe@googlemail.com. All three land in the same inbox. A unique constraint on users.email sees three different strings and hands out three lots of credits.

I don't think it's worth fighting account creation itself. What you can control is who gets the free tier. The pattern is short: build a normalised key from the email, store it in a trial_claims table with a unique index, and insert the claim in the same transaction as the credit grant. If the insert collides, the account still exists; it just doesn't get the credits.

Every snippet below is copied from a small project that I ran under vitest against PostgreSQL 17 and SQLite. The test details are at the end.

The email key

The key is only for matching. Keep sending mail to the address exactly as the person typed it.

Dots. Gmail ignores dots in the part before the @, but only on gmail.com and googlemail.com, which are the same mailbox. Google's help says this doesn't apply to Workspace accounts, so on a company domain hosted by Google, j.smith and jsmith can be two different colleagues. Other providers treat dots as part of the name, so removing dots anywhere else would merge different people.

Plus tags. Gmail and Outlook.com both treat everything after a + as a tag that arrives in the same inbox. On someone's own domain, jo+sales@example.co.uk might be a separate mailbox, or the server may reject it. So strip tags only for providers that document the behaviour, and add to the list only after reading their help pages.

// src/email-key.ts
// Gmail ignores dots in the local part, but only on these two domains.
const DOTS_IGNORED = new Set(["gmail.com", "googlemail.com"]);

// Providers whose own help pages describe +tags. Add one only after reading them.
const PLUS_IS_TAG = new Set(["gmail.com", "googlemail.com", "outlook.com", "hotmail.com"]);

export function emailKey(raw: string): string | null {
  const email = raw.trim().toLowerCase();
  const at = email.lastIndexOf("@");
  if (at <= 0 || at === email.length - 1) return null;

  let local = email.slice(0, at);
  let domain = email.slice(at + 1);

  if (PLUS_IS_TAG.has(domain)) local = local.split("+")[0];
  if (DOTS_IGNORED.has(domain)) {
    local = local.replaceAll(".", "");
    domain = "gmail.com";
  }

  return local ? `${local}@${domain}` : null;
}

emailKey(" J.Doe+trial2@GMAIL.com ") and emailKey("j.doe@googlemail.com") both return jdoe@gmail.com. j.doe+x@outlook.com becomes j.doe@outlook.com. jo+sales@example.co.uk and j.smith@company.com come back unchanged. Junk like +tag@gmail.com, ...@gmail.com or nobody returns null, and null means no trial.

lastIndexOf("@") splits on the last @, which matters for the rare quoted local part that contains one.

Privacy relays stay allowed. Addresses at privaterelay.appleid.com, mozmail.com or duck.com forward to an inbox the person keeps for years, so the normaliser leaves them alone. Someone can create a second relay address, and that's a limit of email keys in general. If it shows up in your data, add a second key such as a confirmed phone number to the same table.

Why a separate trial_claims table

You could put a unique signup_email_key column on users. A separate table has two advantages. The claim outlives the account: if someone deletes their account and signs up again, the row in trial_claims is still there, which is the whole point. (Keep the foreign key as RESTRICT, or drop it if you hard delete users.) And adding another kind of key later, a phone number or a card fingerprint, is just another row with a prefix like phone:+442079460958, with no migration on users.

The unique index is what does the work. Looking first with findFirst or a SELECT and then inserting leaves a gap: two requests can both look at the same moment, both see nothing, and both grant credits. With a unique index, the second insert waits for the first transaction and then fails or does nothing, and that is the whole race handling.

Here is the PostgreSQL schema the tests use:

CREATE TABLE IF NOT EXISTS users (
  id SERIAL PRIMARY KEY,
  email TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS trial_claims (
  id SERIAL PRIMARY KEY,
  claim_key TEXT NOT NULL UNIQUE,
  user_id INTEGER NOT NULL REFERENCES users(id),
  claimed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS credit_grants (
  id SERIAL PRIMARY KEY,
  user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  amount INTEGER NOT NULL,
  reason TEXT NOT NULL
);

node-postgres

INSERT ... ON CONFLICT (claim_key) DO NOTHING RETURNING id returns no row when the key is taken, so there is no exception to catch for the normal case. The grant only runs when a row comes back, inside the same transaction. Note that the whole thing runs on one client from the pool; pool.query("BEGIN") would put each statement on whichever connection is free.

// src/grant-pg.ts
import type { Pool } from "pg";

export const TRIAL_CREDITS = 500;

/** Returns true if credits were granted. Call it after the user row exists. */
export async function grantTrial(pool: Pool, userId: number, key: string | null): Promise<boolean> {
  if (key === null) return false;
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    const claim = await client.query(
      `INSERT INTO trial_claims (claim_key, user_id) VALUES ($1, $2)
       ON CONFLICT (claim_key) DO NOTHING
       RETURNING id`,
      [key, userId],
    );
    if (claim.rowCount === 0) {
      // Someone already holds this key. Nothing was written.
      await client.query("ROLLBACK");
      return false;
    }
    await client.query(
      "INSERT INTO credit_grants (user_id, amount, reason) VALUES ($1, $2, $3)",
      [userId, TRIAL_CREDITS, "signup_trial"],
    );
    await client.query("COMMIT");
    return true;
  } catch (err) {
    await client.query("ROLLBACK");
    throw err;
  } finally {
    client.release();
  }
}

Prisma

The models, mapped onto the same tables:

generator client {
  provider = "prisma-client"
  output   = "../src/generated/prisma"
}

datasource db {
  provider = "postgresql"
}

model User {
  id      Int           @id @default(autoincrement())
  email   String
  claims  TrialClaim[]
  grants  CreditGrant[]

  @@map("users")
}

model TrialClaim {
  id        Int      @id @default(autoincrement())
  claimKey  String   @unique @map("claim_key")
  userId    Int      @map("user_id")
  user      User     @relation(fields: [userId], references: [id], onDelete: Restrict)
  claimedAt DateTime @default(now()) @map("claimed_at") @db.Timestamptz

  @@map("trial_claims")
}

model CreditGrant {
  id     Int    @id @default(autoincrement())
  userId Int    @map("user_id")
  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
  amount Int
  reason String

  @@map("credit_grants")
}

@unique on claimKey becomes the unique index in the migration. The grant uses an interactive transaction. When the claim insert hits the index, Prisma throws P2002, the transaction rolls back, and neither row is written.

// src/prisma/grant.ts
import { Prisma, PrismaClient } from "../generated/prisma/client";

export const TRIAL_CREDITS = 500;

/** Returns true if credits were granted. Call it after the user is created. */
export async function grantTrial(prisma: PrismaClient, userId: number, key: string | null): Promise<boolean> {
  if (key === null) return false;
  try {
    await prisma.$transaction(async (tx) => {
      await tx.trialClaim.create({ data: { claimKey: key, userId } });
      await tx.creditGrant.create({ data: { userId, amount: TRIAL_CREDITS, reason: "signup_trial" } });
    });
    return true;
  } catch (err) {
    if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
      return false; // unique index on claim_key fired; the transaction rolled back
    }
    throw err;
  }
}

This was tested with Prisma 7 and the @prisma/adapter-pg driver adapter. The error code is the same on Prisma 5 and 6 with the built in engine.

Drizzle

The schema:

// src/drizzle/schema.ts
import { integer, pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";

export const users = pgTable("users", {
  id: serial("id").primaryKey(),
  email: text("email").notNull(),
});

export const trialClaims = pgTable("trial_claims", {
  id: serial("id").primaryKey(),
  claimKey: text("claim_key").notNull().unique(),
  userId: integer("user_id").notNull().references(() => users.id),
  claimedAt: timestamp("claimed_at", { withTimezone: true }).notNull().defaultNow(),
});

export const creditGrants = pgTable("credit_grants", {
  id: serial("id").primaryKey(),
  userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
  amount: integer("amount").notNull(),
  reason: text("reason").notNull(),
});

Drizzle has onConflictDoNothing, so the grant reads much like the node-postgres version. Returning false from the callback just ends the transaction normally; there's nothing to roll back because nothing was written.

// src/drizzle/grant.ts
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import { creditGrants, trialClaims } from "./schema";

export const TRIAL_CREDITS = 500;

/** Returns true if credits were granted. Call it after the user row exists. */
export async function grantTrial(db: NodePgDatabase, userId: number, key: string | null): Promise<boolean> {
  if (key === null) return false;
  return db.transaction(async (tx) => {
    const claimed = await tx
      .insert(trialClaims)
      .values({ claimKey: key, userId })
      .onConflictDoNothing({ target: trialClaims.claimKey })
      .returning({ id: trialClaims.id });
    if (claimed.length === 0) return false; // already claimed, nothing to grant

    await tx.insert(creditGrants).values({ userId, amount: TRIAL_CREDITS, reason: "signup_trial" });
    return true;
  });
}

better-sqlite3

If you're on SQLite, better-sqlite3's db.transaction() wraps a function, commits when it returns and rolls back when it throws. .immediate() takes the write lock at the start, which avoids a busy error when several connections write at once.

// src/grant-sqlite.ts
import Database from "better-sqlite3";

export const TRIAL_CREDITS = 500;

export const SCHEMA = `
CREATE TABLE IF NOT EXISTS users (
  id INTEGER PRIMARY KEY,
  email TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS trial_claims (
  id INTEGER PRIMARY KEY,
  claim_key TEXT NOT NULL UNIQUE,
  user_id INTEGER NOT NULL REFERENCES users(id),
  claimed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS credit_grants (
  id INTEGER PRIMARY KEY,
  user_id INTEGER NOT NULL REFERENCES users(id),
  amount INTEGER NOT NULL,
  reason TEXT NOT NULL
);
`;

export function grantTrial(db: Database.Database, userId: number, key: string | null): boolean {
  if (key === null) return false;
  const insertClaim = db.prepare("INSERT INTO trial_claims (claim_key, user_id) VALUES (?, ?)");
  const insertGrant = db.prepare("INSERT INTO credit_grants (user_id, amount, reason) VALUES (?, ?, ?)");

  // db.transaction() commits when the function returns and rolls back if it throws.
  const claimAndGrant = db.transaction(() => {
    insertClaim.run(key, userId);
    insertGrant.run(userId, TRIAL_CREDITS, "signup_trial");
  });

  try {
    claimAndGrant.immediate();
    return true;
  } catch (err) {
    if (err instanceof Database.SqliteError && err.code === "SQLITE_CONSTRAINT_UNIQUE") {
      return false; // key already claimed, both inserts rolled back
    }
    throw err;
  }
}

SQLite also supports INSERT ... ON CONFLICT DO NOTHING RETURNING, so you can use the node-postgres shape here too if you prefer it. I kept the exception version to show the error code.

A Next.js route handler

The route creates the user first, then tries the grant. A taken key still gets a working account, just with no trial credits, so the person who already had one isn't blocked from signing in on a second address.

// app/api/signup/route.ts
import { pool } from "../../../src/db";
import { emailKey } from "../../../src/email-key";
import { grantTrial } from "../../../src/grant-pg";

export async function POST(request: Request) {
  const body = await request.json().catch(() => null);
  const email = typeof body?.email === "string" ? body.email.trim() : "";
  if (!email.includes("@")) {
    return Response.json({ error: "Please enter an email address." }, { status: 400 });
  }

  // Store the address as typed. The key is only for matching trial claims.
  const { rows } = await pool.query<{ id: number }>(
    "INSERT INTO users (email) VALUES ($1) RETURNING id",
    [email],
  );
  const userId = rows[0].id;
  const trialGranted = await grantTrial(pool, userId, emailKey(email));

  return Response.json({ userId, trialGranted }, { status: 201 });
}

The pool lives in its own module so every request reuses it:

// src/db.ts
import { Pool } from "pg";

export const pool = new Pool({
  connectionString: process.env.DATABASE_URL ?? "postgresql://trial:trial@localhost:5432/trial",
});

If you want the user insert and the claim to commit or fail together, move the INSERT INTO users onto the same client inside grantTrial. I kept them apart here on purpose, because an account without credits is the outcome I wanted when the key is taken.

The tests

The four signups used across the tests are jane.doe@gmail.com, janedoe+2@gmail.com, Jane.Doe@googlemail.com and sam@example.com. Expected result: credits for the first and the last, none for the two in the middle, four accounts either way.

// test/pg.test.ts
test("node-postgres: one trial per key", async () => {
  const results = [];
  for (const email of SIGNUPS) {
    results.push(await grantTrial(pool, await addUser(pool, email), emailKey(email)));
  }
  expect(results).toEqual(EXPECTED);
  expect(await count(pool, "users")).toBe(4);
  expect(await count(pool, "credit_grants")).toBe(2);
  expect(await count(pool, "trial_claims")).toBe(2);
});

The concurrency test is the one I cared about most. It creates eight users whose addresses all normalise to janedoe@gmail.com, then fires all eight grants at once with Promise.all. The pool opens up to ten connections, so these are separate transactions racing on the unique index in Postgres:

// test/pg.test.ts
test("node-postgres: eight concurrent claims, one winner", async () => {
  const emails = Array.from({ length: 8 }, (_, i) => `jane.doe+${i}@gmail.com`);
  const ids = [];
  for (const e of emails) ids.push(await addUser(pool, e));

  const results = await Promise.all(ids.map((id, i) => grantTrial(pool, id, emailKey(emails[i]))));

  expect(results.filter(Boolean)).toHaveLength(1);
  expect(await count(pool, "credit_grants")).toBe(1);
  expect(await count(pool, "trial_claims")).toBe(1);
});

Exactly one call wins and exactly one credit row exists. The same race runs against the Prisma and Drizzle versions, and against the route handler with six simultaneous requests:

// test/route.test.ts
test("route handler: concurrent signups", async () => {
  const responses = await Promise.all(
    Array.from({ length: 6 }, (_, i) => signup(`janedoe+${i}@gmail.com`)),
  );
  const bodies = await Promise.all(responses.map((r) => r.json()));
  expect(responses.every((r) => r.status === 201)).toBe(true);
  expect(bodies.filter((b) => b.trialGranted)).toHaveLength(1);
  expect(await count(pool, "users")).toBe(6);
});

For better-sqlite3, which is synchronous, Promise.all wouldn't race anything, so that test starts eight worker threads, each with its own connection to one database file in WAL mode. They wait on a shared Atomics.wait gate and all call grantTrial when it opens. One gets true, seven get false.

What this won't stop

Someone with a fresh inbox on a new domain gets a fresh free tier. That's fine for most products; the aim is to make repeat claims cost more effort than the credits are worth, not to make them impossible. It also does nothing about throwaway inbox services on their own, so pair it with a domain check at signup if those show up in your data.

Tested with

Node.js 24.21.0, TypeScript 6.0.3, vitest 5.0.4, PostgreSQL 17, pg 8.23.1, Prisma 7.10.0 with @prisma/adapter-pg, drizzle-orm 0.45.4, better-sqlite3 13.0.3 and Next.js 16.4.0 (the route handler is called directly with a Request). 26 tests in 6 files, all passing, and the TypeScript compiler reports no errors.

I built Orisift (https://orisift.com/x?m=post&c=hashnode-node-trial&to=%2F), which does this key plus a throwaway domain check in one call.