Skip to content

Automated Test: blocked-email-validation-post #326

Conversation

admin-coderabbit
Copy link
Owner

@admin-coderabbit admin-coderabbit commented Feb 4, 2026

This pull request was automatically created by @coderabbitai/e2e-reviewer.

Batch created pull request.

Summary by CodeRabbit

  • New Features

    • Added email blocking system to prevent reuse of emails previously rejected during account creation.
    • Enhanced email validation with support for blocked email tracking and restrictions.
  • Tests

    • Added test coverage for blocked email functionality and validation behavior.

… many times each email address is blocked, and last time it was blocked. Move email validation out of User model and into EmailValidator. Signup form remembers which email addresses have failed and shows validation error on email field.
@coderabbit-eval
Copy link

coderabbit-eval bot commented Feb 4, 2026

📝 Walkthrough

Walkthrough

This PR introduces an email blocking system for user account creation. It adds a BlockedEmail model with tracking, creates an EmailValidator for domain restrictions and blocked email enforcement, updates the user controller to return rejection details, and modifies the signup controller to track and prevent rejected email re-submission.

Changes

Cohort / File(s) Summary
Email Blocking Model & Infrastructure
app/models/blocked_email.rb, db/migrate/20130724201552_create_blocked_emails.rb, lib/validators/email_validator.rb
Introduces BlockedEmail model with action enum and should_block? logic, database migration with tracking columns, and new EmailValidator that enforces domain restrictions and blocked email checks.
User Validation Updates
app/models/user.rb, config/locales/server.en.yml
Simplifies email validations using declarative approach, removes custom email_validator method, and adds localization for blocked email error message.
User Controller Changes
app/controllers/users_controller.rb
Enhances account creation error responses to include errors hash and values object with user attributes for client-side handling.
Signup Frontend Updates
app/assets/javascripts/discourse/controllers/create_account_controller.js
Introduces rejectedEmails observable array, updates emailValidation dependencies, and tracks rejected emails from server failures to prevent re-submission.
Test Coverage
spec/components/validators/email_validator_spec.rb, spec/fabricators/blocked_email_fabricator.rb, spec/models/blocked_email_spec.rb
Adds validator tests for blocked email logic, test fabricator for blocked_email, and comprehensive model tests for default initialization and should_block? behavior with statistics tracking.

Sequence Diagram

sequenceDiagram
    actor User
    participant Client as Create Account<br/>Controller
    participant Server as Users<br/>Controller
    participant UserModel as User Model<br/>& Validators
    participant BlockedEmail as BlockedEmail<br/>Model
    
    User->>Client: Submit account form
    Client->>Server: POST /users (email in payload)
    Server->>UserModel: user.save
    UserModel->>UserModel: Validate email presence,<br/>uniqueness, format
    UserModel->>BlockedEmail: BlockedEmail.should_block?<br/>(email)
    BlockedEmail->>BlockedEmail: Check if email<br/>matches blocked record
    alt Email Blocked
        BlockedEmail-->>UserModel: true
        UserModel->>UserModel: Validation fails
        UserModel-->>Server: Errors with email
    end
    alt Email Not Blocked
        BlockedEmail-->>UserModel: false
        UserModel-->>Server: Validation success
        Server-->>Client: Account created
        Client->>User: Success
    end
    alt Validation Failed
        Server->>Server: Construct error response<br/>(errors, values)
        Server-->>Client: HTTP 422 with errors<br/>& rejected email
        Client->>Client: Add email to<br/>rejectedEmails array
        Client->>User: Show error,<br/>block email field
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~30 minutes

Poem

🐰 A hop and a skip, we've blocked the spammers' trick,
With email gardens fenced and fine,
Each rejected message joins the line,
To keep our user signup squeaky clean!

🚥 Pre-merge checks | ✅ 1 | ❌ 1
❌ Failed checks (1 inconclusive)
Check name Status Explanation Resolution
Title check ❓ Inconclusive The PR title 'Automated Test: blocked-email-validation-post' is vague and generic, using non-descriptive terms that don't convey meaningful information about the substantial changes made to email validation and blocking functionality. Update the title to describe the primary change, such as 'Add email blocking validation system with BlockedEmail model' or 'Implement blocked email validation for account creation'.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch blocked-email-validation-post

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

@coderabbit-eval coderabbit-eval bot closed this Feb 4, 2026
Copy link

@coderabbit-eval coderabbit-eval 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: 4

🤖 Fix all issues with AI agents
In `@app/models/blocked_email.rb`:
- Around line 7-9: Add a require_dependency 'enum' at the top of
blocked_email.rb so the Enum constant is loaded before self.actions calls
Enum.new(:block, :do_nothing); specifically, open blocked_email.rb and insert
require_dependency 'enum' near other requires (matching how post.rb,
notification.rb, post_action_type.rb do it) to ensure the Enum class is
available for the actions method.
- Around line 11-19: should_block? performs a case-sensitive lookup and
non-atomic read-modify-save on match_count; normalize the incoming email (strip
and downcase) before querying so BlockedEmail.where(email:
normalized_email).first finds stored entries, and replace the read-modify-save
with an atomic DB update that increments match_count and sets last_match_at in
one statement (e.g. an update_all or increment counter with a where clause on
the record id) to avoid lost updates; also add/ensure a before_validation (or
set_defaults) callback to normalize stored emails (self.email =
email.strip.downcase if present) so saved records are consistently lowercased.

In `@lib/validators/email_validator.rb`:
- Around line 13-15: The validator is calling BlockedEmail.should_block? which
performs DB writes (incrementing match_count and updating last_match_at) during
validate_each, causing duplicate increments when user.valid? is called before
save; change this by extracting a pure predicate (e.g.,
BlockedEmail.blocked?(email) or BlockedEmail.match_for(email, persist: false))
that only reads DB and does not update counters, and have the validator call
that pure method; move the increment/update side-effect into a single place that
runs once after a successful create (for example call BlockedEmail.record_match!
from UsersController#create after user.save, or implement an after_create
callback) so match_count/last_match_at are updated exactly once per actual
creation.
- Around line 18-22: The current email_in_restriction_setting? method builds a
domain pattern but its regexp is unanchored so emails like
user@mailinator.com.evil.com match; update the regexp to anchor the domain to
the end of the string (use end-of-string anchor such as \z) so the pattern
matches only the exact domain after the @ (keep the case-insensitive flag),
e.g., modify the Regexp.new call that currently uses "@(#{domains})" to include
the end anchor.
🧹 Nitpick comments (5)
config/locales/server.en.yml (1)

733-734: The blocked message is very similar to the existing not_allowed message.

not_allowed: "is not allowed from that email provider. Please use another email address."
blocked: "is not allowed."

Consider making the blocked message more distinct so users (and support staff reviewing logs) can differentiate between a domain-restriction rejection and a specific blocked-email rejection.

spec/components/validators/email_validator_spec.rb (2)

7-7: Missing spaces after commas in method arguments.

Line 7 has validate_each(record,:email,record.email) — add spaces after the commas for readability.

-  subject(:validate) { validator.validate_each(record,:email,record.email) }
+  subject(:validate) { validator.validate_each(record, :email, record.email) }

3-23: Remove extra blank lines at block body boundaries (RuboCop).

RuboCop flags the extra blank line after describe EmailValidator do (Line 4) and before the closing end (Line 22). Remove them to satisfy Layout/EmptyLinesAroundBlockBody.

♻️ Proposed fix
 describe EmailValidator do
-
   let(:record) { Fabricate.build(:user, email: "bad@spamclub.com") }
   end
-
 end
app/assets/javascripts/discourse/controllers/create_account_controller.js (2)

17-17: Mutable array default on the class prototype.

Em.A([]) defined at the class body level is evaluated once and shared across all instances. In Ember Classic, the safe pattern is to initialize mutable defaults in init. Since Discourse controllers are singletons this is unlikely to bite in practice, but it's still a latent risk if the lifecycle ever changes.

♻️ Safer initialization

Add an init method to set up the array per-instance:

init: function() {
  this._super();
  this.set('rejectedEmails', Em.A([]));
},

And remove the class-level rejectedEmails: Em.A([]), line.


69-74: Consider using a more specific error message for blocked emails.

This uses I18n.t('user.email.invalid') for emails that were previously rejected by the server. Since a blocked translation key was added on the server side, a corresponding client-side key (e.g., 'user.email.blocked') would give users a more accurate signal than "invalid" for an email that is syntactically valid but policy-rejected.

Comment on lines +7 to +9
def self.actions
@actions ||= Enum.new(:block, :do_nothing)
end

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
# Find the Enum class definition
rg -n 'class Enum' --type=ruby

Repository: admin-coderabbit/coderabbit-discourse-mirror

Length of output: 119


🏁 Script executed:

#!/bin/bash
# Check if blocked_email.rb has any explicit requires
echo "=== Checking blocked_email.rb for requires ==="
cat -n app/models/blocked_email.rb | head -20

echo -e "\n=== Examining the Enum class implementation ==="
cat -n lib/enum.rb

echo -e "\n=== Checking if lib is in Rails autoload paths ==="
rg -n 'config\.autoload' --type=ruby config/

Repository: admin-coderabbit/coderabbit-discourse-mirror

Length of output: 2477


🏁 Script executed:

#!/bin/bash
# Search for any requires of 'enum' in the codebase
echo "=== Searching for require statements for Enum ==="
rg -n "require.*enum" --type=ruby -i

echo -e "\n=== Checking for initializers that might load lib/enum ==="
ls -la config/initializers/ 2>/dev/null | head -20
rg -n "require.*lib" --type=ruby config/initializers/

echo -e "\n=== Checking if lib directory is in autoload_paths ==="
cat config/application.rb | grep -A 10 -B 5 "autoload_paths"

Repository: admin-coderabbit/coderabbit-discourse-mirror

Length of output: 2629


Add require_dependency 'enum' at the top of blocked_email.rb.

The Enum class is defined in lib/enum.rb, but blocked_email.rb lacks the required require_dependency 'enum' statement. Other files using Enum in the codebase (e.g., post.rb, notification.rb, post_action_type.rb) include this require statement. Without it, calling Enum.new(:block, :do_nothing) will fail at runtime with a NameError.

🤖 Prompt for AI Agents
In `@app/models/blocked_email.rb` around lines 7 - 9, Add a require_dependency
'enum' at the top of blocked_email.rb so the Enum constant is loaded before
self.actions calls Enum.new(:block, :do_nothing); specifically, open
blocked_email.rb and insert require_dependency 'enum' near other requires
(matching how post.rb, notification.rb, post_action_type.rb do it) to ensure the
Enum class is available for the actions method.

Comment on lines +11 to +19
def self.should_block?(email)
record = BlockedEmail.where(email: email).first
if record
record.match_count += 1
record.last_match_at = Time.zone.now
record.save
end
record && record.action_type == actions[:block]
end

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:

# First, let's explore the repository structure
git ls-files | head -20

Repository: admin-coderabbit/coderabbit-discourse-mirror

Length of output: 348


🏁 Script executed:

# Check the BlockedEmail model and related models
fd -t f -e rb 'blocked_email|user' app/models/ | head -20

Repository: admin-coderabbit/coderabbit-discourse-mirror

Length of output: 615


🏁 Script executed:

# Search for BlockedEmail references and email normalization patterns
rg -n 'BlockedEmail|should_block' --type=rb -g '!spec/**' | head -30

Repository: admin-coderabbit/coderabbit-discourse-mirror

Length of output: 114


🏁 Script executed:

# Check the User model for email handling/normalization
fd -t f 'user.rb' app/models/ -x cat -n {}

Repository: admin-coderabbit/coderabbit-discourse-mirror

Length of output: 39687


🏁 Script executed:

# Look for email normalization in the codebase
rg -n 'downcase|normalize.*email|email.*downcase' --type=rb -g '!spec/**' | head -30

Repository: admin-coderabbit/coderabbit-discourse-mirror

Length of output: 114


🏁 Script executed:

cat -n app/models/blocked_email.rb

Repository: admin-coderabbit/coderabbit-discourse-mirror

Length of output: 805


🏁 Script executed:

# Search for how BlockedEmail is used in the codebase
rg "BlockedEmail" --type-list | head -5

Repository: admin-coderabbit/coderabbit-discourse-mirror

Length of output: 477


🏁 Script executed:

# Try searching for BlockedEmail references without type specification
rg "BlockedEmail\|should_block" --max-count=20

Repository: admin-coderabbit/coderabbit-discourse-mirror

Length of output: 70


🏁 Script executed:

# Search for Email class and downcase method
rg "class Email|def downcase" --max-count=20

Repository: admin-coderabbit/coderabbit-discourse-mirror

Length of output: 685


🏁 Script executed:

# Find the Email class definition
find . -name 'email.rb' -type f | grep -v node_modules | head -10

Repository: admin-coderabbit/coderabbit-discourse-mirror

Length of output: 102


🏁 Script executed:

# Search for where BlockedEmail.should_block? is called
rg "should_block" --max-count=20

Repository: admin-coderabbit/coderabbit-discourse-mirror

Length of output: 658


🏁 Script executed:

# Search for email normalization in validators
rg "downcase|strip|normalize" lib/validators/email_validator.rb

Repository: admin-coderabbit/coderabbit-discourse-mirror

Length of output: 70


🏁 Script executed:

# Check if there's any email normalization in database callbacks
rg "before_save|before_validation.*email" --max-count=20

Repository: admin-coderabbit/coderabbit-discourse-mirror

Length of output: 459


🏁 Script executed:

cat -n lib/email.rb

Repository: admin-coderabbit/coderabbit-discourse-mirror

Length of output: 945


🏁 Script executed:

cat -n lib/validators/email_validator.rb

Repository: admin-coderabbit/coderabbit-discourse-mirror

Length of output: 1095


🏁 Script executed:

cat -n spec/models/blocked_email_spec.rb | head -80

Repository: admin-coderabbit/coderabbit-discourse-mirror

Length of output: 1987


🏁 Script executed:

# Check if email is normalized before being passed to BlockedEmail.should_block?
rg -B5 -A5 "BlockedEmail.should_block" lib/validators/email_validator.rb

Repository: admin-coderabbit/coderabbit-discourse-mirror

Length of output: 509


🏁 Script executed:

# Verify that Email.downcase is not called in the EmailValidator path
rg -B10 -A10 "Email.downcase" lib/validators/email_validator.rb

Repository: admin-coderabbit/coderabbit-discourse-mirror

Length of output: 70


🏁 Script executed:

# Check User model to confirm email is not normalized before validation
rg -B5 -A5 "validates.*email" app/models/user.rb | head -30

Repository: admin-coderabbit/coderabbit-discourse-mirror

Length of output: 418


🏁 Script executed:

# Look at how User creation/validation handles email
rg -B5 -A5 "new_from_params|create_for_email" app/models/user.rb

Repository: admin-coderabbit/coderabbit-discourse-mirror

Length of output: 650


should_block? has no email normalization — case-sensitive lookup may miss blocked entries.

If an admin blocks spam@example.com but a user registers with Spam@Example.com, the where(email: email) lookup will miss the record (case-sensitive comparison in SQL). Although Email.downcase() is available in the codebase, it is not used in the validation flow. The email should be normalized before lookup to ensure consistent blocking.

Additionally, the read-modify-save pattern on match_count is susceptible to lost updates under concurrent requests. Using an atomic SQL update would be more robust for statistics tracking.

Proposed fix for normalization and atomic update
 def self.should_block?(email)
-  record = BlockedEmail.where(email: email).first
+  record = BlockedEmail.where(email: email.strip.downcase).first
   if record
-    record.match_count += 1
-    record.last_match_at = Time.zone.now
-    record.save
+    BlockedEmail.where(id: record.id).update_all(
+      ['match_count = match_count + 1, last_match_at = ?', Time.zone.now]
+    )
   end
   record && record.action_type == actions[:block]
 end

You may also want to normalize the email in set_defaults or via a before_validation callback so stored emails are always lowercase:

before_validation { self.email = email.strip.downcase if email.present? }
🤖 Prompt for AI Agents
In `@app/models/blocked_email.rb` around lines 11 - 19, should_block? performs a
case-sensitive lookup and non-atomic read-modify-save on match_count; normalize
the incoming email (strip and downcase) before querying so
BlockedEmail.where(email: normalized_email).first finds stored entries, and
replace the read-modify-save with an atomic DB update that increments
match_count and sets last_match_at in one statement (e.g. an update_all or
increment counter with a where clause on the record id) to avoid lost updates;
also add/ensure a before_validation (or set_defaults) callback to normalize
stored emails (self.email = email.strip.downcase if present) so saved records
are consistently lowercased.

Comment on lines +13 to +15
if record.errors[attribute].blank? and BlockedEmail.should_block?(value)
record.errors.add(attribute, I18n.t(:'user.email.blocked'))
end

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

BlockedEmail.should_block? has a DB write side-effect inside validation.

Every call to validate_each for a blocked email will increment match_count and update last_match_at. If valid? is called explicitly before save (which internally calls valid? again), the counter gets incremented twice. This is a minor accuracy concern for the statistics, but worth being aware of given the current UsersController#create flow calls user.valid? on Line 168 before user.save on Line 172.

🤖 Prompt for AI Agents
In `@lib/validators/email_validator.rb` around lines 13 - 15, The validator is
calling BlockedEmail.should_block? which performs DB writes (incrementing
match_count and updating last_match_at) during validate_each, causing duplicate
increments when user.valid? is called before save; change this by extracting a
pure predicate (e.g., BlockedEmail.blocked?(email) or
BlockedEmail.match_for(email, persist: false)) that only reads DB and does not
update counters, and have the validator call that pure method; move the
increment/update side-effect into a single place that runs once after a
successful create (for example call BlockedEmail.record_match! from
UsersController#create after user.save, or implement an after_create callback)
so match_count/last_match_at are updated exactly once per actual creation.

Comment on lines +18 to +22
def email_in_restriction_setting?(setting, value)
domains = setting.gsub('.', '\.')
regexp = Regexp.new("@(#{domains})", true)
value =~ regexp
end

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Regex is not anchored — domain restrictions can be bypassed.

The regex @(mailinator\.com|trashmail\.net) matches anywhere after the @, so an email like user@mailinator.com.evil.com would pass the blacklist check (it matches the substring) and also incorrectly pass the whitelist check. Anchor the regex to the end of the string to ensure it matches the full domain.

Proposed fix
 def email_in_restriction_setting?(setting, value)
   domains = setting.gsub('.', '\.')
-  regexp = Regexp.new("@(#{domains})", true)
+  regexp = Regexp.new("@(#{domains})\\z", true)
   value =~ regexp
 end
🤖 Prompt for AI Agents
In `@lib/validators/email_validator.rb` around lines 18 - 22, The current
email_in_restriction_setting? method builds a domain pattern but its regexp is
unanchored so emails like user@mailinator.com.evil.com match; update the regexp
to anchor the domain to the end of the string (use end-of-string anchor such as
\z) so the pattern matches only the exact domain after the @ (keep the
case-insensitive flag), e.g., modify the Regexp.new call that currently uses
"@(#{domains})" to include the end anchor.

Sign in to join this conversation on GitHub.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

1 participant