Class: CampaignEmail

Inherits:
CampaignAction show all
Includes:
Memery, Models::LiquidMethods
Defined in:
app/models/campaign_email.rb

Overview

== Schema Information

Table name: campaign_actions
Database name: primary

id :integer not null, primary key
description :text
frequency :integer
last_transmitted :datetime
name :string
scheduled_time :datetime
sender_email :string
sequence :integer
state :string
type :string
created_at :datetime not null
updated_at :datetime not null
campaign_id :integer
creator_id :integer
email_template_id :integer
sender_id :integer
source_id :integer
updater_id :integer

Indexes

campaign_actions_campaign_id_idx (campaign_id)
campaign_actions_email_template_id_idx (email_template_id)
idx_type (type)

Foreign Keys

fk_rails_... (campaign_id => campaigns.id)
fk_rails_... (email_template_id => email_templates.id)

Constant Summary collapse

FREQUENCIES =

Frequencies.

{ 'daily' => 86_400, 'weekly' => 604_800 }.freeze
SPECIAL_SENDERS =

Special senders.

{ 'Social' => 'social@warmlyyours.com' }.freeze
STATES =

:percentage_for_processed,
:percentage_for_deferred,
:percentage_for_delivered,
:percentage_for_open,
:percentage_for_click,
:percentage_for_bounce,
:percentage_for_dropped,
:percentage_for_spamreport,
:percentage_for_unsubscribe,

%w[
  pending
  queued
  exception
  suppressed
  duplicate
  deferred
  dropped
  bounced
  sent
  processed
  delivered
  opened
  clicked
  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 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

#clone_email_template_idObject

Returns the value of attribute clone_email_template_id.



42
43
44
# File 'app/models/campaign_email.rb', line 42

def clone_email_template_id
  @clone_email_template_id
end

#nameObject (readonly)

before_destroy :can_be_destroyed?

Validations:



88
# File 'app/models/campaign_email.rb', line 88

validates :email_template, :name, presence: true

#sender_emailObject (readonly)



89
# File 'app/models/campaign_email.rb', line 89

validates :sender_email, presence: true

Class Method Details

.combined_states_for_selectObject



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

def self.combined_states_for_select
  STATES.map { |state| [state.to_s.titleize, state] }
end

.frequencies_selectObject



201
202
203
# File 'app/models/campaign_email.rb', line 201

def self.frequencies_select
  FREQUENCIES.map { |text, seconds| [text, seconds] }
end

.ready_to_be_transmittedActiveRecord::Relation<CampaignEmail>

A relation of CampaignEmails that are ready to be transmitted. Active Record Scope

Returns:

See Also:



95
# File 'app/models/campaign_email.rb', line 95

scope :ready_to_be_transmitted, -> { joins(:campaign).where(campaigns: { state: 'active' }, state: 'scheduled').where(CampaignEmail[:scheduled_time].lteq(Time.current)) }

.send_monthly_summary_email(date_start: nil, date_end: nil) ⇒ Object



189
190
191
192
193
194
195
196
197
198
199
# File 'app/models/campaign_email.rb', line 189

def self.send_monthly_summary_email(date_start: nil, date_end: nil)
  date_start ||= Date.current.beginning_of_month
  date_end ||= date_start.end_of_month
  date_range = (date_start..date_end)

  campaign_emails = CampaignEmail.where(last_transmitted: date_range).joins(:campaign).where('last_transmitted between ? and ? and exclude_from_monthly_report = false', Date.current.last_month.beginning_of_month.beginning_of_day,
Date.current.last_month.end_of_month.end_of_day).order(last_transmitted: :asc).to_a
  return 'No emails sent last month' if campaign_emails.blank?

  InternalReportsMailer.campaign_summary(campaign_emails).deliver
end

.sender_optionsObject



185
186
187
# File 'app/models/campaign_email.rb', line 185

def self.sender_options
  (Employee.includes(:employee_account).active_employees.map(&:email_with_name) + CampaignEmail::SPECIAL_SENDERS.map { |name, email| "#{name} <#{email}>" }).sort_by { |s| s[0] }
end

Instance Method Details

#campaignCampaign

Returns:

See Also:



77
# File 'app/models/campaign_email.rb', line 77

belongs_to :campaign, inverse_of: :campaign_emails, optional: true

#campaign_deliveriesActiveRecord::Relation<CampaignDelivery>

Returns:

See Also:



80
# File 'app/models/campaign_email.rb', line 80

has_many :campaign_deliveries

#can_be_destroyed?Boolean

Returns:

  • (Boolean)


239
240
241
242
243
244
245
246
# File 'app/models/campaign_email.rb', line 239

def can_be_destroyed?
  if (unscheduled? || scheduled?) && campaign_deliveries.empty?
    true
  else
    errors.add :base, 'cannot delete campaign email which has already been sent'
    false
  end
end

#can_be_sent?Boolean

Returns:

  • (Boolean)


293
294
295
# File 'app/models/campaign_email.rb', line 293

def can_be_sent?
  unscheduled? or scheduled?
end

#can_be_transmitted?Boolean

Returns:

  • (Boolean)


289
290
291
# File 'app/models/campaign_email.rb', line 289

def can_be_transmitted?
  scheduled_time.present? and scheduled_time <= Time.current
end

#communication_recipientsActiveRecord::Relation<CommunicationRecipient>

Returns:

See Also:



81
# File 'app/models/campaign_email.rb', line 81

has_many :communication_recipients, through: :campaign_deliveries

#costObject



248
249
250
# File 'app/models/campaign_email.rb', line 248

def cost
  communication_recipients.count * 0.0008 # this is the cost per email on sendgrid
end

#delivery_funnel_countsObject

Returns a monotonic funnel of counts for easier human interpretation.
Keys are: :total_recipients, :suppressed, :processed, :bounced, :delivered, :opened, :clicked, :unsubscribed, :spammed



325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
# File 'app/models/campaign_email.rb', line 325

def delivery_funnel_counts
  suppressed = campaign_deliveries.where(state: 'suppressed').count

  # Recipients that entered the transmission pipeline (a communication exists)
  processed_total = communication_recipients.count

  # Show the size of the original audience as Suppressed + Processed
  total_recipients = suppressed + processed_total

  # Outcome states from webhook processing
  bounced_total = communication_recipients.where(state: 'bounced').count
  delivered_total = communication_recipients.where(state: %w[delivered opened clicked spammed unsubscribed]).count
  opened_total = communication_recipients.where(state: %w[opened clicked]).count
  # Split opens into machine-only (Apple MPP / security-scanner prefetch,
  # flagged by SendGrid's sg_machine_open) vs confirmed human. A recipient is
  # "human" the moment any non-machine open is seen; legacy opens with no flag
  # (machine_open IS NULL) count as human, so historical numbers are unchanged.
  machine_opened_total = communication_recipients.where(state: %w[opened clicked], machine_open: true).count
  human_opened_total = opened_total - machine_opened_total
  clicked_total = communication_recipients.where(state: 'clicked').count
  # Split clicks into machine (security-scanner: shared-IP fan-out or rapid
  # multi-link bursts, scored by Communication::ClickBotScorer) vs human. As with
  # opens, unscored/legacy clicks (machine_clicked IS NULL) count as human.
  machine_clicked_total = communication_recipients.where(state: 'clicked', machine_clicked: true).count
  human_clicked_total = clicked_total - machine_clicked_total
  unsubscribed_total = communication_recipients.where(state: 'unsubscribed').count
  spammed_total = communication_recipients.where(state: 'spammed').count

  {
    total_recipients: total_recipients,
    suppressed: suppressed,
    processed: processed_total,
    bounced: bounced_total,
    delivered: delivered_total,
    opened: opened_total,
    machine_opened: machine_opened_total,
    human_opened: human_opened_total,
    clicked: clicked_total,
    machine_clicked: machine_clicked_total,
    human_clicked: human_clicked_total,
    unsubscribed: unsubscribed_total,
    spammed: spammed_total
  }
end

#delivery_percentagesObject



308
309
310
311
312
313
314
315
316
317
318
319
320
# File 'app/models/campaign_email.rb', line 308

def delivery_percentages
  hsh = {}
  total = total_deliveries_count
  delivery_stats.each do |state, counter|
    percentage = if counter > 0 && total > 0
                   ((counter.to_f / total) * 100).round(2)
                 else
                   0.0
                 end
    hsh[state] = percentage
  end
  hsh
end

#delivery_statsObject



297
298
299
300
# File 'app/models/campaign_email.rb', line 297

def delivery_stats
  hsh = view_campaign_deliveries.group(:combined_state).count
  hsh.slice(*STATES) # Re-sorts by specific state order
end

#email_templateEmailTemplate

Validations:



78
# File 'app/models/campaign_email.rb', line 78

belongs_to :email_template, optional: true

#estimated_audience_sizeInteger

Estimated audience size before transmission, mirroring the subscribers
used by #prepare_campaign_deliveries. Does not include subscribers
that would be added by generate_email_dynamic_subscribers at send time.

Returns:

  • (Integer)

    distinct count of active subscribers across the
    campaign's subscriber lists



228
229
230
# File 'app/models/campaign_email.rb', line 228

def estimated_audience_size
  campaign.subscribers.active.distinct.count
end

#frequency_descriptionObject



259
260
261
# File 'app/models/campaign_email.rb', line 259

def frequency_description
  frequency.nil? ? 'One time' : FREQUENCIES.find { |_k, v| v == frequency }[0]
end

#generate_email_dynamic_subscribersObject



267
268
269
# File 'app/models/campaign_email.rb', line 267

def generate_email_dynamic_subscribers
  campaign.subscriber_lists.where(list_type: 'email_dynamic').find_each(&:generate_subscribers)
end

#generate_sourceObject



383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
# File 'app/models/campaign_email.rb', line 383

def generate_source
  return if campaign&.source_id.nil?

  s = Source.new
  s.parent_id = campaign.source_id
  s.name = name
  s.referral_code = s.generate_ref_code
  if s.save
    self.source_id = s.id
    true
  else
    errors.add(:base, "Unable to create source. Error: #{s.errors.full_messages}")
    false
  end
end

#last_transmitted_formattedObject



214
215
216
# File 'app/models/campaign_email.rb', line 214

def last_transmitted_formatted
  last_transmitted.to_fs(:compact)
end

#prepare_campaign_deliveriesObject



271
272
273
274
275
276
277
278
279
280
281
282
# File 'app/models/campaign_email.rb', line 271

def prepare_campaign_deliveries
  generate_email_dynamic_subscribers
  records = campaign.subscribers.active.ids.map do |subscriber_id|
    {
      campaign_email_id: id,
      subscriber_id: subscriber_id,
      state: 'pending'
    }
  end
  # state: 'pending' is preset in the row hash so no model callbacks needed.
  CampaignDelivery.insert_all(records, unique_by: %i[campaign_email_id subscriber_id]) if records.any?
end

#recipient_countObject



218
219
220
# File 'app/models/campaign_email.rb', line 218

def recipient_count
  communication_recipients.count
end

#roiObject

return on investment



253
254
255
256
257
# File 'app/models/campaign_email.rb', line 253

def roi
  return profit if cost.zero?

  ((profit - cost) / cost) * 100
end

#scheduled_time_descriptionObject



263
264
265
# File 'app/models/campaign_email.rb', line 263

def scheduled_time_description
  frequency.nil? ? 'Scheduled Time' : 'Next Scheduled Time'
end

#send_emailsObject



284
285
286
287
# File 'app/models/campaign_email.rb', line 284

def send_emails
  prepare_campaign_deliveries if campaign_deliveries.blank?
  CampaignDeliveryWorker.perform_async
end

#senderObject



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

def sender
  email = Mail::Address.new(sender_email).address
  email.nil? ? nil : Employee.joins(:employee_account).where(accounts: { email: email }).first
end

#set_new_scheduled_timeObject



232
233
234
235
236
237
# File 'app/models/campaign_email.rb', line 232

def set_new_scheduled_time
  return if frequency.blank?

  update(scheduled_time: last_transmitted + frequency)
  reschedule
end

#sourceSource

Returns:

See Also:



79
# File 'app/models/campaign_email.rb', line 79

belongs_to :source, optional: true

#total_deliveries_countObject



303
304
305
# File 'app/models/campaign_email.rb', line 303

def total_deliveries_count
  delivery_stats.values.sum
end

#update_email_templateObject



377
378
379
380
381
# File 'app/models/campaign_email.rb', line 377

def update_email_template
  return unless email_template&.new_record?

  email_template.description = "Template for campaign email #{name}"
end

#view_campaign_deliveriesActiveRecord::Relation<ViewCampaignDelivery>

Returns:

See Also:



82
# File 'app/models/campaign_email.rb', line 82

has_many :view_campaign_deliveries