Skip to content
Merged
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
41 changes: 41 additions & 0 deletions app/services/email_normalizer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
require 'resolv'

# Class to help normalize email addresses for services like Gmail that let users
# add extra things after a +
class EmailNormalizer
attr_reader :email

# @param [#to_s] email
def initialize(email)
@email = Mail::Address.new(email.to_s)
end

# @return [String]
def normalized_email
if gmail?
before_plus, _after_plus = email.local.split('+', 2)
[before_plus.tr('.', ''), email.domain].join('@')
else
email.to_s
end
end

private

def gmail?
email.domain == 'gmail.com' || google_mx_record?
end

def google_mx_record?
return false if ENV['RAILS_OFFLINE']
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

mimicking the behavior we have for the email_validator gem so that CI and other test environments don't have to talk to the real internet

validates :email,
email: {
mx_with_fallback: !ENV['RAILS_OFFLINE'],
ban_disposable_email: true,
}


mx_records(email.domain).any? { |domain| domain.end_with?('google.com') }
end

def mx_records(domain)
Resolv::DNS.open do |dns|
dns.getresources(domain, Resolv::DNS::Resource::IN::MX).
map { |r| r.exchange.to_s }
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.

Out of curiosity, what does the .to_s handle here? Can exchange be nil?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

exchange is an instance of Resolv::DNS::Name

quick lil demo:

Resolv::DNS.new.getresources('gsa.gov', Resolv::DNS::Resource::IN::MX)
=> 
[#<Resolv::DNS::Resource::IN::MX:0x0000000103ef51d8 @exchange=#<Resolv::DNS::Name: alt2.aspmx.l.google.com.>, @preference=5, @ttl=8973>,
 #<Resolv::DNS::Resource::IN::MX:0x0000000103ef45d0 @exchange=#<Resolv::DNS::Name: alt4.aspmx.l.google.com.>, @preference=10, @ttl=8973>,
 #<Resolv::DNS::Resource::IN::MX:0x0000000103ef3b80 @exchange=#<Resolv::DNS::Name: alt1.aspmx.l.google.com.>, @preference=5, @ttl=8973>,
 #<Resolv::DNS::Resource::IN::MX:0x0000000103ef3108 @exchange=#<Resolv::DNS::Name: alt3.aspmx.l.google.com.>, @preference=10, @ttl=8973>,
 #<Resolv::DNS::Resource::IN::MX:0x0000000103ef2898 @exchange=#<Resolv::DNS::Name: aspmx.l.google.com.>, @preference=1, @ttl=8973>]

I ended up doing a slightly more realistic stub in 56a77c4

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I went for the most realistic stub in 4a8ff43

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.

Ah, I was looking at the Ruby docs and it didn't specify what exchange was, so wasn't clear to me it was a Resolv::DNS::Name

https://ruby-doc.org/stdlib-3.1.1/libdoc/resolv/rdoc/Resolv/DNS/Resource/MX.html

end
end
end
44 changes: 44 additions & 0 deletions spec/services/email_normalizer_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
require 'rails_helper'

RSpec.describe EmailNormalizer do
subject(:normalizer) { EmailNormalizer.new(email) }

describe '#normalized_email' do
subject(:normalized_email) { normalizer.normalized_email }

context 'with a non-gmail domain' do
let(:email) { 'foobar+123@example.com' }

it 'is the same email' do
expect(normalized_email).to eq(email)
end
end

context 'with a gmail domain' do
let(:email) { 'foo.bar+123@gmail.com' }

it 'removes . and anything after the +' do
expect(normalized_email).to eq('foobar@gmail.com')
end
end

context 'with a Google Apps domain' do
let(:email) { 'foo.bar.baz+123@example.com' }

before do
dns = instance_double(
'Resolv::DNS',
getresources: [
Resolv::DNS::Resource::IN::MX.new(1, Resolv::DNS::Name.new(%w[abcd l google com])),
],
)

allow(Resolv::DNS).to receive(:open).and_yield(dns)
end

it 'still removes . and anything after the +' do
expect(normalized_email).to eq('foobarbaz@example.com')
end
end
end
end