Module: Tracking::Hashing

Defined in:
app/lib/tracking/hashing.rb

Overview

SHA-256 PII hashing helpers shared by every ad-platform CAPI reporter.

Every server-side conversion API (Pinterest CAPI, OpenAI Ads CAPI,
Facebook CAPI, TikTok Events, Google enhanced conversions) requires
personally-identifying user fields to be hashed before transmission:
email lowercased + trimmed → SHA-256, phone normalized to E.164 →
SHA-256, and so on. Each reporter used to re-implement these helpers
([Pinterest::ConversionReporter#normalize_and_hash_email], the
private methods on [Invoicing::GoogleConversionReporter]); this
module centralizes them.

Google is a special case — it requires stripping dots from the
local-part of gmail.com / googlemail.com addresses before hashing
(because Google treats john.doe@gmail.com and johndoe@gmail.com
as the same account). Every other CAPI uses the industry-standard
plain lowercase+trim. We expose both variants explicitly so the
difference is obvious at the call site.

Class Method Summary collapse

Class Method Details

.email(addr) ⇒ String?

SHA-256 hash of a lowercased, whitespace-trimmed email address with
gmail-style dot-stripping on the local-part of @gmail.com and
@googlemail.com addresses (Google treats j.o.h.n@gmail.com and
john@gmail.com as the same account). Non-gmail addresses pass
through unchanged.

Used by every CAPI reporter in this codebase — the existing
Pinterest::ConversionReporter and Invoicing::GoogleConversionReporter
both did gmail dot-stripping pre-extraction, and tighter match rates on
gmail traffic are worth more than spec-purity for the few platforms
that nominally don't require it.

Parameters:

  • addr (String, nil)

    raw email address.

Returns:

  • (String, nil)

    hex-encoded SHA-256, or nil if addr is blank.



40
41
42
43
44
45
46
# File 'app/lib/tracking/hashing.rb', line 40

def email(addr)
  return nil if addr.blank?

  parts = addr.downcase.strip.split('@')
  parts[0] = parts[0].delete('.') if parts.last&.match?(/\A(gmail|googlemail)\.com\z/)
  Digest::SHA256.hexdigest(parts.join('@'))
end

.phone(raw) ⇒ String?

SHA-256 hash of an E.164-normalized phone number. Uses the
phonelib gem (already in the Gemfile for the order-shipping
phone validation path) to do the normalization; returns nil for
any input that can't be parsed into a plausible international
number.

Parameters:

  • raw (String, nil)

    raw phone number, any common format.

Returns:

  • (String, nil)

    hex-encoded SHA-256, or nil if raw is blank
    or unparseable.



57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# File 'app/lib/tracking/hashing.rb', line 57

def phone(raw)
  return nil if raw.blank?

  # Phonelib is `require: false` in the Gemfile (heavy load cost, only
  # paid by the few call sites that actually parse phones). Require
  # lazily so this module stays cheap to load.
  require 'phonelib'

  parsed = ::Phonelib.parse(raw)
  # `parsed.e164` echoes garbage back for unparseable input — and with
  # `Phonelib.vanity_conversion` enabled app-wide (see the 220_phonelib
  # initializer), letter strings get keypad-mapped to digits
  # ("not-a-phone" → "+668274663"). Gate on `possible?` so only numbers
  # of a plausible length are hashed.
  return nil unless parsed.possible?

  Digest::SHA256.hexdigest(parsed.e164)
end

.sha256(str) ⇒ String?

SHA-256 hash of a plain string — used for free-form identifiers
like a customer's internal numeric ID that some platforms accept
in hashed form even though it isn't PII per se.

Parameters:

  • str (String, Integer, nil)

    value to hash.

Returns:

  • (String, nil)

    hex-encoded SHA-256, or nil if str is blank.



82
83
84
85
86
# File 'app/lib/tracking/hashing.rb', line 82

def sha256(str)
  return nil if str.blank?

  Digest::SHA256.hexdigest(str.to_s.downcase.strip)
end