You described the app, the agent built it, and sign-up works in the preview. Then you share the link, a friend creates an account, and the confirmation email never shows up. Or it shows up, and the button opens localhost:3000.
It's rarely the code. Lovable and Bolt handle sign-up with Supabase Auth or something built on it, and so do a lot of apps built with Cursor or Claude Code. The settings that decide whether an email arrives sit in a dashboard and in your domain's DNS, and your AI agent usually can't see either one. It can rewrite the sign-up page five times without fixing anything. This guide walks through the six fixes in order. Each step has a prompt you can paste into your builder, and the code is there for anyone who wants to check the work.
| What you see | Most likely cause | Step |
|---|---|---|
| You get the emails, your users don't | Supabase's built-in sender only delivers to your own team | 1 |
| The first one or two sign-ups get an email, then nothing | An hourly sending limit | 1 |
| The email button opens localhost:3000 | The Site URL is still the default | 2 |
| Sign-in works in preview, fails on the live site | Live URL missing from the redirect list | 2 |
| Emails come from a Supabase address | No sending domain of your own yet | 3 |
| You saved SMTP settings in Bolt and nothing changed | The database is still Bolt-managed | 3 |
| Emails land in spam | Missing SPF, DKIM, or DMARC records | 4 |
| "Token has expired or is invalid" on the first click | A link scanner opened the link first | 5 |
| Template edits don't show up in Lovable | The templates need a redeploy | 6 |
Start with the row that matches what your users report.
The six fixes
Find out who is sending your email
Every platform starts you on a shared sender with tight limits.
Point the links at your live site
One setting decides where every confirmation link goes.
Send from your own domain
Through your builder's email feature or custom SMTP.
Add the three DNS records
SPF, DKIM, and DMARC, checked with a short script.
Switch the link to a code
So security scanners can't use it up before your user does.
Make the email look like yours
A clear subject, your name in the From line, and no marketing.
Step 1: Find out who is sending your email
Every builder gives you a default sender so sign-up works on day one. None of them are meant for real users, and each one fails in its own way:
| Your setup | Who sends by default | The limit you hit first |
|---|---|---|
| Your own Supabase project (common with Cursor, Claude Code, v0) | Supabase's built-in SMTP | Only delivers to members of your Supabase team, and 2 emails per hour |
| Supabase with custom SMTP | Your email provider | 30 emails per hour until you raise it under Rate Limits |
| Lovable Cloud, free plan | The default Lovable Cloud sender | Sending from your own domain needs a paid plan |
| Lovable Cloud, paid plan | Your domain, through Lovable's Emails | Pro: 500 auth and 100 app emails per hour; 50,000 a month per workspace |
| Bolt database | Supabase's default address | Custom SMTP only works after you claim the database |
| Resend, free plan | Your verified domain | 100 emails a day, 3,000 a month |
From the Supabase, Lovable, Bolt, and Resend docs, checked October 3, 2026.
The first row explains most "my users never got the email" reports. Supabase's custom SMTP pagesays it plainly: without your own SMTP server, Supabase Auth "will refuse to deliver messages to addresses that are not part of the project's team," and those sign-ups fail with Email address not authorized. You tested with your own address, which is on the team, so it worked for you.
Paste this into your builder to find out where you stand:
Don't change any code yet. Tell me:
1. Which service sends my sign-up, password reset, and magic link emails
(built-in Supabase SMTP, Lovable Cloud, custom SMTP, or something else).
2. The From address those emails use.
3. Every place in the code that calls signUp, signInWithOtp,
resetPasswordForEmail, or sends email in another way.
4. Which settings for this live in a dashboard instead of the code,
so I can check them myself.Step 2: Point the links at your live site
If the button in the email opens localhost:3000, your Site URL is still the default. Supabase's redirect URL guide says to change it from http://localhost:3000to your production URL, and calls the setting "critical for email confirmations and password resets." In Supabase it's under Authentication, then URL Configuration. Bolt fills it in for you under Database, Authentication, Advanced, so check it there only if you've changed it.
The second half is the redirect allow list. When your code asks Supabase to send people back to a specific page, that address has to be on the list, or Supabase uses the Site URL instead. That's why Lovable's troubleshooting pagelists "sign-in works in preview but fails on the published app": the published URL was never added.
Confirmation emails link to localhost instead of my live site,
which is https://myapp.com.
1. Tell me where to set the auth Site URL to https://myapp.com,
or set it if you have access.
2. Add https://myapp.com/** and my preview URL to the allowed redirect URLs.
3. Find every signUp, signInWithOtp, and resetPasswordForEmail call.
Make emailRedirectTo / redirectTo use window.location.origin
instead of a hard-coded localhost URL.
Don't change anything else.Supabase accepts wildcards here, and ** matches any path, which is handy for preview deployments. For the live site, the docs recommend listing the exact URL.
Step 3: Send from your own domain
This is the step that fixes the delivery limit for good. Which route you take depends on the builder.
Lovable on a paid plan has email built in. Its Emails featuresends auth and app emails from a domain you own, sets up SPF, DKIM, and DMARC for you, and includes 50,000 emails a month per paid workspace. You don't need a separate email provider. Lovable suggests a sending subdomain like notify.yourdomain.comso problems there don't affect your main domain.
Everything on Supabase (your own project, a claimed Bolt database, most Cursor and Claude Code apps) uses custom SMTP. With Resend, the values are:
| Field | Value |
|---|---|
| Host | smtp.resend.com |
| Port | 465 |
| Username | resend |
| Password | A Resend API key you create just for this |
| Sender email | An address on the domain you verified, like no-reply@notify.myapp.com |
Resend's SMTP settings. Other providers work too; Supabase's docs also list SES, Postmark, SendGrid, ZeptoMail, and Brevo.
Then open Authentication, Rate Limits in Supabase. Once custom SMTP is on, the limit starts at 30 emails an hour. It allows a short burst, but a launch post that brings in a hundred sign-ups in an hour will run past it, and Supabase answers the extra requests with a 429 error instead of an email.
Bolt users:if your project uses a Bolt-managed database, claim it into your own Supabase account first (Database, Advanced, Claim). Bolt's email guidewarns that without this, SMTP settings "will appear to save correctly but auth emails will continue to come from Supabase's default address."
Step 4: Add the three DNS records
Gmail's sender guidelines have required SPF or DKIM from every sender since February 2024, and bulk senders need both plus DMARC. Your provider gives you the exact records when you add a domain. Here is what each one does, in plain terms:
- SPF lists the servers allowed to send for your domain. Resend puts it on a
sendsubdomain, next to an MX record with the same name. - DKIMis a signature on every email that proves it wasn't changed and really came from you. Resend's lives at
resend._domainkey. - DMARC tells inboxes what to do with mail that fails the other two. Starting with
v=DMARC1; p=none;is fine while you check that everything passes.
The most common mistake is in the record name. Many DNS dashboards, Cloudflare included, add your domain automatically, so typing send.myapp.com creates send.myapp.com.myapp.com. Type send. To see what's actually published, run this script, or ask your agent to run it for you:
// Usage: npx tsx scripts/check-email-dns.ts yourdomain.com
// Defaults match Resend: SPF on the "send" subdomain, DKIM selector "resend".
import { resolveTxt } from "node:dns/promises";
const [domain, returnPath = "send", dkimSelector = "resend"] = process.argv.slice(2);
if (!domain) {
console.error("Usage: npx tsx scripts/check-email-dns.ts <domain> [return-path] [dkim-selector]");
process.exit(1);
}
async function txt(host: string): Promise<string[]> {
try {
const records = await resolveTxt(host);
return records.map((chunks) => chunks.join(""));
} catch {
return []; // No such name, or no TXT records on it.
}
}
// A subdomain without its own DMARC record uses the parent domain's.
async function findDmarc(name: string): Promise<{ host: string; record?: string }> {
const hosts = [`_dmarc.${name}`, `_dmarc.${name.split(".").slice(-2).join(".")}`];
for (const host of new Set(hosts)) {
const record = (await txt(host)).find((value) => value.startsWith("v=DMARC1"));
if (record) return { host, record };
}
return { host: hosts[0]! };
}
const spfHost = `${returnPath}.${domain}`;
const dkimHost = `${dkimSelector}._domainkey.${domain}`;
const dmarc = await findDmarc(domain);
// An empty "p=" means the key was revoked, so it doesn't count.
const hasDkimKey = (value: string) => /(^|;)\s*p=[A-Za-z0-9+/]/.test(value);
const checks = [
{ name: "SPF", host: spfHost, record: (await txt(spfHost)).find((v) => v.startsWith("v=spf1")) },
{ name: "DKIM", host: dkimHost, record: (await txt(dkimHost)).find(hasDkimKey) },
{ name: "DMARC", host: dmarc.host, record: dmarc.record },
];
for (const { name, host, record } of checks) {
const status = record ? "OK " : "MISSING";
console.log(`${status} ${name.padEnd(5)} ${host}${record ? ` ${record.slice(0, 50)}` : ""}`);
}
if (checks.some((check) => !check.record)) process.exitCode = 1;Against resend.com it prints three OK lines. Against example.comit reports SPF and DKIM as missing, including a DKIM record that exists but has an empty key. That's the one people miss when they check by eye. DNS changes can take a few hours to show up, so a fresh MISSING isn't a reason to start over.
A new domain also starts with no reputation, so some of the first emails may still land in spam even with all three records in place. That improves with steady, low-complaint sending. The SPF, DKIM, and DMARC guide covers the records in more depth, and the domain warmup guide covers the first few weeks.
Step 5: Switch the link to a code
Some users click the confirmation link and get "Token has expired or is invalid" the very first time. Company email systems often open every link in an incoming message to check it for malware. That first visit uses up the one-time link before the person ever sees it. Supabase's email template docsname Microsoft Defender's Safe Links as an example, and their first suggested fix is to send a code instead.
A 6-digit code can't be used up by a scanner, because someone has to type it. The template variable is {{ .Token }}. This is the template body for Confirm signup, which you can paste into Supabase (Authentication, Email Templates) or Bolt (Database, Authentication, Email, Edit email templates):
<p>Hi,</p>
<p>Enter this code in the app to confirm your email address:</p>
<p style="font-size:32px;font-weight:700;letter-spacing:6px;font-family:Menlo,Consolas,monospace;margin:24px 0;">
{{ .Token }}
</p>
<p>If you didn't create an account, you can ignore this email.</p>The app then needs a screen where people type the code. This is the part to hand to your agent, or to write yourself:
import type { SupabaseClient } from "@supabase/supabase-js";
export type CodeResult = { ok: true } | { ok: false; message: string };
// The code comes from {{ .Token }} in the email. A link scanner can't use it up.
export async function verifyEmailCode(
supabase: SupabaseClient,
email: string,
code: string,
): Promise<CodeResult> {
const token = code.replace(/\s/g, "");
if (!/^\d+$/.test(token)) {
return { ok: false, message: "Enter the code from the email, digits only." };
}
const { error } = await supabase.auth.verifyOtp({ email, token, type: "email" });
if (error) {
return { ok: false, message: "That code is wrong or has expired. Request a new one." };
}
return { ok: true };
}
export async function resendSignupCode(
supabase: SupabaseClient,
email: string,
): Promise<CodeResult> {
const { error } = await supabase.auth.resend({ type: "signup", email });
// Resends are rate limited; Supabase's message says when to try again.
if (error) return { ok: false, message: error.message };
return { ok: true };
}type: "email" is the current value for sign-up and sign-in codes. The older signup and magiclinktypes are deprecated. The resend function matters more than it looks. Lovable's docs point out that signing in again doesn't resend a lost confirmation email, and their workaround is to sign up again, which few users will think to try. A "Send a new code" button is easier.
Change email confirmation from a link to a 6-digit code.
1. After sign-up, show a "Check your email" screen with a code input
and a "Send a new code" button.
2. Verify with supabase.auth.verifyOtp({ email, token, type: "email" }).
3. Resend with supabase.auth.resend({ type: "signup", email }) and show
Supabase's error message if it's rate limited.
4. Tell me what to change in the "Confirm signup" email template
so it shows {{ .Token }} instead of the link.Step 6: Make the email look like yours
Once mail is arriving, the default template is the next problem. A plain "Confirm your signup" from an address people don't recognize looks like phishing, and people hesitate before clicking anything in it. What helps:
- A From name that matches your product, on your own domain.
- A subject that says what the email is: "Your MyApp confirmation code."
- Your logo and one brand color, on a plain background.
- No marketing. Supabase and Lovable both advise keeping promotions out of auth emails, and Supabase suggests a separate sending domain for marketing so one can't damage the other.
Lovable's email templates are React Email components with inline styles. Supabase and Bolt templates are HTML with variables like {{ .Token }} and {{ .ConfirmationURL }}. Either way, you can start from a finished design instead of asking the agent to invent one. Give it a React Email template, or paste an HTML version into the template editor and swap in the variables. If your Lovable edits don't appear, its docs say some projects need a redeploy: ask it to "Redeploy my email templates."
Update all auth email templates (confirm signup, magic link,
reset password, change email):
- From name "MyApp", subject lines that say what each email is
- Our logo (https://myapp.com/logo.png) and brand color #4f46e5
on a plain white background
- One clear button or code per email, no marketing text
Use the template I'm pasting below as the layout.What you have now
Emails go to everyone, not just your team. Links open your live site. Mail comes from your domain with SPF, DKIM, and DMARC in place, and the hourly limit fits a launch day. Confirmation uses a code that scanners can't break, and the email looks like it came from your product.
Test it like a stranger would
Your own inbox is the worst test, because it's on the team and has already received a hundred emails from the app. Before you post the launch link, sign up with addresses that have never seen your app: a new Gmail account, an Outlook.com account, and a work address if you can get one, since that's where link scanners live. Check the spam folder each time, and open the email on a phone. The email verification teardowngoes deeper on what a good confirmation email contains, and if you're a developer on Next.js, the Supabase auth emails with React Email guide shows how to send these from your own code with a Send Email hook.
- Default senders only work for you. Supabase's delivers to your team only, 2 emails an hour.
- Set the Site URL to your live domain and add it to the redirect list.
- Send from your own domain, then raise the hourly limit before launch.
- Check SPF, DKIM, and DMARC with a script, not by eye.
- Use a 6-digit code so link scanners can't break confirmation, and add a resend button.
- Test with fresh Gmail, Outlook, and work addresses before you share the link.