Class: EmailPreference

Inherits:
ApplicationRecord show all
Includes:
Models::Auditable
Defined in:
app/models/email_preference.rb

Overview

== Schema Information

Table name: email_preferences
Database name: primary

id :integer not null, primary key
disable_announcements :boolean default(FALSE), not null
disable_events :boolean default(FALSE), not null
disable_newsletters :boolean default(FALSE), not null
disable_promotions :boolean default(FALSE), not null
disable_reviews :boolean default(FALSE), not null
disable_webinars :boolean default(FALSE), not null
disable_email_tracking :boolean default(FALSE), not null
email :string not null
last_delivery_status :enum
last_delivery_status_at :datetime
last_delivery_status_notes :string
created_at :datetime
updated_at :datetime
creator_id :integer
updater_id :integer

Indexes

index_email_preferences_on_email (email) UNIQUE

Constant Summary collapse

ALL_STATES =

Recognised all states.

%w[suppressed dropped deferred bounced delivered spammed opened clicked unsubscribed].freeze

Constants included from Models::Auditable

Models::Auditable::ALWAYS_IGNORED

Constants included from Models::Schedulable

Models::Schedulable::SIMPLE_FORM_OPTIONS

Instance Attribute Summary collapse

Has many collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Models::Auditable

#all_skipped_columns, #audit_reference_data, #creator, #should_not_save_version, #stamp_record, #updater

Methods inherited from ApplicationRecord

ransackable_associations, ransackable_attributes, ransackable_scopes, ransortable_attributes, #to_relation

Methods included from Models::Schedulable

config

Methods included from Models::AfterCommittable

#after_commit

Methods included from Models::EventPublishable

#publish_event

Instance Attribute Details

#emailObject (readonly)



32
# File 'app/models/email_preference.rb', line 32

validates :email, email_format: true, presence: true, uniqueness: true

Class Method Details

.can_receive_email_of_category(email, category) ⇒ Object



79
80
81
82
83
84
85
86
87
# File 'app/models/email_preference.rb', line 79

def self.can_receive_email_of_category(email, category)
  ep = EmailPreference.find_by(email: email)
  return true if ep.nil? # no preference exist so all is possible

  category_method = "disable_#{category}"
  return true unless ep.respond_to?(category_method) # unknown category is always good

  !ep.send(category_method)
end

.can_track_email?(email) ⇒ Boolean

Whether open/click tracking should be enabled for emails to this address.
Defaults to true (tracking on) unless explicitly disabled.
Used to comply with CNIL (France) / Garante (Italy) requirements for
prior consent on marketing email tracking pixels.

Returns:

  • (Boolean)


93
94
95
96
97
98
# File 'app/models/email_preference.rb', line 93

def self.can_track_email?(email)
  ep = find_by(email: normalize_value_for(:email, email)) # match the stored :strip/:downcase normalization
  return true if ep.nil?

  !ep.disable_email_tracking
end

.completely_unsubscribedActiveRecord::Relation<EmailPreference>

A relation of EmailPreferences that are completely unsubscribed. Active Record Scope

Returns:

See Also:



38
39
40
41
42
43
44
45
# File 'app/models/email_preference.rb', line 38

scope :completely_unsubscribed, -> {
  where(disable_promotions: true,
        disable_newsletters: true,
        disable_announcements: true,
        disable_events: true,
        disable_webinars: true,
        disable_reviews: true)
}

.decrypt_token(token) ⇒ Object

Returns the email encoded in the token, or nil if it can't be verified
by any of the known formats. Tries MessageVerifier (new format) first,
then the legacy decoders.



169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
# File 'app/models/email_preference.rb', line 169

def self.decrypt_token(token)
  verified = message_verifier.verified(token, purpose: :email_preference)
  return verified if verified

  # Legacy fallback for in-flight customer-email URLs. Encryption.decrypt_string
  # is unauthenticated — a malformed token yields garbage bytes instead of
  # raising, and invalid-UTF-8 garbage otherwise blows up downstream at Postgres
  # (PG::CharacterNotInRepertoire). Only trust a result that is actually an email.
  res = Encryption.decrypt_string(token)
  return res if valid_email_token?(res)

  ActiveSupport::MessageEncryptor.new(Heatwave::Configuration.fetch(:secret_key_base)).decrypt_and_verify(token)
rescue StandardError
  nil
end

.encrypt_email(email) ⇒ Object

Mints a signed token that encodes the customer's email address. Used by
unsubscribe / preference-center URLs in marketing email and in CRM
account UI.

New tokens are signed with ActiveSupport::MessageVerifier under the
purpose 'email_preference/v1' and a key derived from the application
key_generator — payload-bound and tampering-resistant. Legacy tokens
produced by Encryption.encrypt_string (Blowfish/AES, see lib/encryption.rb)
remain decodable via the fallback chain in decrypt_token so that
in-flight links sitting in customer mailboxes keep working through the
grace window. Drop the legacy decoders ~30 days after this lands when
outstanding emails have aged out.



162
163
164
# File 'app/models/email_preference.rb', line 162

def self.encrypt_email(email)
  message_verifier.generate(email.to_s, purpose: :email_preference)
end

.generate_email_preferences_form_url(email) ⇒ Object



119
120
121
122
# File 'app/models/email_preference.rb', line 119

def self.generate_email_preferences_form_url(email)
  # locale: false ensures no locale prefix - Cloudflare worker will add appropriate locale based on user's country
  UrlHelper.instance.(token: EmailPreference.encrypt_email(email), host: "https://#{WEB_HOSTNAME}", locale: false)
end

.import_suppressions(path) ⇒ Object



124
125
126
127
128
129
130
131
132
133
134
# File 'app/models/email_preference.rb', line 124

def self.import_suppressions(path)
  CSV.foreach(path) do |row|
    ep = find_or_create_by(email: row[0])
    ep.update(disable_promotions: true,
              disable_newsletters: true,
              disable_announcements: true,
              disable_events: true,
              disable_webinars: true,
              disable_reviews: true)
  end
end

.message_verifierObject



192
193
194
195
196
197
198
# File 'app/models/email_preference.rb', line 192

def self.message_verifier
  @message_verifier ||= ActiveSupport::MessageVerifier.new(
    Rails.application.key_generator.generate_key('email_preference/v1'),
    digest: 'SHA256',
    serializer: JSON
  )
end

.unsubscribe(emails, disable: true) ⇒ Object



136
137
138
139
140
141
142
143
144
145
146
147
148
# File 'app/models/email_preference.rb', line 136

def self.unsubscribe(emails, disable: true)
  email_preferences = EmailPreference.where(email: emails)
  email_preferences.each do |ep|
    ep.update(
      disable_promotions: disable,
      disable_newsletters: disable,
      disable_announcements: disable,
      disable_events: disable,
      disable_webinars: disable,
      disable_reviews: disable
    )
  end
end

.valid_email_token?(value) ⇒ Boolean

True when a decoded token is a usable email (valid UTF-8 + email shape).
Guards the unauthenticated legacy decoders in decrypt_token from returning
junk for a malformed token.

Returns:

  • (Boolean)


188
189
190
# File 'app/models/email_preference.rb', line 188

def self.valid_email_token?(value)
  value.is_a?(String) && value.valid_encoding? && value.match?(URI::MailTo::EMAIL_REGEXP)
end

Instance Method Details

#accountsActiveRecord::Relation<Account>

Returns:

  • (ActiveRecord::Relation<Account>)

See Also:



35
# File 'app/models/email_preference.rb', line 35

has_many :accounts, foreign_key: :email, primary_key: :email

#any?Boolean

Returns:

  • (Boolean)


70
71
72
73
74
75
76
77
# File 'app/models/email_preference.rb', line 70

def any?
  disable_promotions ||
    disable_newsletters ||
    disable_announcements ||
    disable_events ||
    disable_webinars ||
    disable_reviews
end

#broadcast_updatesObject



210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
# File 'app/models/email_preference.rb', line 210

def broadcast_updates
  event_hash = { email: email }
  event_hash[:categories] = unsubscribed_categories_changes
  return if event_hash[:categories].blank?

  ActiveRecord.after_all_transactions_commit do
    Rails.configuration.event_store.publish(
      Events::EmailUnsubscribed.new(data: {
        email: event_hash[:email],
        categories: event_hash[:categories]
      }),
      stream_name: "EmailPreference-#{id}"
    )
  end
end

#communication_recipientsActiveRecord::Relation<CommunicationRecipient>

Returns:

See Also:



36
# File 'app/models/email_preference.rb', line 36

has_many :communication_recipients, foreign_key: :detail, primary_key: :email

#contact_pointsActiveRecord::Relation<ContactPoint>

Returns:

See Also:



34
# File 'app/models/email_preference.rb', line 34

has_many :contact_points, foreign_key: :detail, primary_key: :email

#disable_allObject

This is to allow the initial state on the form to be selected properly



61
62
63
64
65
66
67
68
# File 'app/models/email_preference.rb', line 61

def disable_all
  disable_promotions &&
    disable_newsletters &&
    disable_announcements &&
    disable_events &&
    disable_webinars &&
    disable_reviews
end

#eu_recipient?Boolean

Whether this address belongs to a party on an EU-country catalog.

Drives whether the preference center offers the open/click tracking control.
That toggle exists for CNIL (France) / Garante (Italy) prior-consent rules,
so it is noise on the North American catalogs. Note this gates the control
only — can_track_email? still enforces whatever is already stored, so a
hidden toggle never silently re-enables tracking.

Any linked party on an EU catalog is enough: an address shared across parties
should see the control rather than lose access to it.

Returns:

  • (Boolean)


112
113
114
115
116
117
# File 'app/models/email_preference.rb', line 112

def eu_recipient?
  Party.where(id: contact_points.select(:party_id))
       .joins(catalog: :country)
       .where(countries: { iso: Country::EU_COUNTRIES_ISO })
       .exists?
end

#to_sObject



200
201
202
# File 'app/models/email_preference.rb', line 200

def to_s
  email
end

#unsubscribed_categories_changesObject



204
205
206
207
208
# File 'app/models/email_preference.rb', line 204

def unsubscribed_categories_changes
  saved_changes.select do |k, v|
    k.starts_with?('disable_') && v.last == true && k != 'disable_email_tracking'
  end.keys.map { |k| k.scan(/disable_(.*)/)[0][0].to_sym }
end