Class: CommunicationRecipient

Inherits:
ApplicationRecord show all
Includes:
ActionView::Helpers::NumberHelper, Models::Auditable
Defined in:
app/models/communication_recipient.rb

Overview

== Schema Information

Table name: communication_recipients
Database name: primary

id :integer not null, primary key
canary_ip :string
canary_tripped_at :datetime
category :string(255)
detail :string(255)
email_method :string(3)
ip_address :string(255)
machine_clicked :boolean
machine_open :boolean
name :string(255)
state :string(25)
state_response :text
state_updated_at :datetime
user_agent :text
created_at :datetime not null
updated_at :datetime not null
communication_id :integer
contact_point_id :integer

Indexes

idx_comm_rp_contact_point_id (contact_point_id)
idx_communication_id_contact_point_id (communication_id,contact_point_id)
idx_communication_id_email_method (communication_id,email_method)
idx_communication_recipients_unique (communication_id,category,detail) UNIQUE
index_communication_recipients_on_state (state)
index_communication_recipients_on_state_updated_at (state_updated_at) USING brin

Foreign Keys

communication_recipients_communication_id_fk (communication_id => communications.id) ON DELETE => cascade

Constant Summary collapse

UNDELIVERED =

Undelivered.

%w[dropped deferred bounced].freeze
DELIVERED =

Delivered.

%w[processed delivered opened clicked spammed unsubscribed].freeze
UNWANTED =

Unwanted.

%w[spammed unsubscribed].freeze

Constants included from Models::Auditable

Models::Auditable::ALWAYS_IGNORED

Constants included from Schedulable

Schedulable::SIMPLE_FORM_OPTIONS

Instance Attribute Summary collapse

Belongs to collapse

Methods included from Models::Auditable

#creator, #updater

Has one collapse

Has many collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Models::Auditable

#all_skipped_columns, #audit_reference_data, #should_not_save_version, #stamp_record

Methods inherited from ApplicationRecord

ransackable_associations, ransackable_attributes, ransackable_scopes, ransortable_attributes, #to_relation

Methods included from Schedulable

config

Methods included from Models::AfterCommittable

#after_commit

Methods included from Models::EventPublishable

#publish_event

Instance Attribute Details

#categoryObject (readonly)



127
# File 'app/models/communication_recipient.rb', line 127

validates :category, presence: true, inclusion: { in: [ContactPoint::FAX, ContactPoint::EMAIL] }

#detailObject (readonly)



128
# File 'app/models/communication_recipient.rb', line 128

validates :detail, presence: true

#ignore_global_unsubscribeObject

Returns the value of attribute ignore_global_unsubscribe.



58
59
60
# File 'app/models/communication_recipient.rb', line 58

def ignore_global_unsubscribe
  @ignore_global_unsubscribe
end

Class Method Details

.emailsActiveRecord::Relation<CommunicationRecipient>

A relation of CommunicationRecipients that are emails. Active Record Scope

Returns:

See Also:



119
# File 'app/models/communication_recipient.rb', line 119

scope :emails, -> { where(category: ContactPoint::EMAIL) }

.faxesActiveRecord::Relation<CommunicationRecipient>

A relation of CommunicationRecipients that are faxes. Active Record Scope

Returns:

See Also:



120
# File 'app/models/communication_recipient.rb', line 120

scope :faxes, -> { where(category: ContactPoint::FAX) }

.states_for_selectObject



177
178
179
# File 'app/models/communication_recipient.rb', line 177

def self.states_for_select
  state_machines[:state].states.map(&:name)
end

.undeliveredActiveRecord::Relation<CommunicationRecipient>

A relation of CommunicationRecipients that are undelivered. Active Record Scope

Returns:

See Also:



121
# File 'app/models/communication_recipient.rb', line 121

scope :undelivered, -> { where(state: UNDELIVERED) }

.unwantedActiveRecord::Relation<CommunicationRecipient>

A relation of CommunicationRecipients that are unwanted. Active Record Scope

Returns:

See Also:



122
# File 'app/models/communication_recipient.rb', line 122

scope :unwanted, -> { where(state: UNWANTED) }

.with_partyActiveRecord::Relation<CommunicationRecipient>

A relation of CommunicationRecipients that are with party. Active Record Scope

Returns:

See Also:



118
# File 'app/models/communication_recipient.rb', line 118

scope :with_party, -> { joins(contact_point: :party) }

Instance Method Details

#campaign_deliveryCampaignDelivery



53
# File 'app/models/communication_recipient.rb', line 53

has_one :campaign_delivery

#combo_categoryObject



198
199
200
201
202
203
# File 'app/models/communication_recipient.rb', line 198

def combo_category
  return ContactPoint::FAX if is_fax?

  self.email_method ||= 'to' if is_email?
  "#{ContactPoint::EMAIL}_#{email_method}"
end

#combo_category=(val) ⇒ Object



187
188
189
190
191
192
193
194
195
196
# File 'app/models/communication_recipient.rb', line 187

def combo_category=(val)
  map = { 'email_to' => %w[email to],
          'email_cc' => %w[email cc],
          'email_bcc' => %w[email bcc],
          'fax' => ['fax', nil] }[val]
  return unless map

  self.category = map[0]
  self.email_method = map[1]
end

#communicationCommunication



51
# File 'app/models/communication_recipient.rb', line 51

belongs_to :communication, inverse_of: :communication_recipients, optional: true

Returns:

See Also:



55
# File 'app/models/communication_recipient.rb', line 55

has_many :communication_recipient_email_links

#contact_pointContactPoint



52
# File 'app/models/communication_recipient.rb', line 52

belongs_to :contact_point, inverse_of: :communication_recipients, optional: true

#customerObject



235
236
237
# File 'app/models/communication_recipient.rb', line 235

def customer
  party.try(:customer)
end

#deep_dupObject



132
133
134
135
136
# File 'app/models/communication_recipient.rb', line 132

def deep_dup
  deep_clone(except: %i[state_updated_at user_agent ip_address state_response machine_open machine_clicked canary_tripped_at canary_ip]) do |_original, copy|
    copy.state = 'ok' if copy.is_a?(CommunicationRecipient)
  end
end

#display_formatObject



243
244
245
246
247
248
249
250
# File 'app/models/communication_recipient.rb', line 243

def display_format
  case category
  when ContactPoint::FAX
    PhoneNumber.parse_and_format(detail)
  else
    detail
  end
end

#display_nameObject



239
240
241
# File 'app/models/communication_recipient.rb', line 239

def display_name
  name || contact_point.try(:party).try(:full_name)
end

Returns:

See Also:



56
# File 'app/models/communication_recipient.rb', line 56

has_many :email_links, -> { distinct }, through: :communication_recipient_email_links

#formatted_email_stringObject



221
222
223
224
225
226
227
228
229
# File 'app/models/communication_recipient.rb', line 221

def formatted_email_string
  case category
  when ContactPoint::FAX
    n = PhoneNumber.parse_and_format(detail, display_format: :fax_dial)
    "#{n}@fax.tc"
  when ContactPoint::EMAIL
    detail
  end
end

#is_email?Boolean

Returns:

  • (Boolean)


213
214
215
# File 'app/models/communication_recipient.rb', line 213

def is_email?
  category == ContactPoint::EMAIL
end

#is_fax?Boolean

Returns:

  • (Boolean)


217
218
219
# File 'app/models/communication_recipient.rb', line 217

def is_fax?
  category == ContactPoint::FAX
end

#is_undelivered?Boolean

Returns:

  • (Boolean)


205
206
207
# File 'app/models/communication_recipient.rb', line 205

def is_undelivered?
  UNDELIVERED.include? state
end

#is_unwanted?Boolean

Returns:

  • (Boolean)


209
210
211
# File 'app/models/communication_recipient.rb', line 209

def is_unwanted?
  UNWANTED.include? state
end

#normalize_formatObject (protected)



254
255
256
257
258
259
260
261
262
263
264
265
266
# File 'app/models/communication_recipient.rb', line 254

def normalize_format
  return if detail.blank?

  # Remove invalid characters, stick to ascii
  self.detail = detail.gsub(/\P{ASCII}/u, '').strip.downcase
  if RFC822::EMAIL.match?(detail)
    self.category = ContactPoint::EMAIL
  elsif (p = PhoneNumber.parse(detail))
    self.category = ContactPoint::FAX
    self.detail = p.to_s
  end
  true
end

#notify_rep_email_bouncedObject



181
182
183
184
185
# File 'app/models/communication_recipient.rb', line 181

def notify_rep_email_bounced
  return unless communication.important?

  SystemMailer.email_address_bounced_notification(self).deliver_later
end

#partyObject



231
232
233
# File 'app/models/communication_recipient.rb', line 231

def party
  contact_point.try(:party)
end

#record_canary_trip!(ip:) ⇒ void

This method returns an undefined value.

Record that this recipient's invisible canary link was fetched — a provable
security-scanner hit, since no human can see or click an invisible link (see
Communication#canary_link_html, Api::V1::EmailCanaryController). First hit
wins; the IP is the scanner's egress, which Communication::ClickBotScorer
folds into its scanner-IP set for the whole send. Uses update_columns: a
denormalized signal that must not fire callbacks/validations or disturb the
recipient's delivery state.

Parameters:

  • ip (String, nil)

    the IP that fetched the canary



171
172
173
174
175
# File 'app/models/communication_recipient.rb', line 171

def record_canary_trip!(ip:)
  return if canary_tripped_at.present? # idempotent — first hit wins

  update_columns(canary_tripped_at: Time.current, canary_ip: ip)
end

#record_machine_open(machine) ⇒ void

This method returns an undefined value.

Roll up SendGrid's per-open sg_machine_open indicator to a sticky
recipient-level signal for deliverability reporting.

Apple Mail Privacy Protection (and corporate security scanners that load
images) fire an open for every message regardless of whether a human
looked at it — SendGrid flags those as sg_machine_open: true. A single
non-machine open, by contrast, is proof a real client rendered the
tracking pixel, so once we see one we latch #machine_open to false and
never flip it back. A nil flag (SendGrid omitted it, e.g. legacy events)
carries no signal and leaves the value untouched — those count as human.

Sets the attribute only; the subsequent open_communication transition
persists it alongside the state change.

Parameters:

  • machine (Boolean, nil)

    the event's cast sg_machine_open value



154
155
156
157
158
159
# File 'app/models/communication_recipient.rb', line 154

def record_machine_open(machine)
  return if machine_open == false # confirmed-human latch wins permanently
  return if machine.nil?          # no indicator on this event — no information

  self.machine_open = machine
end

#webhook_eventsActiveRecord::Relation<WebhookEvent>

Returns:

See Also:



54
# File 'app/models/communication_recipient.rb', line 54

has_many :webhook_events