Skip to content

Verification and password reset emails are dispatched without a rejection handler, producing unhandled rejections #10636

Description

@AdrianCurtin

New Issue Checklist

Issue Description

Verification and password reset emails are dispatched without awaiting the mail adapter and without a rejection handler anywhere in the chain. A mail adapter that rejects, which is what an outage at the email provider looks like, produces an unhandled promise rejection.

The adapter call itself is never awaited, in either flow and in both the adapter-specific and sendMail fallback branches:

// src/Controllers/UserController.js
if (this.adapter.sendVerificationEmail) {
  this.adapter.sendVerificationEmail(options);              // 181
} else {
  this.adapter.sendMail(this.defaultVerificationEmail(options));   // 183
}

if (this.adapter.sendPasswordResetEmail) {
  this.adapter.sendPasswordResetEmail(options);             // 293
} else {
  this.adapter.sendMail(this.defaultResetPasswordEmail(options));  // 295
}

Several callers of the surrounding controller methods are also not awaited:

Location Call Runs on
src/RestWrite.js:1214 userController.sendVerificationEmail(…) user signup
src/Routers/UsersRouter.js:685 userController.sendVerificationEmail(user, req) POST /verificationEmailRequest
src/Controllers/UserController.js:227 this.sendVerificationEmail(aUser, req) resend from the verification page

UsersRouter.handleResetRequest at line 630 does await userController.sendPasswordResetEmail(email), but that await covers only token generation, since the adapter call inside resolves before delivery is attempted.

I want to separate two things here, because I think only one of them is unambiguously a defect.

The unhandled rejection is a defect. No .catch() exists at any level, so a rejecting mail adapter surfaces as an unhandled rejection with no indication of which email failed or for which user. Depending on the Node version and process configuration that either logs a bare warning or terminates the process, so an outage at the email provider can take down an otherwise healthy server. This is the same shape as #10634, where RedisCacheAdapter write methods reject into unawaited call sites.

Whether the client should learn about the failure is a design question, and I am not assuming the answer. The signup path is explicitly commented // Fire and forget! (src/RestWrite.js:1213), so the non-blocking dispatch is deliberate, and there are good reasons for it. Blocking signup on an email provider is a poor tradeoff, and for the password reset flow specifically, awaiting delivery leaks timing that helps an attacker distinguish registered from unregistered addresses, which is the enumeration exposure that resetPasswordSuccessOnInvalidEmail exists to close. So "return 200 regardless" may well be the intended contract.

What that leaves is a question for maintainers: should a failed send at minimum be logged with the adapter error and the target flow, so operators can detect a broken email pipeline, while delivery stays non-blocking? Today a completely dead mail adapter is silent in the logs, and the only signal is a stray unhandled rejection warning with no context.

Steps to reproduce

  1. Configure Parse Server with a mail adapter whose sendVerificationEmail or sendPasswordResetEmail returns a rejected promise.
  2. Sign up a user with verifyUserEmails: true, or call POST /requestPasswordReset.
const server = await reconfigureServer({
  verifyUserEmails: true,
  publicServerURL: 'http://localhost:8378/1',
  emailAdapter: {
    sendVerificationEmail: () => Promise.reject(new Error('Email provider is down')),
    sendPasswordResetEmail: () => Promise.reject(new Error('Email provider is down')),
    sendMail: () => Promise.reject(new Error('Email provider is down')),
  },
});

process.on('unhandledRejection', reason => console.log('unhandled:', reason.message));

const user = new Parse.User();
user.setUsername('zebra');
user.setPassword('password');
user.setEmail('zebra@example.com');
await user.signUp();

Actual Outcome

The request succeeds. unhandled: Email provider is down is printed. Nothing is written to the Parse Server log identifying the failed email, the flow it belonged to, or the user it was addressed to.

Expected Outcome

At minimum, the rejection is caught and logged with the adapter error and enough context to identify the flow, and no unhandled rejection is produced. Whether the response to the client should change is the maintainers' call, and I would not change it without direction, given the enumeration tradeoff on the password reset path.

Happy to open a PR once there is a decision on scope.

Environment

Server

  • Parse Server version: 9.10.1-alpha.6
  • Operating system: macOS 15.5
  • Local or remote host: local

Database

  • System (MongoDB or Postgres): MongoDB
  • Database version: 8.0
  • Local or remote host: local

Client

  • SDK (iOS, Android, JavaScript, PHP, Unity, etc): JavaScript
  • SDK version: as vendored by parse-server

Logs

No Parse Server log entry is produced for the failed send. The only output is the runtime's unhandled rejection warning.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions