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 Models::Schedulable

Models::Schedulable::SIMPLE_FORM_OPTIONS

Instance Attribute Summary collapse

Belongs to collapse

Methods included from Models::Auditable

#creator, #updater

Has and belongs to many 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 Models::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:



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

validates :email_template, :name, presence: true

#sender_emailObject (readonly)



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

validates :sender_email, presence: true

Class Method Details

.combined_states_for_selectObject



221
222
223
# File 'app/models/campaign_email.rb', line 221

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

.frequencies_selectObject



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

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:



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

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



205
206
207
208
209
210
211
212
213
214
215
# File 'app/models/campaign_email.rb', line 205

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



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

def self.sender_options
  # compact_blank: an active employee without an account email maps to
  # nil/"" and crashes the sort (""[0] is nil).
  (Employee.includes(:employee_account).active_employees.map(&:email_with_name) + CampaignEmail::SPECIAL_SENDERS.map { |name, email| "#{name} <#{email}>" }).compact_blank.sort_by { |s| s[0] }
end

Instance Method Details

#audiencesActiveRecord::Relation<Audience>

Optional per-email recipient override. When any list is attached, this email
sends to those lists instead of the campaign's own lists (see
#recipient_audiences). The join is on the STI campaign_actions table.

Returns:

See Also:



83
84
85
86
# File 'app/models/campaign_email.rb', line 83

has_and_belongs_to_many :audiences,
join_table: :audiences_campaign_actions,
foreign_key: :campaign_action_id,
association_foreign_key: :audience_id

#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:



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

has_many :campaign_deliveries

#can_be_destroyed?Boolean

Returns:

  • (Boolean)


277
278
279
280
281
282
283
284
# File 'app/models/campaign_email.rb', line 277

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)


331
332
333
# File 'app/models/campaign_email.rb', line 331

def can_be_sent?
  unscheduled? or scheduled?
end

#can_be_transmitted?Boolean

Returns:

  • (Boolean)


327
328
329
# File 'app/models/campaign_email.rb', line 327

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

#communication_recipientsActiveRecord::Relation<CommunicationRecipient>

Returns:

See Also:



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

has_many :communication_recipients, through: :campaign_deliveries

#costObject



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

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, :tracking_disabled



363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
# File 'app/models/campaign_email.rb', line 363

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

  tracking_disabled = communication_recipients.where(tracking_disabled: true).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,
    tracking_disabled: tracking_disabled
  }
end

#delivery_percentagesObject



346
347
348
349
350
351
352
353
354
355
356
357
358
# File 'app/models/campaign_email.rb', line 346

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



335
336
337
338
# File 'app/models/campaign_email.rb', line 335

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

#email_template_not_used_by_another_campaignObject



424
425
426
427
428
429
430
431
432
433
434
# File 'app/models/campaign_email.rb', line 424

def email_template_not_used_by_another_campaign
  clash = CampaignEmail.where(email_template_id: email_template_id)
  clash = clash.where.not(id: id) if persisted?
  clash = clash.where.not(campaign_id: campaign_id).includes(:campaign).first
  return unless clash

  errors.add(:email_template,
             "is already used by campaign '#{clash.campaign&.name || clash.campaign_id}' " \
             "(campaign email ##{clash.id}) — a template can only be linked to one campaign. " \
             'Clone the template instead.')
end

#estimated_audience_sizeInteger

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

Returns:

  • (Integer)

    distinct count of active audience_members across the email's
    recipient lists (override or campaign)



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

def estimated_audience_size
  recipient_audience_members.active.distinct.count
end

#frequency_descriptionObject



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

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

#generate_dynamic_audience_membersObject



305
306
307
# File 'app/models/campaign_email.rb', line 305

def generate_dynamic_audience_members
  recipient_audiences.where(list_type: 'dynamic').find_each(&:generate_audience_members)
end

#generate_sourceObject



436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
# File 'app/models/campaign_email.rb', line 436

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



230
231
232
# File 'app/models/campaign_email.rb', line 230

def last_transmitted_formatted
  last_transmitted.to_fs(:compact)
end

#prepare_campaign_deliveriesObject



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

def prepare_campaign_deliveries
  generate_dynamic_audience_members
  records = recipient_audience_members.active.ids.map do |audience_member_id|
    {
      campaign_email_id: id,
      audience_member_id: audience_member_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 audience_member_id]) if records.any?
end

#recipient_audience_membersActiveRecord::Relation<AudienceMember>

Active audience_members across #recipient_audiences.

Returns:



256
257
258
# File 'app/models/campaign_email.rb', line 256

def recipient_audience_members
  AudienceMember.where(audience_id: recipient_audiences.select(:id))
end

#recipient_audiencesActiveRecord::Relation<Audience>

The audiences this email actually sends to: its own override lists
when any are attached, otherwise the campaign's lists. Override lists need
not belong to the campaign — that's the point (e.g. a webinar-attendee
thank-you list separate from the invitee lists).

Returns:



244
245
246
# File 'app/models/campaign_email.rb', line 244

def recipient_audiences
  audiences.any? ? audiences : campaign.audiences
end

#recipient_countObject



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

def recipient_count
  communication_recipients.count
end

#recipient_override?Boolean

Returns whether a per-email recipient override is in effect.

Returns:

  • (Boolean)

    whether a per-email recipient override is in effect



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

def recipient_override?
  audiences.any?
end

#roiObject

return on investment



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

def roi
  return profit if cost.zero?

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

#scheduled_time_descriptionObject



301
302
303
# File 'app/models/campaign_email.rb', line 301

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

#send_emailsObject



322
323
324
325
# File 'app/models/campaign_email.rb', line 322

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

#senderObject



225
226
227
228
# File 'app/models/campaign_email.rb', line 225

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



270
271
272
273
274
275
# File 'app/models/campaign_email.rb', line 270

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



341
342
343
# File 'app/models/campaign_email.rb', line 341

def total_deliveries_count
  delivery_stats.values.sum
end

#update_email_templateObject



418
419
420
421
422
# File 'app/models/campaign_email.rb', line 418

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:



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

has_many :view_campaign_deliveries