Class: Communication::EventParser

Inherits:
BaseService show all
Defined in:
app/services/communication/event_parser.rb

Overview

Service object: event parser.

Constant Summary collapse

SENDGRID_EVENTS_MAP =
{
  dropped: :dropped,
  delivered: :delivered,
  bounce: :bounced,
  blocked: :blocked,
  open: :opened,
  click: :clicked,
  spamreport: :spammed,
  unsubscribe: :unsubscribed
}.freeze
SENDGRID_EVENTS =

Sendgrid events.

SENDGRID_EVENTS_MAP.keys.freeze

Instance Attribute Summary

Attributes inherited from BaseService

#options

Instance Method Summary collapse

Methods inherited from BaseService

#initialize, #log_debug, #log_error, #log_info, #log_warning, #logger, #tagged_logger

Constructor Details

This class inherits a constructor from BaseService

Instance Method Details

#process(options) ⇒ Object



20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'app/services/communication/event_parser.rb', line 20

def process(options)
  # normalize our keys
  Rails.logger.debug("Processing Sendgrid event", event: options[:event], email: options[:email], unique_id: options[:unique_id])
  options.transform_keys! { |key| key.to_s.underscore.to_sym }
  unique_id = options[:unique_id]
  email = options[:email]&.downcase
  timestamp = options.delete(:timestamp)
  date_time_of_event = Time.zone.at(timestamp) if timestamp.present?
  date_time_of_event ||= Time.current
  event = options[:event].to_s.strip.downcase.presence&.to_sym
  ip_address = options[:ip]
  user_agent = options[:useragent]
  options[:bounce_type] ||= options[:type]
  mapped_status = SENDGRID_EVENTS_MAP[event]

  if email.present? && !email.ends_with?('@warmlyyours.com') && mapped_status
    # Email Preference Check
    EmailPreference.with_advisory_lock(email) do
      ep = EmailPreference.where(email: email).first_or_initialize
      # Only process the event if it came after
      if ep.last_delivery_status_at.nil? || (date_time_of_event > ep.last_delivery_status_at)
        ep.last_delivery_status = mapped_status
        ep.last_delivery_status_notes = options.slice(:reason, :response, :type, :status).map { |k, v| "#{k}: #{v}" }.join(', ')
        ep.last_delivery_status_at = date_time_of_event || Time.current
        ep.save
      end
    end
  end

  # SendGrid webhook events for emails our app sends *outside* the
  # Communication / CommunicationRecipient pipeline (Devise password resets,
  # ExceptionNotifier, ad-hoc Mail.deliver_now in scripts, etc.) arrive here
  # without our `unique_id` custom-arg. They're addressed to internal
  # @warmlyyours.com mailboxes and have nothing to attach to — there is no
  # CommunicationRecipient, and EmailPreference updates are already skipped
  # for @warmlyyours.com above. Drop silently to keep the WARN feed clean.
  # Sample at AppSignal triage Apr 30 2026: every "missing required fields"
  # warning matched this exact shape (unique_id="null", recipient
  # @warmlyyours.com, event in {processed,delivered}).
  #
  # Note: SendGrid sometimes serializes a missing custom-arg as the literal
  # string "null" rather than omitting the field, so we treat that as blank.
  if email&.ends_with?('@warmlyyours.com') &&
     (unique_id.blank? || unique_id.to_s.strip == 'null')
    Rails.logger.debug("Communication Event for untracked internal email — skipping",
                       event: event, email: email)
    return false
  end

  unless unique_id.present? && email.present? && event.present? && date_time_of_event
    Rails.logger.warn("Communication Event missing required fields", event: event, email: email, unique_id: unique_id)
    return false
  end

  # Find communication recipient by communication id and detail
  cr = CommunicationRecipient.joins(:communication)
                             .where(detail: email)
                             .find_by(communications: { unique_id: unique_id })

  # If we don't find the event log and skip
  # bounce, click, deferred, delivered, dropped, processed, open, spamreport, unsubscribe
  unless cr
    Rails.logger.error("Communication Event recipient not found", event: event, email: email, unique_id: unique_id)
    return false
  end

  webhook_event = WebhookEvent.new(timestamp: date_time_of_event)
  webhook_event.attributes = options.select { |k, _v| webhook_event.attributes.keys.member?(k.to_s) }
  cr.webhook_events << webhook_event

  if cr.state_updated_at.present? && date_time_of_event < cr.state_updated_at
    Rails.logger.warn("Communication Event out of order",
                      event: event,
                      email: email,
                      recipient_id: cr.id,
                      event_time: date_time_of_event,
                      last_update: cr.state_updated_at)
    return false
  end

  cr.state_updated_at = date_time_of_event
  cr.user_agent = user_agent
  cr.ip_address = ip_address

  case event
  when :processed
    cr.process
  when :dropped
    cr.state_response = options[:reason]
    cr.drop
  when :delivered
    cr.state_response = options[:response].to_s
    cr.deliver
  when :deferred
    cr.state_response = "Attempt #{options[:attempt]}: #{options[:response]}"
    cr.defer
  when :bounce
    cr.state_response = "#{options[:type]} #{options[:reason]}, #{options[:status]}"
    cr.bounce
  when :open
    # SendGrid stamps every open event with `sg_machine_open` (true => the open
    # was Apple MPP / proxy-prefetched, not a confirmed human render). The raw
    # flag is already captured on `webhook_event` by the attribute-map above;
    # roll it up to a sticky recipient-level signal for deliverability reporting.
    cr.record_machine_open(webhook_event.sg_machine_open)
    cr.open_communication
  when :click
    # Find this url
    el = EmailLink.where(url: webhook_event.url).first_or_create
    # Capture per-click forensics (IP / user-agent) on the join row so
    # Communication::ClickBotScorer can separate scanner clicks from human ones.
    cr.communication_recipient_email_links.create(email_link_id: el.id, ip_address: ip_address, user_agent: user_agent)
    cr.click
  when :spamreport
    cr.spam
  when :unsubscribe
    cr.unsubscribe
  else
    Rails.logger.error "Communication Event #{options.inspect} : Unknown event #{event}"
  end
end