diff --git a/Gemfile b/Gemfile
index 389ae5035618c55f9081cb828a7dd8cb671de8d7..a9affea41773842551dee846b16c7745cb11050c 100644
--- a/Gemfile
+++ b/Gemfile
@@ -206,3 +206,5 @@ gem 'net-http', '~> 0.4.0'
 gem 'rubyzip', '~> 2.3'
 
 gem 'hcaptcha', '~> 7.1'
+
+gem 'mail', '~> 2.8'
diff --git a/Gemfile.lock b/Gemfile.lock
index acc4394940d3b31e6a33c7702d4cbd1a1b3e488f..ac50270229b85ad4ca1a524b8b84c78ae772aaa7 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -880,6 +880,7 @@ DEPENDENCIES
   letter_opener_web (~> 2.0)
   link_header (~> 0.0)
   lograge (~> 0.12)
+  mail (~> 2.8)
   mario-redis-lock (~> 1.2)
   md-paperclip-azure (~> 2.2)
   memory_profiler
diff --git a/app/models/user.rb b/app/models/user.rb
index 17ec90c9ee9ea18622cfbf7305b662f6da9f6ccb..c62a6d0de1c4653cf3fe57b45837b678a24a5e39 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -95,6 +95,8 @@ class User < ApplicationRecord
   accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? && !Setting.require_invite_text }
   validates :invite_request, presence: true, on: :create, if: :invite_text_required?
 
+  validates :email, presence: true, email_address: true
+
   validates_with BlacklistedEmailValidator, if: -> { ENV['EMAIL_DOMAIN_LISTS_APPLY_AFTER_CONFIRMATION'] == 'true' || !confirmed? }
   validates_with EmailMxValidator, if: :validate_email_dns?
   validates :agreement, acceptance: { allow_nil: false, accept: [true, 'true', '1'] }, on: :create
diff --git a/app/validators/email_address_validator.rb b/app/validators/email_address_validator.rb
new file mode 100644
index 0000000000000000000000000000000000000000..ed0bb116524aec4c0ee0a6464a70f5ff550a11a9
--- /dev/null
+++ b/app/validators/email_address_validator.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+# NOTE: I initially wrote this as `EmailValidator` but it ended up clashing
+# with an indirect dependency of ours, `validate_email`, which, turns out,
+# has the same approach as we do, but with an extra check disallowing
+# single-label domains. Decided to not switch to `validate_email` because
+# we do want to allow at least `localhost`.
+
+class EmailAddressValidator < ActiveModel::EachValidator
+  def validate_each(record, attribute, value)
+    value = value.strip
+
+    address = Mail::Address.new(value)
+    record.errors.add(attribute, :invalid) if address.address != value
+  rescue Mail::Field::FieldError
+    record.errors.add(attribute, :invalid)
+  end
+end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 39986f476c0161ab4ab3b25524b6f7daaef59ec6..2a07263069944d7d0e2997f259492922cb604491 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -38,6 +38,12 @@ RSpec.describe User do
       user.save(validate: false)
       expect(user.valid?).to be true
     end
+
+    it 'is valid with a localhost e-mail address' do
+      user = Fabricate.build(:user, email: 'admin@localhost')
+      user.valid?
+      expect(user.valid?).to be true
+    end
   end
 
   describe 'Normalizations' do