Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Discourse.CreateAccountController = Discourse.Controller.extend(Discourse.ModalF
accountPasswordConfirm: 0,
accountChallenge: 0,
formSubmitted: false,
rejectedEmails: Em.A([]),

submitDisabled: function() {
if (this.get('formSubmitted')) return true;
Expand Down Expand Up @@ -64,6 +65,14 @@ Discourse.CreateAccountController = Discourse.Controller.extend(Discourse.ModalF
}

email = this.get("accountEmail");

if (this.get('rejectedEmails').contains(email)) {
return Discourse.InputValidation.create({
failed: true,
reason: I18n.t('user.email.invalid')
});
}

if ((this.get('authOptions.email') === email) && this.get('authOptions.email_valid')) {
return Discourse.InputValidation.create({
ok: true,
Expand All @@ -84,7 +93,7 @@ Discourse.CreateAccountController = Discourse.Controller.extend(Discourse.ModalF
failed: true,
reason: I18n.t('user.email.invalid')
});
}.property('accountEmail'),
}.property('accountEmail', 'rejectedEmails.@each'),

usernameMatch: function() {
if (this.usernameNeedsToBeValidatedWithEmail()) {
Expand Down Expand Up @@ -262,6 +271,9 @@ Discourse.CreateAccountController = Discourse.Controller.extend(Discourse.ModalF
createAccountController.set('complete', true);
} else {
createAccountController.flash(result.message || I18n.t('create_account.failed'), 'error');
if (result.errors && result.errors.email && result.values) {
createAccountController.get('rejectedEmails').pushObject(result.values.email);
}
createAccountController.set('formSubmitted', false);
}
if (result.active) {
Expand Down
4 changes: 3 additions & 1 deletion app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,9 @@ def create
else
render json: {
success: false,
message: I18n.t("login.errors", errors: user.errors.full_messages.join("\n"))
message: I18n.t("login.errors", errors: user.errors.full_messages.join("\n")),
errors: user.errors.to_hash,
values: user.attributes.slice("name", "username", "email")
}
end
rescue ActiveRecord::StatementInvalid
Expand Down
25 changes: 25 additions & 0 deletions app/models/blocked_email.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
class BlockedEmail < ActiveRecord::Base

before_validation :set_defaults

validates :email, presence: true, uniqueness: true

def self.actions
@actions ||= Enum.new(:block, :do_nothing)
end

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

Comment on lines +11 to +20

Copilot AI Jan 30, 2026

Copy link

Choose a reason for hiding this comment

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

The should_block? method performs a write operation (incrementing match_count and updating last_match_at) on every check. This creates a database write for every email validation, even during read-only operations like form validation. Consider separating the blocking check from the statistics update, or using a background job to update statistics asynchronously.

Suggested change
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
def self.find_by_email(email)
BlockedEmail.where(email: email).first
end
def self.should_block?(email)
record = find_by_email(email)
record && record.action_type == actions[:block]
end
def self.record_match!(email)
record = find_by_email(email)
return unless record
record.match_count += 1
record.last_match_at = Time.zone.now
record.save
end

Copilot uses AI. Check for mistakes.
def set_defaults
self.action_type ||= BlockedEmail.actions[:block]
end

end
23 changes: 2 additions & 21 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,9 @@ class User < ActiveRecord::Base
has_one :user_search_data

validates_presence_of :username
validates_presence_of :email
validates_uniqueness_of :email
validate :username_validator
validate :email_validator, if: :email_changed?
validates :email, presence: true, uniqueness: true
validates :email, email: true, if: :email_changed?
validate :password_validator

before_save :cook
Expand Down Expand Up @@ -565,24 +564,6 @@ def username_validator
end
end

def email_validator
if (setting = SiteSetting.email_domains_whitelist).present?
unless email_in_restriction_setting?(setting)
errors.add(:email, I18n.t(:'user.email.not_allowed'))
end
elsif (setting = SiteSetting.email_domains_blacklist).present?
if email_in_restriction_setting?(setting)
errors.add(:email, I18n.t(:'user.email.not_allowed'))
end
end
end

def email_in_restriction_setting?(setting)
domains = setting.gsub('.', '\.')
regexp = Regexp.new("@(#{domains})", true)
self.email =~ regexp
end

def password_validator
if (@raw_password && @raw_password.length < 6) || (@password_required && !@raw_password)
errors.add(:password, "must be 6 letters or longer")
Expand Down
1 change: 1 addition & 0 deletions config/locales/server.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,7 @@ en:
must_begin_with_alphanumeric: "must begin with a letter or number"
email:
not_allowed: "is not allowed from that email provider. Please use another email address."
blocked: "is not allowed."

invite_mailer:
subject_template: "[%{site_name}] %{invitee_name} invited you to join a discussion on %{site_name}"
Expand Down
12 changes: 12 additions & 0 deletions db/migrate/20130724201552_create_blocked_emails.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
class CreateBlockedEmails < ActiveRecord::Migration
def change
create_table :blocked_emails do |t|
t.string :email, null: false
t.integer :action_type, null: false
t.integer :match_count, null: false, default: 0
t.datetime :last_match_at
t.timestamps
end
add_index :blocked_emails, :email, unique: true
end
end
24 changes: 24 additions & 0 deletions lib/validators/email_validator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
class EmailValidator < ActiveModel::EachValidator

def validate_each(record, attribute, value)
if (setting = SiteSetting.email_domains_whitelist).present?
unless email_in_restriction_setting?(setting, value)
record.errors.add(attribute, I18n.t(:'user.email.not_allowed'))
end
elsif (setting = SiteSetting.email_domains_blacklist).present?
if email_in_restriction_setting?(setting, value)
record.errors.add(attribute, I18n.t(:'user.email.not_allowed'))
end
end
if record.errors[attribute].blank? and BlockedEmail.should_block?(value)

Copilot AI Jan 30, 2026

Copy link

Choose a reason for hiding this comment

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

Ruby style prefers && over and for boolean operators in conditional expressions. Using and can lead to unexpected precedence issues.

Suggested change
if record.errors[attribute].blank? and BlockedEmail.should_block?(value)
if record.errors[attribute].blank? && BlockedEmail.should_block?(value)

Copilot uses AI. Check for mistakes.
record.errors.add(attribute, I18n.t(:'user.email.blocked'))
end
end

def email_in_restriction_setting?(setting, value)
domains = setting.gsub('.', '\.')
regexp = Regexp.new("@(#{domains})", true)
value =~ regexp
end

end
23 changes: 23 additions & 0 deletions spec/components/validators/email_validator_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
require 'spec_helper'

describe EmailValidator do

let(:record) { Fabricate.build(:user, email: "bad@spamclub.com") }
let(:validator) { described_class.new({attributes: :email}) }
subject(:validate) { validator.validate_each(record,:email,record.email) }

context "blocked email" do
it "doesn't add an error when email doesn't match a blocked email" do
BlockedEmail.stubs(:should_block?).with(record.email).returns(false)
validate
record.errors[:email].should_not be_present
end

it "adds an error when email matches a blocked email" do
BlockedEmail.stubs(:should_block?).with(record.email).returns(true)
validate
record.errors[:email].should be_present
Comment on lines +13 to +19

Copilot AI Jan 30, 2026

Copy link

Choose a reason for hiding this comment

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

The deprecated RSpec syntax .should_not is used. Consider updating to the modern expect syntax: expect(record.errors[:email]).not_to be_present

Suggested change
record.errors[:email].should_not be_present
end
it "adds an error when email matches a blocked email" do
BlockedEmail.stubs(:should_block?).with(record.email).returns(true)
validate
record.errors[:email].should be_present
expect(record.errors[:email]).not_to be_present
end
it "adds an error when email matches a blocked email" do
BlockedEmail.stubs(:should_block?).with(record.email).returns(true)
validate
expect(record.errors[:email]).to be_present

Copilot uses AI. Check for mistakes.
Comment on lines +13 to +19

Copilot AI Jan 30, 2026

Copy link

Choose a reason for hiding this comment

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

The deprecated RSpec syntax .should is used. Consider updating to the modern expect syntax: expect(record.errors[:email]).to be_present

Suggested change
record.errors[:email].should_not be_present
end
it "adds an error when email matches a blocked email" do
BlockedEmail.stubs(:should_block?).with(record.email).returns(true)
validate
record.errors[:email].should be_present
expect(record.errors[:email]).not_to be_present
end
it "adds an error when email matches a blocked email" do
BlockedEmail.stubs(:should_block?).with(record.email).returns(true)
validate
expect(record.errors[:email]).to be_present

Copilot uses AI. Check for mistakes.
end
end

end
4 changes: 4 additions & 0 deletions spec/fabricators/blocked_email_fabricator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Fabricator(:blocked_email) do
email { sequence(:email) { |n| "bad#{n}@spammers.org" } }
action_type BlockedEmail.actions[:block]
end
48 changes: 48 additions & 0 deletions spec/models/blocked_email_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
require 'spec_helper'

describe BlockedEmail do

let(:email) { 'block@spamfromhome.org' }

describe "new record" do
it "sets a default action_type" do
BlockedEmail.create(email: email).action_type.should == BlockedEmail.actions[:block]

Copilot AI Jan 30, 2026

Copy link

Choose a reason for hiding this comment

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

The deprecated RSpec syntax .should is used. Consider updating to the modern expect syntax: expect(BlockedEmail.create(email: email).action_type).to eq(BlockedEmail.actions[:block])

Copilot uses AI. Check for mistakes.
end

it "last_match_at is null" do
# If we manually load the table with some emails, we can see whether those emails
# have ever been blocked by looking at last_match_at.
BlockedEmail.create(email: email).last_match_at.should be_nil

Copilot AI Jan 30, 2026

Copy link

Choose a reason for hiding this comment

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

The deprecated RSpec syntax .should is used. Consider updating to the modern expect syntax: expect(BlockedEmail.create(email: email).last_match_at).to be_nil

Suggested change
BlockedEmail.create(email: email).last_match_at.should be_nil
expect(BlockedEmail.create(email: email).last_match_at).to be_nil

Copilot uses AI. Check for mistakes.
end
end

describe "#should_block?" do
subject { BlockedEmail.should_block?(email) }

it "returns false if a record with the email doesn't exist" do
subject.should be_false

Copilot AI Jan 30, 2026

Copy link

Choose a reason for hiding this comment

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

The deprecated RSpec syntax .should is used. Consider updating to the modern expect syntax: expect(subject).to be false

Copilot uses AI. Check for mistakes.
end

shared_examples "when a BlockedEmail record matches" do
it "updates statistics" do
Timecop.freeze(Time.zone.now) do
expect { subject }.to change { blocked_email.reload.match_count }.by(1)
blocked_email.last_match_at.should be_within_one_second_of(Time.zone.now)

Copilot AI Jan 30, 2026

Copy link

Choose a reason for hiding this comment

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

The deprecated RSpec syntax .should is used. Consider updating to the modern expect syntax: expect(blocked_email.last_match_at).to be_within_one_second_of(Time.zone.now)

Copilot uses AI. Check for mistakes.
end
end
end

context "action_type is :block" do
let!(:blocked_email) { Fabricate(:blocked_email, email: email, action_type: BlockedEmail.actions[:block]) }
it { should be_true }
include_examples "when a BlockedEmail record matches"
end

context "action_type is :do_nothing" do
let!(:blocked_email) { Fabricate(:blocked_email, email: email, action_type: BlockedEmail.actions[:do_nothing]) }
it { should be_false }
include_examples "when a BlockedEmail record matches"
end
end

end