Compare commits

...
Author SHA1 Message Date
Shivam Mishra 03db72a6f4 chore: remove corporate domains 2026-05-20 16:05:16 +05:30
Shivam Mishra 1df28973ed chore: restore MX check 2026-05-20 13:56:37 +05:30
Shivam MishraandGitHub 4baf8d4409 Merge branch 'develop' into feat/business-email-enforcement 2026-05-20 13:06:05 +05:30
Shivam Mishra 5fff10325e feat: remove dns lookup 2026-05-19 13:53:19 +05:30
Shivam Mishra 4303dc32fc fix: allow free email providers during installation onboarding
Installation onboarding creates the first super-admin via AccountBuilder,
which routes through SignUpEmailValidationService. Without an opt-out, a
self-hoster setting up Chatwoot with a gmail/outlook admin email would
be rejected at install time — a regression for a flow that has nothing
to do with public abuse signals.

Adds an allow_free_email_provider keyword to the service and plumbs it
through AccountBuilder. The public signup paths (api/v1/accounts and
the omniauth callback) continue to enforce the check by default; only
the onboarding controller opts out.
2026-05-19 13:43:01 +05:30
Shivam Mishra 86c8f1ec67 test: business validation 2026-05-19 13:34:55 +05:30
Shivam Mishra 685a1dda48 feat: block free email provider signups
Mirrors the existing client-side check in the v3 signup form
(company-email-validator via Vuelidate) so non-business emails can no
longer slip in via Google OAuth, direct API calls, or any other path
that bypassed the form.

The deny list ships as config/deny_listed_email_domains.yml — picked up
automatically by ValidEmail2 — and is trimmed to the 6.5k consumer-only
domains the gem's disposable list doesn't already cover.
2026-05-19 13:31:55 +05:30
Shivam Mishra 0cb5eb8a5f chore: upgrade valid_email2 to v7
Brings in the deny_list / deny_listed? API (renamed from
blacklist/blacklisted? in 5.3+), which lets the gem own the consumer
email provider lookup rather than maintaining a parallel set in app code.

No behavior change at this commit — existing call sites use valid? and
disposable_domain?, both preserved in v7.
2026-05-19 13:31:43 +05:30
10 changed files with 6609 additions and 18 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ gem 'rest-client'
gem 'telephone_number'
gem 'time_diff'
gem 'tzinfo-data'
gem 'valid_email2'
gem 'valid_email2', '~> 7.0'
gem 'email-provider-info'
gem 'gemoji'
# compress javascript config.assets.js_compressor
+11 -10
View File
@@ -167,8 +167,8 @@ GEM
statsd-ruby (~> 1.1)
base64 (0.3.0)
bcrypt (3.1.22)
benchmark (0.4.1)
bigdecimal (3.2.2)
benchmark (0.5.0)
bigdecimal (3.3.1)
bindex (0.8.1)
bootsnap (1.16.0)
msgpack (~> 1.2)
@@ -190,7 +190,7 @@ GEM
climate_control (1.2.0)
coderay (1.1.3)
commonmarker (0.23.10)
concurrent-ruby (1.3.5)
concurrent-ruby (1.3.6)
connection_pool (2.5.5)
crack (1.0.0)
bigdecimal
@@ -451,7 +451,7 @@ GEM
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
httpclient (2.8.3)
i18n (1.14.7)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
@@ -545,7 +545,8 @@ GEM
loofah (2.23.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
mail (2.9.0)
logger
mini_mime (>= 0.1.1)
net-imap
net-pop
@@ -562,7 +563,7 @@ GEM
mini_magick (4.12.0)
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (5.25.5)
minitest (5.27.0)
mock_redis (0.36.0)
ruby2_keywords
msgpack (1.8.0)
@@ -577,7 +578,7 @@ GEM
uri (>= 0.11.1)
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.24)
net-imap (0.6.4)
date
net-protocol
net-pop (0.1.2)
@@ -991,8 +992,8 @@ GEM
uri (1.1.1)
uri-idna (0.3.1)
uri_template (0.7.0)
valid_email2 (5.2.6)
activemodel (>= 3.2)
valid_email2 (7.0.15)
activemodel (>= 6.0)
mail (~> 2.5)
version_gem (1.1.4)
vite_rails (3.0.17)
@@ -1181,7 +1182,7 @@ DEPENDENCIES
twitty (~> 0.1.5)
tzinfo-data
uglifier
valid_email2
valid_email2 (~> 7.0)
vite_rails
web-console (>= 4.2.1)
web-push (>= 3.0.1)
+3 -2
View File
@@ -2,7 +2,8 @@
class AccountBuilder
include CustomExceptions::Account
pattr_initialize [:account_name, :email!, :confirmed, :user, :user_full_name, :user_password, :super_admin, :locale]
pattr_initialize [:account_name, :email!, :confirmed, :user, :user_full_name, :user_password, :super_admin, :locale,
:allow_free_email_provider]
def perform
if @user.nil?
@@ -32,7 +33,7 @@ class AccountBuilder
end
def validate_email
Account::SignUpEmailValidationService.new(@email).perform
Account::SignUpEmailValidationService.new(@email, allow_free_email_provider: @allow_free_email_provider).perform
end
def validate_user
@@ -11,7 +11,8 @@ class Installation::OnboardingController < ApplicationController
email: onboarding_params.dig(:user, :email),
user_password: params.dig(:user, :password),
super_admin: true,
confirmed: true
confirmed: true,
allow_free_email_provider: true
).perform
rescue StandardError => e
redirect_to '/', flash: { error: e.message } and return
@@ -4,8 +4,9 @@ class Account::SignUpEmailValidationService
include CustomExceptions::Account
attr_reader :email
def initialize(email)
def initialize(email, allow_free_email_provider: false)
@email = email
@allow_free_email_provider = allow_free_email_provider
end
def perform
@@ -17,6 +18,8 @@ class Account::SignUpEmailValidationService
raise InvalidEmail.new({ valid: true, disposable: true }) if address.disposable?
raise InvalidEmail.new({ free_email_provider: true }) if !@allow_free_email_provider && address.deny_listed?
true
end
File diff suppressed because it is too large Load Diff
+1
View File
@@ -60,6 +60,7 @@ en:
signup:
disposable_email: We do not allow disposable emails
blocked_domain: This domain is not allowed. If you believe this is a mistake, please contact support.
free_email_provider: Please sign up with your work email address.
invalid_email: You have entered an invalid email
email_already_exists: 'You have already signed up for an account with %{email}'
invalid_params: 'Invalid, please check the signup paramters and try again'
+2
View File
@@ -7,6 +7,8 @@ module CustomExceptions::Account
I18n.t 'errors.signup.blocked_domain'
elsif @data[:disposable]
I18n.t 'errors.signup.disposable_email'
elsif @data[:free_email_provider]
I18n.t 'errors.signup.free_email_provider'
elsif !@data[:valid]
I18n.t 'errors.signup.invalid_email'
end
+1 -1
View File
@@ -20,7 +20,7 @@ RSpec.describe AccountBuilder do
# Mock the email validation service
before do
allow(Account::SignUpEmailValidationService).to receive(:new).with(email).and_return(validation_service)
allow(Account::SignUpEmailValidationService).to receive(:new).with(email, allow_free_email_provider: anything).and_return(validation_service)
end
describe '#perform' do
@@ -5,7 +5,8 @@ require 'rails_helper'
RSpec.describe Account::SignUpEmailValidationService, type: :service do
let(:service) { described_class.new(email) }
let(:blocked_domains) { "gmail.com\noutlook.com" }
let(:valid_email_address) { instance_double(ValidEmail2::Address, valid?: true, disposable?: false) }
let(:valid_email_address) { instance_double(ValidEmail2::Address, valid?: true, disposable?: false, deny_listed?: false) }
let(:free_provider_email_address) { instance_double(ValidEmail2::Address, valid?: true, disposable?: false, deny_listed?: true) }
let(:disposable_email_address) { instance_double(ValidEmail2::Address, valid?: true, disposable?: true) }
let(:invalid_email_address) { instance_double(ValidEmail2::Address, valid?: false) }
@@ -62,13 +63,77 @@ RSpec.describe Account::SignUpEmailValidationService, type: :service do
end
end
context 'when email is valid business email' do
context 'when email is from a free provider' do
let(:email) { 'test@example.com' }
it 'raises InvalidEmail with free email provider message' do
allow(ValidEmail2::Address).to receive(:new).with(email).and_return(free_provider_email_address)
expect { service.perform }.to raise_error do |error|
expect(error.class.name).to eq('CustomExceptions::Account::InvalidEmail')
expect(error.message).to eq(I18n.t('errors.signup.free_email_provider'))
end
end
end
context 'when email is valid business email' do
let(:email) { 'test@chatwoot.com' }
it 'returns true' do
allow(ValidEmail2::Address).to receive(:new).with(email).and_return(valid_email_address)
expect(service.perform).to be(true)
end
end
end
# Exercises the real ValidEmail2 + config/deny_listed_email_domains.yml
# integration without stubbing ValidEmail2::Address. Guards against the YAML
# file going missing, the path drifting, or upstream API renames.
describe '#perform with the real deny list' do
before do
allow(GlobalConfigService).to receive(:load).with('BLOCKED_EMAIL_DOMAINS', '').and_return('')
# ValidEmail2::Dns is instantiated lazily inside ValidEmail2::Address per call,
# so we cannot capture the instance to stub it. Stubbing the class-level constructor
# would also break ValidEmail2's internal caching. Returning empty MX/A records keeps
# disposable? and valid_mx? offline and deterministic in CI.
allow_any_instance_of(ValidEmail2::Dns).to receive_messages(mx_servers: [], a_servers: []) # rubocop:disable RSpec/AnyInstance
end
%w[user@gmail.com user@outlook.com user@hotmail.com user@yahoo.com user@protonmail.com user@icloud.com].each do |email|
it "rejects #{email} with the free_email_provider message" do
expect { described_class.new(email).perform }.to raise_error do |error|
expect(error.class.name).to eq('CustomExceptions::Account::InvalidEmail')
expect(error.message).to eq(I18n.t('errors.signup.free_email_provider'))
end
end
end
it 'rejects a subdomain of a free provider via ValidEmail2 depth walking' do
expect { described_class.new('user@mail.gmail.com').perform }.to raise_error do |error|
expect(error.class.name).to eq('CustomExceptions::Account::InvalidEmail')
expect(error.message).to eq(I18n.t('errors.signup.free_email_provider'))
end
end
it 'allows a real business domain' do
expect(described_class.new('hello@chatwoot.com').perform).to be(true)
end
it 'still rejects a disposable email with the disposable message, not the free_email message' do
expect { described_class.new('user@mailinator.com').perform }.to raise_error do |error|
expect(error.class.name).to eq('CustomExceptions::Account::InvalidEmail')
expect(error.message).to eq(I18n.t('errors.signup.disposable_email'))
end
end
it 'allows free email providers when allow_free_email_provider is true' do
expect(described_class.new('admin@gmail.com', allow_free_email_provider: true).perform).to be(true)
end
it 'still rejects disposable emails even when allow_free_email_provider is true' do
expect { described_class.new('user@mailinator.com', allow_free_email_provider: true).perform }.to raise_error do |error|
expect(error.class.name).to eq('CustomExceptions::Account::InvalidEmail')
expect(error.message).to eq(I18n.t('errors.signup.disposable_email'))
end
end
end
end