The Problem
A paid Facebook ad brought in interest from a large number of people who wanted to join the platform. Each lead arrived as just a name and an email. To onboard them, a teammate was opening the signup form and typing in one lead at a time to send each person a signup magic link.
That approach has three problems:
- It doesn't scale. Hand-entering a large batch is slow and tedious.
- It's error-prone. Manual copy-paste drops and mistypes addresses.
- It's blind to the rate limit. The email provider behind Supabase Auth caps how many emails go out per hour. A human clicking fast has no idea when they've crossed it — and crossing it silently breaks signups.
The team needed to invite the whole list in one shot, safely, without anyone babysitting a form.
The Approach
The script is deliberately thin. It holds no Supabase service-role key and talks to no database directly. It does exactly one thing: fetch() against the deployed /api/auth/signup/jobseeker endpoint — the same route the real signup modal calls.
That's the central design decision. The endpoint already owns account creation, duplicate detection, jobseeker-profile creation, and the magic-link send. Reimplementing any of that inside a one-off script would duplicate production logic and invite drift the moment the route changes. So the script stays a dumb, safe caller and inherits every guarantee the route already provides.
Around that single call sit four safety layers:
- Dry-run by default. With no
APPLY=true, the script parses, dedupes, diffs against the sent-log, prints exactly who would be contacted, writes a plan file, and makes zero HTTP calls. Nobody emails real people by accident. - An on-disk sent-log for idempotency. Every successful (or already-existing) address is appended to
backfill-output/invite-fb-leads-sent.jsonimmediately, so a crash, aCtrl-C, or a deliberate stop mid-batch resumes cleanly — already-invited people are never double-emailed. - A configurable throttle (
DELAY_MS) between sends, so the run can be tuned to stay under the Auth email rate limit. - A masked-failure guard — the part that took the most thought.
The signup route creates the auth user first, then calls signInWithOtp to send the link. When the email provider rate-limits that send, the route doesn't return a 429. It catches the error and returns a generic 400 "Failed to send magic link". From the script's side, that looks like an ordinary validation failure — but the account already exists. A loop that simply logged the 400 and moved on would manufacture a large number of accounts that exist with no usable login link, and a re-run would treat each one as "already exists" and skip it permanently. So the guard pattern-matches that masked failure (plus the half-create 500 "Account created but..."), halts the entire batch, records those addresses in a separate created_link_not_sent list, and — critically — does not add them to the sent-log, so they remain re-invitable after the rate limit is raised.
- 1
Parse & dedupe CSV
BOM strip, quoted-field aware, header auto-detect; lowercase + dedupe emails within the file
- 2
Diff against sent-log
Load prior runs from disk; drop anyone already invited — this is what makes re-runs safe
- 3
Dry-run gate
Default mode: print the recipient count + first few, write a plan file, make zero HTTP calls
- 4
Throttled POST per lead
APPLY=true: POST {name, email, locale} to /api/auth/signup/jobseeker, sleep DELAY_MS between sends
- 5
Classify & guard
ok / already-exists / masked-send-failure (STOP) / network-or-4xx-fail — each handled distinctly
- 6
Persist sent-log + summary
Append each success to disk immediately; write a final summary with sent / skipped / failed / created-no-link counts
The Code
Three pieces carry the design: the masked-failure guard, the per-success idempotency write, and the dry-run gate. All three are verbatim from the shipped script.
The masked-failure guard — stop, don't strand
The signup route creates the auth user before sending the magic link and masks the email provider's 429 as a 400 "Failed to send magic link". This branch catches that masked shape (and the half-create 500), records the address in a separate list, and breaks the loop — those addresses are intentionally never written to the sent-log, so they stay re-invitable.
} else if (
status === 429 ||
(status >= 400 &&
(bodyText.includes("Failed to send magic link") ||
bodyText.includes("Account created but") ||
bodyText.toLowerCase().includes("rate limit")))
) {
// Route creates the auth user BEFORE sending the link and masks GoTrue 429s as
// 400 "Failed to send magic link". A rate-limited/failed send leaves an account
// that EXISTS with no usable link. Do NOT add to sent-log (a re-run would only
// mark it "exists" and skip — the link never gets sent). Stop: budget is spent;
// continuing only creates more half-provisioned accounts.
createdNoLink.push({
name: lead.name,
email: lead.email,
status,
body: bodyText.slice(0, 500),
});
rateLimited = true;
console.log(`${label} STOP: account created, magic link NOT sent`);
console.error(
"\nMagic-link send failed (likely Auth email rate limit). Stopping.\n" +
"Accounts in created_link_not_sent[] EXIST but got no link — raise Auth → Rate\n" +
"Limits / increase DELAY_MS, then resend a link to JUST those addresses\n" +
"(they are NOT retried automatically — the sent-log excludes them).",
);
break;
}Idempotency — persist after each success
Both a successful send and an "already exists" response write the email to the sent-log immediately, not at the end. Any interruption resumes from exactly where it stopped, and nobody is double-emailed.
if (status === 200) {
sent.add(lead.email);
writeSentLog(sent);
okCount++;
console.log(`${label} ok`);
} else if (status === 400 && bodyText.includes("already exists")) {
sent.add(lead.email);
writeSentLog(sent);
existsCount++;
console.log(`${label} exists`);
}Dry-run gate — zero HTTP calls by default
Without APPLY=true, the script computes the exact recipient set (CSV minus the sent-log), previews it, writes a plan file, and returns before any network call. Sending real email to real people is strictly opt-in.
const sent = loadSentLog();
const remaining = unique.filter((l) => !sent.has(l.email));
const alreadyDone = unique.length - remaining.length;
if (!APPLY) {
const preview = remaining.slice(0, 5).map((l) => l.email);
if (preview.length > 0) {
console.log(`\nFirst ${preview.length}: ${preview.join(", ")}`);
}
writeFileSync(PLAN_PATH, JSON.stringify({ /* plan */ }, null, 2));
console.log(`\nDry-run — 0 HTTP calls. Plan written to ${PLAN_PATH}.`);
return;
}Trade-offs
Most of these are about what the script deliberately does not do — the restraint is the point.
Chose
Call the live endpoint, not a service-role client
The script holds no Supabase admin key and writes no rows directly. The signup route already owns account creation, dedupe, profile creation, and the magic-link send. A script that reimplemented that would duplicate production logic and drift the moment the route changed. Staying a thin fetch() caller means the tool inherits every guarantee the route provides — including future fixes — for free.
Stop on the first masked send-failure
Because the route creates the user before sending the link, a failed send leaves an account that exists but can't log in. Continuing the loop would multiply that damage across a large list. Halting on the first occurrence caps the blast radius at one, and the sent-log exclusion makes the eventual resume clean.
An on-disk JSON sent-log over a database table
This is a one-off ops tool. A gitignored JSON file under backfill-output/ gives full idempotency and resumability with zero schema footprint on the production database — no migration, no table to clean up afterward.
Dry-run as the default, sending as the opt-in
The destructive action here is emailing real people, which can't be undone. Making APPLY=true an explicit gate — with a zero-HTTP preview of the exact recipient set first — means a mistaken invocation prints a plan instead of blasting a campaign.
Skipped
Automatic in-loop retry of failed sends
A masked 429 means the hourly email budget is spent — retrying immediately just burns more of a budget that's already gone and creates more half-provisioned accounts. The correct recovery is human: raise the Auth rate limit (or increase the throttle), then resend to exactly the created_link_not_sent list. So retry was deliberately left out.
Outcomes
Qualitative, because the lead count is private — but the operational wins are concrete.
Eliminated — whole batch in a couple of minutes
Manual form entry
Idempotent — already-invited skipped
Re-runs
Zero — stop-on-masked-failure guard
Stranded accounts
None — thin endpoint caller, no DB writes
Production schema touched
None — CSV path gitignored, no data in code
Lead PII in the repo