TryGhost/Ghost · error · BadRequestError

Email is not valid

Error message

Email is not valid

What it means

A BadRequestError from `MagicLinkService.sendMagicLink` when `isEmail(options.email)` returns false before any token is created or mail sent. It is the first guard in the magic-link/OTC send flow, so an invalid recipient short-circuits immediately. The check uses the `isEmail` validator, not a custom regex.

Source

Thrown at ghost/core/core/server/services/lib/magic-link/magic-link.js:76

        this.labsService = options.labsService || undefined;
    }

    /**
     * sendMagicLink
     *
     * @param {object} options
     * @param {string} options.email - The email to send magic link to
     * @param {TokenData} options.tokenData - The data for token
     * @param {string} [options.type='signin'] - The type to be passed to the url and content generator functions
     * @param {string} [options.referrer=null] - The referrer of the request, if exists. The member will be redirected back to this URL after signin.
     * @param {boolean} [options.includeOTC=false] - Whether to send a one-time-code in the email.
     * @returns {Promise<{token: Token, otcRef: string | null, info: SentMessageInfo}>}
     */
    async sendMagicLink(options) {
        this.sentry?.captureMessage?.(`[Magic Link] Generating magic link`, {extra: options});

        if (!isEmail(options.email)) {
            throw new BadRequestError({
                message: tpl(messages.invalidEmail)
            });
        }

        const token = await this.tokenProvider.create(options.tokenData);

        const type = options.type || 'signin';

        const url = this.getSigninURL(token, type, options.referrer);

        let otc = null;
        if (options.includeOTC) {
            try {
                otc = await this.getOTCFromToken(token);
            } catch (err) {
                this.sentry?.captureException?.(err);
                otc = null;
            }

View on GitHub (pinned to 47d8b0e2ad)

Solutions

  1. Validate and trim the email with the same `isEmail` check on the client before calling `sendMagicLink`.
  2. Confirm you pass the address under the `email` key (not `address`/`to`).
  3. Strip whitespace/newlines: `email.trim()`.
  4. If the member's stored email is invalid, prompt correction in the portal before initiating the magic link.

Example fix

// before
await magicLink.sendMagicLink({email: input, tokenData}); // input may be invalid

// after
const email = String(input ?? '').trim();
if (!isEmail(email)) throw new Error('Enter a valid email');
await magicLink.sendMagicLink({email, tokenData});
Defensive patterns

Strategy: validation

Validate before calling

const {isEmail} = require('@tryghost/validator');
function validateMagicLinkEmail(email) {
  const trimmed = String(email ?? '').trim();
  if (!isEmail(trimmed)) throw new Error('Email is not valid');
  return trimmed;
}

Type guard

const isValidEmail = (v) => typeof v === 'string' && isEmail(v.trim());

Try / catch

try {
  await magicLink.sendMagicLink({email, tokenData});
} catch (err) {
  if (err.type === 'BadRequestError' && /not valid/i.test(err.message)) showEmailError(err.message);
  else throw err;
}

Prevention

When it happens

Trigger: Calling `sendMagicLink({email, tokenData, ...})` with an email that is missing, not a string, or fails the `isEmail` RFC check. Also when the field name differs (e.g. `address`) so `options.email` is undefined, or when whitespace/format issues trip the validator.

Common situations: A member types a malformed address in the portal sign-in form; the portal sends an empty string because the input was uncontrolled; an integration passes the member's name field instead of email; copy-paste introduces a trailing space or newline.

Related errors


AI-assisted analysis of TryGhost/Ghost@47d8b0e2ad (2026-08-13). Data as JSON: /api/errors/689042a10ac3f2a2. Report an issue: GitHub.