Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 64 additions & 45 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'}`,
);
}
},
};
Expand All @@ -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'}`,
);
}
},
},
Expand Down
28 changes: 21 additions & 7 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,14 +230,28 @@ export class AuthPlugin implements Plugin {
if (this.authManager) {
await this.bindAuthSettings(ctx);

try {
const emailSvc = ctx.getService<any>('email');
if (emailSvc) {
this.authManager.setEmailService(emailSvc);
ctx.logger.info('Auth: email service wired (transactional mail enabled)');
let emailSvc: any;
try { emailSvc = ctx.getService<any>('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
Expand Down