diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index bece6ed12..0056a8cb5 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -430,32 +430,39 @@ export class AuthManager { sendResetPassword: async ({ user, url, token }: { user: { id: string; email: string; name?: string }; url: string; token: string }) => { const email = this.getEmailService(); if (!email) { - console.warn( - `[AuthManager] Password-reset requested for ${user.email} but no email service is wired. URL: ${url}`, + // No transport wired but password reset is enabled — a + // misconfiguration. THROW (don't silently drop): better-auth + // invokes this via `runInBackgroundOrAwait` and the forget-password + // route always returns `{status:true}`, so this never leaks whether + // an address exists AND never turns the request into a 500 — it just + // surfaces the failure in the logs instead of vanishing. + throw new Error( + `Password-reset email could not be sent to ${user.email}: no email service is configured for this deployment.`, ); - return; } const ttlSec = this.config.emailAndPassword?.resetPasswordTokenExpiresIn ?? 60 * 60; - try { - await email.sendTemplate({ - template: 'auth.password_reset', - to: { address: user.email, ...(user.name ? { name: user.name } : {}) }, - data: { - user: { name: user.name || user.email, email: user.email, id: user.id }, - resetUrl: url, - token, - expiresInMinutes: Math.round(ttlSec / 60), - appName: this.getAppName(), - }, - relatedObject: 'sys_user', - relatedId: user.id, - }); - } catch (err: any) { - // Do NOT rethrow: the user account exists; an email-transport failure - // (missing template, bad credentials, network blip) must not turn - // the user-facing reset request into a 500. The user can retry via - // the "forgot password" flow. - console.error(`[AuthManager] sendResetPassword failed (swallowed): ${err?.message ?? err}`); + // Surface both template-resolution throws and transport failures + // (status:'failed'); resilience is preserved by better-auth's + // background-task handling (see sendVerificationEmail) and the + // forget-password route always returns {status:true}, so this never + // leaks whether an address exists nor turns the request into a 500. + const result = await email.sendTemplate({ + template: 'auth.password_reset', + to: { address: user.email, ...(user.name ? { name: user.name } : {}) }, + data: { + user: { name: user.name || user.email, email: user.email, id: user.id }, + resetUrl: url, + token, + expiresInMinutes: Math.round(ttlSec / 60), + appName: this.getAppName(), + }, + relatedObject: 'sys_user', + relatedId: user.id, + }); + if (result?.status === 'failed') { + throw new Error( + `Password-reset email could not be sent to ${user.email}: ${result.error ?? 'delivery failed'}`, + ); } }, }; @@ -475,31 +482,43 @@ export class AuthManager { sendVerificationEmail: async ({ user, url, token }: { user: { id: string; email: string; name?: string }; url: string; token: string }) => { const email = this.getEmailService(); if (!email) { - console.warn( - `[AuthManager] Verification email requested for ${user.email} but no email service is wired. URL: ${url}`, + // Verification is enabled (this callback only exists when it is) + // but no email transport is wired — a misconfiguration, not a + // transient blip. THROW so the explicit `/send-verification-email` + // resend endpoint (which awaits this) surfaces a real error + // instead of a false "email sent" success. Sign-up stays + // resilient regardless: better-auth runs the sendOnSignUp call + // through `runInBackgroundOrAwait`, which logs (never rethrows) + // a failure, so the account is still created and the user lands + // on the verify screen (where an honest resend now reports the + // problem). Previously this was swallowed, leaving every user + // permanently stuck with no signal and no resend that could work. + throw new Error( + `Verification email could not be sent to ${user.email}: no email service is configured for this deployment.`, ); - return; } const ttlSec = this.config.emailVerification?.expiresIn ?? 60 * 60; - try { - await email.sendTemplate({ - template: 'auth.verify_email', - to: { address: user.email, ...(user.name ? { name: user.name } : {}) }, - data: { - user: { name: user.name || user.email, email: user.email, id: user.id }, - verificationUrl: url, - token, - expiresInMinutes: Math.round(ttlSec / 60), - appName: this.getAppName(), - }, - relatedObject: 'sys_user', - relatedId: user.id, - }); - } catch (err: any) { - // Do NOT rethrow: the user account exists; an email-transport - // failure must not turn signup or /send-verification-email into - // a 500. The "Resend verification email" UI lets the user retry. - console.error(`[AuthManager] sendVerificationEmail failed (swallowed): ${err?.message ?? err}`); + // Let send failures propagate (see above): sendTemplate THROWS on + // template/loader errors, and returns status:'failed' on transport + // errors — surface both so resend is honest and signup stays + // resilient via better-auth's background-task error handling. + const result = await email.sendTemplate({ + template: 'auth.verify_email', + to: { address: user.email, ...(user.name ? { name: user.name } : {}) }, + data: { + user: { name: user.name || user.email, email: user.email, id: user.id }, + verificationUrl: url, + token, + expiresInMinutes: Math.round(ttlSec / 60), + appName: this.getAppName(), + }, + relatedObject: 'sys_user', + relatedId: user.id, + }); + if (result?.status === 'failed') { + throw new Error( + `Verification email could not be sent to ${user.email}: ${result.error ?? 'delivery failed'}`, + ); } }, }, diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 8b2c351aa..0a498ec82 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -230,14 +230,28 @@ export class AuthPlugin implements Plugin { if (this.authManager) { await this.bindAuthSettings(ctx); - try { - const emailSvc = ctx.getService('email'); - if (emailSvc) { - this.authManager.setEmailService(emailSvc); - ctx.logger.info('Auth: email service wired (transactional mail enabled)'); + let emailSvc: any; + try { emailSvc = ctx.getService('email'); } catch { emailSvc = undefined; } + if (emailSvc) { + this.authManager.setEmailService(emailSvc); + ctx.logger.info('Auth: email service wired (transactional mail enabled)'); + } else { + // No email service. The verification / password-reset callbacks now + // THROW when invoked without a transport (so an explicit resend + // reports a real error rather than faking success). If verification + // is REQUIRED, that means every signup would be stuck — surface the + // misconfiguration loudly at boot instead of one failure per signup. + const requiresEmail = !!this.authManager.getPublicConfig?.()?.emailPassword?.requireEmailVerification; + if (requiresEmail) { + ctx.logger.error( + 'Auth: email verification is REQUIRED but NO email service is registered — ' + + 'verification & password-reset emails will FAIL and new users will be locked ' + + 'out at sign-in. Register an email service (e.g. EmailServicePlugin + OS_EMAIL_*) ' + + 'or disable verification (OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false).', + ); + } else { + ctx.logger.info('Auth: no email service registered — transactional mail disabled'); } - } catch { - ctx.logger.info('Auth: no email service registered — auth callbacks will log instead of sending'); } // Bind the email brand name (`{{appName}}`) to the live