Module: Marketing::CustomerMatch::Hasher

Defined in:
app/services/marketing/customer_match/hasher.rb

Overview

Normalizes + SHA-256-hex-hashes PII for Google Customer Match upload.
Google requires client-side hashing of email/phone/name (lowercase + trim,
hex SHA-256). Email also strips dots and +tags from gmail/googlemail local
parts. Region/postal codes are sent in clear and are NOT handled here.
Mirrors the logic in Invoicing::GoogleConversionReporter#normalize_and_hash.

Class Method Summary collapse

Class Method Details

.email(email, gmail_normalize: true) ⇒ String?

Returns SHA-256 hex of the normalized email, nil if blank.

Parameters:

  • email (String, nil)
  • gmail_normalize (Boolean) (defaults to: true)

    strip dots + +tags from gmail/googlemail
    local parts. Google's Data Manager mirrors this; OpenAI does NOT — its
    spec is plain lowercase+trim+hex, so the OpenAI adapter passes false
    (else gmail hashes wouldn't match OpenAI's index).

Returns:

  • (String, nil)

    SHA-256 hex of the normalized email, nil if blank



23
24
25
26
27
28
29
30
31
32
33
34
# File 'app/services/marketing/customer_match/hasher.rb', line 23

def email(email, gmail_normalize: true)
  return nil if email.blank?

  normalized = email.downcase.strip
  return nil unless normalized.count('@') == 1 # reject "a@b@c" and "no-at-sign"

  local, domain = normalized.split('@', 2)
  return nil if local.blank? || domain.blank?

  local = local.split('+', 2).first.delete('.') if gmail_normalize && domain.match?(/\A(gmail|googlemail)\.com\z/)
  sha256_hex("#{local}@#{domain}")
end

.name(name) ⇒ String?

Returns SHA-256 hex of the normalized name, nil if blank.

Parameters:

  • name (String, nil)

    a given or family name

Returns:

  • (String, nil)

    SHA-256 hex of the normalized name, nil if blank



54
55
56
# File 'app/services/marketing/customer_match/hasher.rb', line 54

def name(name)
  name.blank? ? nil : sha256_hex(name.strip.downcase)
end

.phone(phone, include_plus: true) ⇒ String?

Returns SHA-256 hex of the normalized phone, nil if blank.

Parameters:

  • phone (String, nil)

    expected already E.164 (ContactPoint#detail)

  • include_plus (Boolean) (defaults to: true)

    keep the leading + before hashing. Google
    and OpenAI hash the full E.164 (+1…); Facebook wants digits WITH the
    country code but WITHOUT the + (1…), so the Facebook adapter passes
    false (else phone hashes wouldn't match Meta's index).

Returns:

  • (String, nil)

    SHA-256 hex of the normalized phone, nil if blank



42
43
44
45
46
47
48
49
50
# File 'app/services/marketing/customer_match/hasher.rb', line 42

def phone(phone, include_plus: true)
  return nil if phone.blank?

  normalized = phone.strip.gsub(/[^\d+]/, '')
  return nil unless normalized.match?(/\A\+\d{7,15}\z/) # E.164: leading + and 7–15 digits

  normalized = normalized.delete('+') unless include_plus
  sha256_hex(normalized)
end

.sha256_hex(value) ⇒ String

Returns lowercase hex SHA-256.

Parameters:

  • value (String)

Returns:

  • (String)

    lowercase hex SHA-256



60
61
62
# File 'app/services/marketing/customer_match/hasher.rb', line 60

def sha256_hex(value)
  Digest::SHA256.hexdigest(value)
end