Skip to content

fix: Use modern email validator to support new gTLDs (.rocks, .radio, .app)#27136

Open
cyphercodes wants to merge 3 commits intoTryGhost:mainfrom
cyphercodes:fix-email-validation-gtld
Open

fix: Use modern email validator to support new gTLDs (.rocks, .radio, .app)#27136
cyphercodes wants to merge 3 commits intoTryGhost:mainfrom
cyphercodes:fix-email-validation-gtld

Conversation

@cyphercodes
Copy link
Copy Markdown

@cyphercodes cyphercodes commented Apr 5, 2026

Summary

Fixed email validation to support modern generic Top-Level Domains (gTLDs) like .rocks, .radio, .app, etc.

Problem

Ghost's email validation was rejecting legitimate email addresses using newer gTLDs approved by ICANN after 2012. The legacy validator (validator@7.2.0 from 2017) predates many of these TLDs.

Solution

Changed all validator.isEmail() calls to use {legacy: false} option, which enables the modern custom validator that properly handles all TLDs.

Files Changed

  • email-address-service-wrapper.js
  • automated_emails.js
  • invitations.js
  • password_reset.js
  • settings.js

Testing

  • Verified syntax of all changed files
  • The modern validator is already used in member-repository.js and sending-service.js

Fixes #26197


Note

Medium Risk
Changes email validation behavior across several auth/members-related endpoints (magic links, invitations, password reset, settings), which could affect who can trigger these flows. Risk is moderate because it broadens accepted input but doesn’t alter core authorization or token logic.

Overview
Switches multiple server-side email validation call sites to use validator.isEmail(..., {legacy: false}), enabling support for modern gTLDs across automated email test sends, invitations, password reset token generation, member support address settings, managed email address validation, and magic-link sign-in/signup flows.

Adds unit coverage to ensure magic-link sending and the members sendMagicLink controller accept addresses like example.rocks.

Reviewed by Cursor Bugbot for commit 17ef92b. Bugbot is set up for automated code reviews on this repo. Configure here.

… .app)

Changed all validator.isEmail() calls to use {legacy: false} option,
which enables support for modern generic Top-Level Domains like
.rocks, .radio, .app that were approved by ICANN after 2012.

The legacy validator (validator@7.2.0 from 2017) predates many gTLDs
and rejects valid email addresses using these domains.

Files changed:
- email-address-service-wrapper.js
- automated_emails.js
- invitations.js
- password_reset.js
- settings.js

Fixes TryGhost#26197
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 5, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 452ecfef-b0d1-4f62-8b0e-004214e189b7

📥 Commits

Reviewing files that changed from the base of the PR and between 17ef92b and 5ee62fc.

📒 Files selected for processing (1)
  • ghost/core/test/unit/server/services/members/members-api/controllers/router-controller.test.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • ghost/core/test/unit/server/services/members/members-api/controllers/router-controller.test.js

Walkthrough

Calls to validator.isEmail(...) were changed to validator.isEmail(..., {legacy: false}) in multiple places: automated emails, invitations, password reset, settings, email-address service wrapper, magic-link service, and members API router controller. Two unit tests were added to verify acceptance of modern gTLD addresses (example.rocks) in the magic-link service and members controller. No exported/public signatures, control flow, or error messages were modified.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely describes the main change: switching to a modern email validator to support new gTLDs (.rocks, .radio, .app).
Description check ✅ Passed The description comprehensively explains the problem, solution, files changed, and testing performed, all directly related to the changeset.
Linked Issues check ✅ Passed All code changes successfully address issue #26197 by updating validator.isEmail() calls to use {legacy: false} across all relevant email validation points.
Out of Scope Changes check ✅ Passed All changes are within scope, updating email validation across 7 files and adding 2 test files to support modern gTLD validation as specified in the linked issue.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud Bot commented Apr 5, 2026

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@ghost/core/core/server/api/endpoints/utils/validators/input/automated_emails.js`:
- Line 76: Update the two member-facing validators to use the modern email rules
by adding the options object {legacy: false} to the validator.isEmail calls:
locate the email validation in magic-link.js (the function that validates the
magic-link/email input) and the email check in router-controller.js (the
controller handling member signup/signin) and change their
validator.isEmail(email) calls to validator.isEmail(email, {legacy: false}),
keeping any surrounding typeof/email presence checks intact so behavior matches
the other validators (e.g., automated_emails.js).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 4b81fdd4-def0-4bc1-b71a-eba7fd44667b

📥 Commits

Reviewing files that changed from the base of the PR and between edabb63 and 0076c66.

📒 Files selected for processing (5)
  • ghost/core/core/server/api/endpoints/utils/validators/input/automated_emails.js
  • ghost/core/core/server/api/endpoints/utils/validators/input/invitations.js
  • ghost/core/core/server/api/endpoints/utils/validators/input/password_reset.js
  • ghost/core/core/server/api/endpoints/utils/validators/input/settings.js
  • ghost/core/core/server/services/email-address/email-address-service-wrapper.js

const lexical = frame.data.lexical;

if (typeof email !== 'string' || !validator.isEmail(email)) {
if (typeof email !== 'string' || !validator.isEmail(email, {legacy: false})) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify member-facing email validation callsites still using default isEmail(...)
rg -nP --type=js -C2 '\bisEmail\s*\(\s*[^,()]+\s*\)' \
  ghost/core/core/server/services/members/members-api/controllers/router-controller.js \
  ghost/core/core/server/services/lib/magic-link/magic-link.js

# Compare with explicit modern validation usage added in this PR area
rg -nP --type=js -C2 '\bisEmail\s*\(\s*[^,()]+\s*,\s*\{\s*legacy\s*:\s*false\s*\}\s*\)' \
  ghost/core/core/server/api/endpoints/utils/validators/input \
  ghost/core/core/server/services/email-address/email-address-service-wrapper.js

Repository: TryGhost/Ghost

Length of output: 4390


Member authentication flows still use legacy email validation.

Two member-facing entry points (magic-link.js line 75 and router-controller.js line 706) still call isEmail(...) without { legacy: false }, while the current file and other API validators use the modern validation. This allows modern gTLDs to slip through validation in signup and signin flows, creating an inconsistent email validation scope.

These files need to adopt the same { legacy: false } parameter:

  • ghost/core/core/server/services/lib/magic-link/magic-link.js
  • ghost/core/core/server/services/members/members-api/controllers/router-controller.js
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@ghost/core/core/server/api/endpoints/utils/validators/input/automated_emails.js`
at line 76, Update the two member-facing validators to use the modern email
rules by adding the options object {legacy: false} to the validator.isEmail
calls: locate the email validation in magic-link.js (the function that validates
the magic-link/email input) and the email check in router-controller.js (the
controller handling member signup/signin) and change their
validator.isEmail(email) calls to validator.isEmail(email, {legacy: false}),
keeping any surrounding typeof/email presence checks intact so behavior matches
the other validators (e.g., automated_emails.js).

refs TryGhost#26197
Member sign-in and magic-link flows still used legacy email validation.
@cyphercodes
Copy link
Copy Markdown
Author

Pushed a follow-up commit addressing the missed member sign-in validation paths.

Summary:

  • Updated ghost/core/core/server/services/members/members-api/controllers/router-controller.js to call isEmail(email, {legacy: false}).
  • Updated ghost/core/core/server/services/lib/magic-link/magic-link.js to call isEmail(options.email, {legacy: false}).
  • Added targeted unit coverage for modern gTLD addresses in both member router and magic-link tests.
  • Re-checked server-side isEmail callsites under ghost/core/core/server; the active callsites now use {legacy: false}.

Verification:

  • yarn --cwd ghost/core test:single test/unit/server/services/lib/magic-link/index.test.js — 18 passing
  • yarn --cwd ghost/core test:single test/unit/server/services/members/members-api/controllers/router-controller.test.js — 64 passing
  • yarn --cwd ghost/core eslint --ignore-path .eslintignore core/server/services/lib/magic-link/magic-link.js core/server/services/members/members-api/controllers/router-controller.js — passed
  • yarn --cwd ghost/core eslint -c test/.eslintrc.js --ignore-path test/.eslintignore test/unit/server/services/lib/magic-link/index.test.js test/unit/server/services/members/members-api/controllers/router-controller.test.js — passed

Note: I also attempted to update/rebase the branch onto latest upstream/main, but GitHub rejected the branch update because the current OAuth token does not have workflow scope for upstream workflow changes. The requested validation fix is pushed on top of the existing PR branch.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
ghost/core/test/unit/server/services/members/members-api/controllers/router-controller.test.js (1)

1011-1022: Test placement and assertions could be tighter.

This test lives in the honeypot block and sets honeypot = 'filled!', so it implicitly relies on email validation passing before the honeypot short-circuit (since validation at line 706 of router-controller.js runs before the honeypot check at line 720). It does verify the gTLD passes validation (otherwise a BadRequestError would propagate), but the test name suggests this is purely a gTLD-acceptance test.

Consider either:

  • Adding a complementary test in a more general email validation describe block (without honeypot) that asserts sendEmailWithMagicLink is called for jamie@example.rocks, which more directly proves modern gTLDs are accepted in the actual signup flow.
  • Or renaming to clarify it's the honeypot-with-modern-gTLD scenario.

Not a blocker — the current test does provide coverage.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@ghost/core/test/unit/server/services/members/members-api/controllers/router-controller.test.js`
around lines 1011 - 1022, The test titled "Accepts email addresses with modern
gTLDs" lives in the honeypot block and therefore short-circuits via
req.body.honeypot='filled!', so add a complementary test in the email validation
describe block (or rename the existing test) that calls createRouterController()
and controller.sendMagicLink(req, res) with req.body.email='jamie@example.rocks'
and req.body.honeypot='' (or omitted), then assert sendEmailWithMagicLinkStub
was called (e.g. sendEmailWithMagicLinkStub.calledOnce) and the response is 201
JSON; this proves modern gTLDs pass normal validation rather than relying on the
honeypot path.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In
`@ghost/core/test/unit/server/services/members/members-api/controllers/router-controller.test.js`:
- Around line 1011-1022: The test titled "Accepts email addresses with modern
gTLDs" lives in the honeypot block and therefore short-circuits via
req.body.honeypot='filled!', so add a complementary test in the email validation
describe block (or rename the existing test) that calls createRouterController()
and controller.sendMagicLink(req, res) with req.body.email='jamie@example.rocks'
and req.body.honeypot='' (or omitted), then assert sendEmailWithMagicLinkStub
was called (e.g. sendEmailWithMagicLinkStub.calledOnce) and the response is 201
JSON; this proves modern gTLDs pass normal validation rather than relying on the
honeypot path.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: bff1cff9-acb5-4d86-a9f0-6a52ed97205b

📥 Commits

Reviewing files that changed from the base of the PR and between 0076c66 and 17ef92b.

📒 Files selected for processing (4)
  • ghost/core/core/server/services/lib/magic-link/magic-link.js
  • ghost/core/core/server/services/members/members-api/controllers/router-controller.js
  • ghost/core/test/unit/server/services/lib/magic-link/index.test.js
  • ghost/core/test/unit/server/services/members/members-api/controllers/router-controller.test.js
✅ Files skipped from review due to trivial changes (1)
  • ghost/core/core/server/services/lib/magic-link/magic-link.js

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 17ef92b. Configure here.

sinon.assert.calledWith(res.writeHead, 201, {'Content-Type': 'application/json'});
assert.equal(res.end.calledOnceWith('{}'), true);
assert.equal(sendEmailWithMagicLinkStub.notCalled, true);
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test uses honeypot, never verifies email actually sends

Medium Severity

The test "Accepts email addresses with modern gTLDs" sets req.body.honeypot = 'filled!', which causes the controller to take the bot-detection shortcut path (return 201 without sending). The test then asserts sendEmailWithMagicLinkStub.notCalled, effectively verifying the email is not sent — the opposite of what "accepts" implies. While it does indirectly confirm validation passes, it gives false confidence that the full send path works with modern gTLDs. Removing the honeypot and asserting the stub is called would properly test acceptance.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 17ef92b. Configure here.

@cursor
Copy link
Copy Markdown

cursor Bot commented Apr 26, 2026

You have used all of your free Bugbot PR reviews.

To receive reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial.

@cyphercodes
Copy link
Copy Markdown
Author

Updated the router-controller coverage for the review feedback:

  • Moved the modern gTLD case into an email validation block instead of the honeypot block.
  • Removed the honeypot shortcut and now assert the normal signup path sends a magic link for jamie@example.rocks with requestedType: signup, plus the 201 JSON response.

Verification:

  • yarn workspace @tryghost/parse-email-address build
  • yarn test:single test/unit/server/services/members/members-api/controllers/router-controller.test.js (64 passing)
  • yarn test:single test/unit/server/services/lib/magic-link/index.test.js (18 passing)
  • yarn eslint -c test/.eslintrc.js --ignore-path test/.eslintignore test/unit/server/services/members/members-api/controllers/router-controller.test.js --no-cache

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Email validation rejects valid modern gTLDs (.rocks, .radio, .app) due to legacy validator default

1 participant