Skip to content

Commit 03af498

Browse files
os-zhuangclaude
andauthored
fix(plugin-auth): surface email-send failures instead of swallowing (#2354)
The verification / password-reset callbacks swallowed every send failure (console.error + return), so signup and the explicit "Resend verification email" endpoint both reported success while no mail was sent — users were left permanently stuck with no signal and no resend that could ever work. - sendVerificationEmail / sendResetPassword now THROW on failure (no email service, template error, or transport status:'failed'). better-auth runs the sendOnSignUp / forget-password paths through runInBackgroundOrAwait (await + catch + log, never rethrow) and the routes return success regardless, so SIGN-UP stays resilient and there is no email-enumeration leak — while the awaited /send-verification-email RESEND now surfaces a real error to the UI. - AuthPlugin boot guard: when verification is required but no email service is registered, log an error at kernel:ready instead of a quiet info line, so the misconfiguration is caught at startup. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c715d25 commit 03af498

2 files changed

Lines changed: 85 additions & 52 deletions

File tree

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 64 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -430,32 +430,39 @@ export class AuthManager {
430430
sendResetPassword: async ({ user, url, token }: { user: { id: string; email: string; name?: string }; url: string; token: string }) => {
431431
const email = this.getEmailService();
432432
if (!email) {
433-
console.warn(
434-
`[AuthManager] Password-reset requested for ${user.email} but no email service is wired. URL: ${url}`,
433+
// No transport wired but password reset is enabled — a
434+
// misconfiguration. THROW (don't silently drop): better-auth
435+
// invokes this via `runInBackgroundOrAwait` and the forget-password
436+
// route always returns `{status:true}`, so this never leaks whether
437+
// an address exists AND never turns the request into a 500 — it just
438+
// surfaces the failure in the logs instead of vanishing.
439+
throw new Error(
440+
`Password-reset email could not be sent to ${user.email}: no email service is configured for this deployment.`,
435441
);
436-
return;
437442
}
438443
const ttlSec = this.config.emailAndPassword?.resetPasswordTokenExpiresIn ?? 60 * 60;
439-
try {
440-
await email.sendTemplate({
441-
template: 'auth.password_reset',
442-
to: { address: user.email, ...(user.name ? { name: user.name } : {}) },
443-
data: {
444-
user: { name: user.name || user.email, email: user.email, id: user.id },
445-
resetUrl: url,
446-
token,
447-
expiresInMinutes: Math.round(ttlSec / 60),
448-
appName: this.getAppName(),
449-
},
450-
relatedObject: 'sys_user',
451-
relatedId: user.id,
452-
});
453-
} catch (err: any) {
454-
// Do NOT rethrow: the user account exists; an email-transport failure
455-
// (missing template, bad credentials, network blip) must not turn
456-
// the user-facing reset request into a 500. The user can retry via
457-
// the "forgot password" flow.
458-
console.error(`[AuthManager] sendResetPassword failed (swallowed): ${err?.message ?? err}`);
444+
// Surface both template-resolution throws and transport failures
445+
// (status:'failed'); resilience is preserved by better-auth's
446+
// background-task handling (see sendVerificationEmail) and the
447+
// forget-password route always returns {status:true}, so this never
448+
// leaks whether an address exists nor turns the request into a 500.
449+
const result = await email.sendTemplate({
450+
template: 'auth.password_reset',
451+
to: { address: user.email, ...(user.name ? { name: user.name } : {}) },
452+
data: {
453+
user: { name: user.name || user.email, email: user.email, id: user.id },
454+
resetUrl: url,
455+
token,
456+
expiresInMinutes: Math.round(ttlSec / 60),
457+
appName: this.getAppName(),
458+
},
459+
relatedObject: 'sys_user',
460+
relatedId: user.id,
461+
});
462+
if (result?.status === 'failed') {
463+
throw new Error(
464+
`Password-reset email could not be sent to ${user.email}: ${result.error ?? 'delivery failed'}`,
465+
);
459466
}
460467
},
461468
};
@@ -475,31 +482,43 @@ export class AuthManager {
475482
sendVerificationEmail: async ({ user, url, token }: { user: { id: string; email: string; name?: string }; url: string; token: string }) => {
476483
const email = this.getEmailService();
477484
if (!email) {
478-
console.warn(
479-
`[AuthManager] Verification email requested for ${user.email} but no email service is wired. URL: ${url}`,
485+
// Verification is enabled (this callback only exists when it is)
486+
// but no email transport is wired — a misconfiguration, not a
487+
// transient blip. THROW so the explicit `/send-verification-email`
488+
// resend endpoint (which awaits this) surfaces a real error
489+
// instead of a false "email sent" success. Sign-up stays
490+
// resilient regardless: better-auth runs the sendOnSignUp call
491+
// through `runInBackgroundOrAwait`, which logs (never rethrows)
492+
// a failure, so the account is still created and the user lands
493+
// on the verify screen (where an honest resend now reports the
494+
// problem). Previously this was swallowed, leaving every user
495+
// permanently stuck with no signal and no resend that could work.
496+
throw new Error(
497+
`Verification email could not be sent to ${user.email}: no email service is configured for this deployment.`,
480498
);
481-
return;
482499
}
483500
const ttlSec = this.config.emailVerification?.expiresIn ?? 60 * 60;
484-
try {
485-
await email.sendTemplate({
486-
template: 'auth.verify_email',
487-
to: { address: user.email, ...(user.name ? { name: user.name } : {}) },
488-
data: {
489-
user: { name: user.name || user.email, email: user.email, id: user.id },
490-
verificationUrl: url,
491-
token,
492-
expiresInMinutes: Math.round(ttlSec / 60),
493-
appName: this.getAppName(),
494-
},
495-
relatedObject: 'sys_user',
496-
relatedId: user.id,
497-
});
498-
} catch (err: any) {
499-
// Do NOT rethrow: the user account exists; an email-transport
500-
// failure must not turn signup or /send-verification-email into
501-
// a 500. The "Resend verification email" UI lets the user retry.
502-
console.error(`[AuthManager] sendVerificationEmail failed (swallowed): ${err?.message ?? err}`);
501+
// Let send failures propagate (see above): sendTemplate THROWS on
502+
// template/loader errors, and returns status:'failed' on transport
503+
// errors — surface both so resend is honest and signup stays
504+
// resilient via better-auth's background-task error handling.
505+
const result = await email.sendTemplate({
506+
template: 'auth.verify_email',
507+
to: { address: user.email, ...(user.name ? { name: user.name } : {}) },
508+
data: {
509+
user: { name: user.name || user.email, email: user.email, id: user.id },
510+
verificationUrl: url,
511+
token,
512+
expiresInMinutes: Math.round(ttlSec / 60),
513+
appName: this.getAppName(),
514+
},
515+
relatedObject: 'sys_user',
516+
relatedId: user.id,
517+
});
518+
if (result?.status === 'failed') {
519+
throw new Error(
520+
`Verification email could not be sent to ${user.email}: ${result.error ?? 'delivery failed'}`,
521+
);
503522
}
504523
},
505524
},

packages/plugins/plugin-auth/src/auth-plugin.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -230,14 +230,28 @@ export class AuthPlugin implements Plugin {
230230
if (this.authManager) {
231231
await this.bindAuthSettings(ctx);
232232

233-
try {
234-
const emailSvc = ctx.getService<any>('email');
235-
if (emailSvc) {
236-
this.authManager.setEmailService(emailSvc);
237-
ctx.logger.info('Auth: email service wired (transactional mail enabled)');
233+
let emailSvc: any;
234+
try { emailSvc = ctx.getService<any>('email'); } catch { emailSvc = undefined; }
235+
if (emailSvc) {
236+
this.authManager.setEmailService(emailSvc);
237+
ctx.logger.info('Auth: email service wired (transactional mail enabled)');
238+
} else {
239+
// No email service. The verification / password-reset callbacks now
240+
// THROW when invoked without a transport (so an explicit resend
241+
// reports a real error rather than faking success). If verification
242+
// is REQUIRED, that means every signup would be stuck — surface the
243+
// misconfiguration loudly at boot instead of one failure per signup.
244+
const requiresEmail = !!this.authManager.getPublicConfig?.()?.emailPassword?.requireEmailVerification;
245+
if (requiresEmail) {
246+
ctx.logger.error(
247+
'Auth: email verification is REQUIRED but NO email service is registered — '
248+
+ 'verification & password-reset emails will FAIL and new users will be locked '
249+
+ 'out at sign-in. Register an email service (e.g. EmailServicePlugin + OS_EMAIL_*) '
250+
+ 'or disable verification (OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false).',
251+
);
252+
} else {
253+
ctx.logger.info('Auth: no email service registered — transactional mail disabled');
238254
}
239-
} catch {
240-
ctx.logger.info('Auth: no email service registered — auth callbacks will log instead of sending');
241255
}
242256

243257
// Bind the email brand name (`{{appName}}`) to the live

0 commit comments

Comments
 (0)