From 7548ea75d4b83c9c97de0d06c2854f22c46db684 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Mon, 28 Sep 2026 15:43:26 +0200 Subject: [PATCH] fix: skip email verification entirely when no mail transport exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SMTP-less self-hosted instances still marked every account emailVerified=false and attempted a verification mail that could never arrive. Now: no transport → accounts are created verified, the send is skipped, and existing unverified rows are backfilled on startup. .env.example stops defaulting SMTP_HOST to the dev-only mailpit service. Verified live: fresh signup returns emailVerified=true, legacy unverified users flip on restart, sign-in unaffected. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .env.example | 6 +++++- CHANGELOG.md | 4 ++++ apps/auth/src/index.ts | 3 ++- apps/auth/src/lib/auth.ts | 44 +++++++++++++++++++++++++++------------ apps/auth/src/lib/mail.ts | 8 +++++++ 5 files changed, 50 insertions(+), 15 deletions(-) diff --git a/.env.example b/.env.example index 76a8353..3562127 100644 --- a/.env.example +++ b/.env.example @@ -77,9 +77,13 @@ BACKEND_STORAGE_DRIVER=local # Mail defaults — each is overridable in-app (Settings → Instance). In-app # values win over these env vars; deleting the in-app value reverts here. +# With no transport configured (no Resend key and no SMTP_HOST) accounts are +# verified automatically and no verification mail is attempted. +# The dev overlay (docker compose -f docker-compose.yml -f docker-compose.dev.yml) +# runs Mailpit — then SMTP_HOST=mailpit catches all outgoing mail at :8025. RESEND_API_KEY= MAIL_FROM=Primora -SMTP_HOST=mailpit +SMTP_HOST= SMTP_PORT=1025 SMTP_USER= SMTP_PASSWORD= diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b0b5d4..7dbfb5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepac ## [Unreleased] +### Fixed + +- **SMTP-less instances no longer involve email at all** — when no mail transport is configured (no Resend key, no `SMTP_HOST`), new accounts are created `emailVerified=true`, the verification send is skipped entirely, and existing unverified users are marked verified on startup. `.env.example` no longer defaults `SMTP_HOST` to the dev-only `mailpit` service, which left production deploys pointing at a transport that does not exist. + ## [0.8.0] - 2026-09-28 ### Added diff --git a/apps/auth/src/index.ts b/apps/auth/src/index.ts index 3dbd969..6e769fb 100644 --- a/apps/auth/src/index.ts +++ b/apps/auth/src/index.ts @@ -5,7 +5,7 @@ import { requestId } from "hono/request-id"; import { HTTPException } from "hono/http-exception"; import { createClient } from "redis"; -import { auth, promoteAdminEmails, runAuthMigrations } from "./lib/auth.js"; +import { auth, promoteAdminEmails, runAuthMigrations, verifyUsersWithoutMailTransport } from "./lib/auth.js"; import { authPool } from "./lib/db.js"; import { env } from "./lib/env.js"; @@ -20,6 +20,7 @@ try { } await retry("auth_migrations", runAuthMigrations); +await verifyUsersWithoutMailTransport(); await promoteAdminEmails(); const app = new Hono(); diff --git a/apps/auth/src/lib/auth.ts b/apps/auth/src/lib/auth.ts index ff8abab..2357413 100644 --- a/apps/auth/src/lib/auth.ts +++ b/apps/auth/src/lib/auth.ts @@ -4,7 +4,7 @@ import { admin, jwt } from "better-auth/plugins"; import { getMigrations } from "better-auth/db/migration"; import { authPool } from "./db.js"; -import { sendTransactionalEmail } from "./mail.js"; +import { hasMailTransport, sendTransactionalEmail } from "./mail.js"; import { getBoolSetting } from "./settings.js"; import { env } from "./env.js"; @@ -72,20 +72,29 @@ export const auth = betterAuth({ // Gates only the public sign-up endpoint. Users created through the // admin plugin or OAuth callbacks take different paths and pass. async before(user, ctx) { - if (ctx?.path !== "/sign-up/email") return; - const { rows } = await authPool.query<{ n: number }>( - `select count(*)::int as n from "user"`, - ); - if (rows[0].n === 0) { - // Bootstrap: the first public signup becomes the instance admin. - return { data: { ...user, role: "admin" } }; + const data = { ...user }; + // Without a mail transport the verification mail can never arrive — + // mark every created account verified so nothing downstream nags + // "unverified" on users nobody can verify. + if (!(await hasMailTransport())) { + data.emailVerified = true; } - const enabled = await getBoolSetting("auth.signup_enabled", env.SIGNUP_ENABLED); - if (!enabled) { - throw new APIError("FORBIDDEN", { - message: "Sign-up is disabled on this instance.", - }); + if (ctx?.path === "/sign-up/email") { + const { rows } = await authPool.query<{ n: number }>( + `select count(*)::int as n from "user"`, + ); + if (rows[0].n === 0) { + // Bootstrap: the first public signup becomes the instance admin. + return { data: { ...data, role: "admin" } }; + } + const enabled = await getBoolSetting("auth.signup_enabled", env.SIGNUP_ENABLED); + if (!enabled) { + throw new APIError("FORBIDDEN", { + message: "Sign-up is disabled on this instance.", + }); + } } + return data.emailVerified === user.emailVerified ? undefined : { data }; }, }, }, @@ -94,6 +103,9 @@ export const auth = betterAuth({ sendOnSignUp: true, autoSignInAfterVerification: true, async sendVerificationEmail({ user, url }) { + // No transport at all → the mail can never arrive; the user was already + // marked verified at creation. + if (!(await hasMailTransport())) return; // Verification is optional — a misconfigured transport must not fail // sign-up or sign-in. try { @@ -143,6 +155,12 @@ export async function runAuthMigrations() { await migrations.runMigrations(); } +/** No transport → existing unverified users can never resolve that state. */ +export async function verifyUsersWithoutMailTransport() { + if (await hasMailTransport()) return; + await authPool.query(`update "user" set "emailVerified" = true where "emailVerified" = false`); +} + /** Bootstrap admins by email — run after migrations so the role column exists. */ export async function promoteAdminEmails() { const emails = (env.AUTH_ADMIN_EMAILS ?? "") diff --git a/apps/auth/src/lib/mail.ts b/apps/auth/src/lib/mail.ts index 5090e69..5a453fd 100644 --- a/apps/auth/src/lib/mail.ts +++ b/apps/auth/src/lib/mail.ts @@ -30,6 +30,14 @@ async function resolveMailConfig() { } as const; } +// True when a mail transport exists (Resend key or SMTP host). Verification +// and reset mails only make sense when mail can actually leave the box. +export async function hasMailTransport(): Promise { + const cfg = await resolveMailConfig(); + if ("resendApiKey" in cfg && cfg.resendApiKey) return true; + return "smtp" in cfg && Boolean(cfg.smtp); +} + export async function sendTransactionalEmail(input: { to: string; subject: string;