email_events 1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: a50ec61b9cc483e32fa321362664ece0aa435628
4
+ data.tar.gz: 281031a6d100071fae7ddb5d4e8dce6332d6d3c0
5
+ SHA512:
6
+ metadata.gz: d251a256c1798791572a0742f91412b307af47dd35f20b792c14bdf795b9f8407a36cae3f0dde09f43391401aa13ffcb19fcf4628d97f8c2352f86041aa79a53
7
+ data.tar.gz: 6d4f20aa9df67859b13c308c5b5bbea116616b2d88d48bc9a3bae582627aede91da7b87ce6b8b3509934e685668a49597c8190b206263f0fe6aadf6b5a4cdb12
@@ -0,0 +1,10 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+ /.idea/
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format documentation
2
+ --color
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in email_events.gemspec
4
+ gemspec
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2016 Coupa Software Inc
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,136 @@
1
+ # EmailEvents
2
+
3
+ EmailEvents handles incoming events for your emails: bounces, drops, delivery, link clicks, and replies. It aims to do
4
+ this in a provider agnostic way. Currently supports Sengrid and AWS.
5
+
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem 'email_events'
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ $ bundle
18
+
19
+ Or install it yourself as:
20
+
21
+ $ gem install email_events
22
+
23
+ Then install the migration for tracking sent emails:
24
+
25
+ $ rails g email_events:install
26
+
27
+ ### Rails configuration
28
+
29
+ 1. Setup your action_mailer smtp settings as usual. Email_events will detect whether your using Sendgrid SES based on the
30
+ smtp address, but you can also set the adapter in an initializer -- eg. `EmailEvents.provider = :sendgrid`.
31
+
32
+ 2. **Important**: Set `config.action_mailer.smtp_settings[:return_response] = true`. This is necessary to get the email
33
+ provider's unique ids back for tracking events against the original email.
34
+
35
+
36
+ ### Sendgrid
37
+
38
+ 1. Login to your SendGrid account and navigate to "Mail Settings" -> Event Notification.
39
+
40
+ 2. Turn the module "On".
41
+
42
+ 3. Set the HTTP POST URL to https://<yourdomain>/email_events/sendgrid
43
+
44
+ 4. Under "Select Actions", choose the event types for which you would like to receive triggers.
45
+
46
+ That's it!
47
+
48
+ ### AWS SES / SNS
49
+
50
+ 1. Login to the AWS Management Console.
51
+
52
+ 2. Open up the AWS SNS console.
53
+
54
+ 3. Click "Create Topic". Set both the the "Topic name" to "email_events" and the "Display name" to "emails". Click "Create topic".
55
+
56
+ 4. In the "Topic Details", click "Create Subscription". Set the endpoint to https://<yourdomain>/email_events/ses.
57
+
58
+ 5. With your Rails server running, click "Request confirmations" to confirm the subscription at your endpoint.
59
+
60
+ 6. Open up the AWS SES console.
61
+
62
+ 7. Under either "Domains" or "Email Addresses" (depending on whether you want event triggers for an entire domain or individual
63
+ senders), click on a domain or email adress, then click "Details" and expand the "Notifications" tab.
64
+
65
+ 8. Click Edit Configuration and set the SNS Topic to "email_events" for Bounces, Complaints, and/or Deliveries.
66
+
67
+ 9. At the moment, you need to mount the sns_endpoint gem engine which email_events uses (yes, a nice-to-have would be for
68
+ email_events to just act as an engine itself). Put `mount SnsEndpoint::Core => '/email_events/ses'` in your routes.rb.
69
+
70
+ ## Usage
71
+
72
+ ### Basic
73
+
74
+ Simply add an `on_event` handler to your mailer to start handling email events. Eg.:
75
+
76
+ ```
77
+ class MyMailer < ActionMailer::Base
78
+ on_event :handle_event
79
+
80
+ ...
81
+
82
+
83
+ def handle_event(event_data, email_data)
84
+ if event_data.event_type == :bounce
85
+ my_bounce_notification_method(email_data.to)
86
+ end
87
+ end
88
+ end
89
+ ```
90
+
91
+ ### Supported Event Types
92
+
93
+ - For Sendgrid: :delivered, :bounce, :dropped, :deferred, :processed, :click, :open, :spamreport, :group_unsubscribe, :group_resubscribe
94
+ - For AWS: :delivered, :bounce, :spamreport
95
+
96
+ ### Advanced
97
+
98
+ You can track custom JSON data along with the original email message. This data will then be available to you in the event
99
+ handler:
100
+
101
+ ```
102
+ class MyMailer < ActionMailer::Base
103
+ on_event :handle_event
104
+ track_data :custom_metadata
105
+
106
+ ...
107
+
108
+ def custom_metadata
109
+ {
110
+ my_data: true
111
+ }
112
+ end
113
+
114
+ def handle_event(event_data, email_data)
115
+ my_data = email_data.data[:my_data]
116
+ ...
117
+ end
118
+ end
119
+ ```
120
+
121
+
122
+ ## Development
123
+
124
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
125
+
126
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
127
+
128
+ ## Contributing
129
+
130
+ Bug reports and pull requests are welcome on GitHub at https://github.com/85x14/email_events.
131
+
132
+
133
+ ## License
134
+
135
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
136
+
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
@@ -0,0 +1,4 @@
1
+ machine:
2
+ ruby:
3
+ version:
4
+ 2.2.4
@@ -0,0 +1,32 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'email_events/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "email_events"
8
+ spec.version = EmailEvents::VERSION
9
+ spec.authors = ["Kent Mewhort @ Coupa"]
10
+ spec.email = ["kent.mewhort@coupa.com"]
11
+
12
+ spec.summary = %q{Email event handling for delivery, bounces, drops, replies, etc.}
13
+ spec.description = %q{Supports handling incoming events for SES/SNS or Sendgrid}
14
+ spec.homepage = "https://github.com/85x14/email_events"
15
+ spec.license = "MIT"
16
+
17
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
18
+ spec.bindir = "exe"
19
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
20
+ spec.require_paths = ["lib"]
21
+
22
+ spec.required_ruby_version = '~> 2.0'
23
+ spec.add_dependency "gridhook"
24
+ spec.add_dependency "sns_endpoint"
25
+ spec.add_dependency "uuidtools"
26
+ spec.add_dependency "virtus"
27
+ spec.add_development_dependency "bundler"
28
+ spec.add_development_dependency "rake", "~> 10.0"
29
+ spec.add_development_dependency "rspec", "~> 3.0"
30
+ spec.add_development_dependency "sqlite3", "~> 1.0"
31
+ spec.add_development_dependency "combustion", "~> 0.5.0"
32
+ end
@@ -0,0 +1,46 @@
1
+ require "email_events/version"
2
+ require "email_events/mailer"
3
+ require "email_events/railtie"
4
+
5
+ require "email_events/models/sent_email_data"
6
+ require "email_events/adapters/abstract/initializer"
7
+ require "email_events/adapters/sendgrid/initializer"
8
+ require "email_events/adapters/ses/initializer"
9
+ require "email_events/adapters/abstract/event_data"
10
+ require "email_events/adapters/sendgrid/event_data"
11
+ require "email_events/adapters/ses/event_data"
12
+ require "email_events/adapters/abstract/smtp_response"
13
+ require "email_events/adapters/sendgrid/smtp_response"
14
+ require "email_events/adapters/ses/smtp_response"
15
+
16
+ require "email_events/services/service"
17
+ require "email_events/services/track_data_in_header"
18
+ require "email_events/services/retrieve_data_from_header"
19
+ require "email_events/services/handle_event"
20
+ require "email_events/services/parse_smtp_response_for_provider_id"
21
+
22
+ module EmailEvents
23
+ def self.initialize
24
+ adapter.const_get('Initializer').initialize unless adapter.nil?
25
+ end
26
+
27
+ def self.adapter
28
+ # auto-detect the adapter unless it's already been explicitly set
29
+ @adapter ||= begin
30
+ adapter_initializer = EmailEvents::Adapters::Abstract::Initializer.descendants.find {|adapter| adapter.load_adapter?}
31
+ return nil if adapter_initializer.nil?
32
+
33
+ adapter_initializer.parent
34
+ end
35
+ end
36
+
37
+ def self.adapter=(adapter_module)
38
+ if adapter_module.is_a?(String) || adapter_module.is_a?(Symbol)
39
+ @adapter = "EmailEvents::Adapters::#{adapter_module.to_s.camelize}".constantize
40
+ else
41
+ @adapter = adapter_module
42
+ end
43
+ end
44
+ end
45
+
46
+
@@ -0,0 +1,30 @@
1
+ module EmailEvents::Adapters
2
+ module Abstract
3
+ class EventData
4
+ [:event_type, :event_timestamp, :recipient, :status_string, :smtp_status_code, :reason,
5
+ :smtp_message_id, :provider_message_id, :simplified_status, :raw_data].each do |pure_virtual_method|
6
+ define_method(pure_virtual_method) { raise "Not implemented" }
7
+ end
8
+
9
+ def simplified_status
10
+ # try to get a specific status based on the smtp status code; however, if the event doesn't have an smtp
11
+ # status code (eg. bounce events always do, but drop events only do sometimes), supply a generic one
12
+ return :unable_to_send_email_to_address_provided if smtp_status_code.blank?
13
+
14
+ case smtp_status_code
15
+ when 510, 511, 512
16
+ :email_address_invalid
17
+ when 523
18
+ :email_exceeds_recipients_size_limit
19
+ when 541
20
+ :email_rejected_as_spam
21
+ when 552
22
+ :recipients_inbox_is_full
23
+ else
24
+ :unknown
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+
@@ -0,0 +1,13 @@
1
+ module EmailEvents::Adapters
2
+ module Abstract
3
+ class Initializer
4
+ def self.load_adapter?
5
+ raise "Not implemented"
6
+ end
7
+
8
+ def self.initialize
9
+ raise "Not implemented"
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ module EmailEvents::Adapters
2
+ module Abstract
3
+ class SmtpResponse
4
+ def initialize(raw_smtp_response)
5
+ @raw_smtp_response = raw_smtp_response
6
+ end
7
+
8
+ def provider_message_id
9
+ raise "Not implemented"
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,49 @@
1
+ module EmailEvents::Adapters
2
+ module Sendgrid
3
+ class EventData < Abstract::EventData
4
+ def initialize(sendgrid_data)
5
+ @sendgrid_data = sendgrid_data
6
+
7
+ raise "Unrecognized Sendgrid event type" unless event_type.in?([:delivered, :bounce, :dropped, :deferred, :processed, :click,
8
+ :open, :spamreport, :group_unsubscribe, :group_resubscribe])
9
+ end
10
+
11
+ def event_type
12
+ @sendgrid_data['event'].to_sym
13
+ end
14
+
15
+ def event_timestamp
16
+ return nil if @sendgrid_data.nil?
17
+ Time.at @sendgrid_data['timestamp']
18
+ end
19
+
20
+ def recipients
21
+ return nil if @sendgrid_data.nil?
22
+ [@sendgrid_data['email']]
23
+ end
24
+
25
+ def smtp_status_code
26
+ return nil if @sendgrid_data[:status].blank?
27
+
28
+ @sendgrid_data[:status].gsub(/\./,'').to_i
29
+ end
30
+
31
+ def reason
32
+ @sendgrid_data[:reason]
33
+ end
34
+
35
+ def smtp_message_id
36
+ @sendgrid_data['smtp-id']
37
+ end
38
+
39
+ def provider_message_id
40
+ return nil if @sendgrid_data['sg_message_id'].nil?
41
+ @sendgrid_data['sg_message_id'].gsub(/\.filter.*/,'')
42
+ end
43
+
44
+ def raw_data
45
+ @sendgrid_data
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,20 @@
1
+ require 'gridhook'
2
+
3
+ module EmailEvents::Adapters
4
+ module Sendgrid
5
+ class Initializer < Abstract::Initializer
6
+ def self.load_adapter?
7
+ smtp_settings = Rails.configuration.action_mailer.smtp_settings
8
+ smtp_settings.present? && smtp_settings[:address].include?('sendgrid')
9
+ end
10
+
11
+ def self.initialize
12
+ Gridhook.configure do |config|
13
+ config.event_receive_path = '/email_events/sendgrid'
14
+
15
+ config.event_processor = EmailEvents::Service::HandleEvent
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,12 @@
1
+ module EmailEvents::Adapters
2
+ module Sendgrid
3
+ class SmtpResponse < Abstract::SmtpResponse
4
+ def provider_message_id
5
+ # Status OK
6
+ return nil unless @raw_smtp_response.status == '250'
7
+
8
+ @raw_smtp_response.string.match(/queued as (.+)/)[1]
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,62 @@
1
+ module EmailEvents::Adapters
2
+ module Ses
3
+ class EventData < Abstract::EventData
4
+ def initialize(raw_data)
5
+ @sns_data = JSON.parse raw_data['Message']
6
+
7
+ raise "Unrecognized SES event type" if event_type.nil?
8
+ end
9
+
10
+ def event_type
11
+ case @sns_data['notificationType']
12
+ when 'Bounce'
13
+ :bounce
14
+ when 'Complaint'
15
+ :spamreport
16
+ when 'Delivery'
17
+ :delivered
18
+ else
19
+ nil
20
+ end
21
+ end
22
+
23
+ def event_timestamp
24
+ Time.parse @sns_data['mail']['timestamp']
25
+ end
26
+
27
+ def recipients
28
+ @sns_data['mail']['destination']
29
+ end
30
+
31
+ def smtp_status_code
32
+ # only supported for bounce events
33
+ return nil unless event_type == :bounce
34
+
35
+ status_code_str = @sns_data['bounce']['bouncedRecipients'].last['status']
36
+ return nil if status_code_str.nil?
37
+
38
+ status_code_str.gsub(/\./,'').to_i
39
+ end
40
+
41
+ def reason
42
+ # only supported for bounce events
43
+ return nil unless event_type == :bounce
44
+
45
+ @sns_data['bounce']['bounceSubType']
46
+ end
47
+
48
+ def smtp_message_id
49
+ # not supported by SNS
50
+ nil
51
+ end
52
+
53
+ def provider_message_id
54
+ @sns_data['mail']['messageId']
55
+ end
56
+
57
+ def raw_data
58
+ @sns_data
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,32 @@
1
+ require 'sns_endpoint'
2
+ require 'net/http'
3
+
4
+ module EmailEvents::Adapters
5
+ module Ses
6
+ class Initializer < Abstract::Initializer
7
+ def self.load_adapter?
8
+ smtp_settings = Rails.configuration.action_mailer.smtp_settings
9
+ smtp_settings.present? && smtp_settings[:address].include?('amazonaws.com')
10
+ end
11
+
12
+ def self.initialize
13
+ SnsEndpoint.setup do |config|
14
+ config.topics_list = SnsEndpointTopicListMatcher.new ['email_events']
15
+ config.message_proc = EmailEvents::Service::HandleEvent
16
+ config.subscribe_proc = Proc.new do |data|
17
+ # confirm the subscription
18
+ confirmation_endpoint = URI.parse(data['SubscribeURL'])
19
+ Net::HTTP.get confirmation_endpoint
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+ class SnsEndpointTopicListMatcher < Array
26
+ # match any topic ending in the topic name (as opposed to the long ARN topic ID)
27
+ def include?(arn)
28
+ self.any? {|topic| arn.end_with? topic}
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,12 @@
1
+ module EmailEvents::Adapters
2
+ module Ses
3
+ class SmtpResponse < Abstract::SmtpResponse
4
+ def provider_message_id
5
+ # Status OK
6
+ return nil unless @raw_smtp_response.status == '250'
7
+
8
+ @raw_smtp_response.string.match(/Ok (.+)/)[1]
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,87 @@
1
+ module EmailEvents
2
+ module Mailer
3
+ def self.included(base)
4
+ base.class_eval do
5
+ # whether or not to track the email and handle events for the email
6
+ class_attribute :event_handler
7
+ # additional metadata to store along the email, which we can pull up again when an event occurs (JSON format)
8
+ class_attribute :tracked_data_method
9
+
10
+ # an alternative class (generally, a subclass) instead of SentEmailData - eg. to perform validations on the
11
+ # custom tracked_metadata
12
+ class_attribute :sent_email_data_class
13
+
14
+ after_action :__track_data_in_header
15
+
16
+ protected
17
+ def self.on_event(event_handler)
18
+ self.event_handler = event_handler
19
+ end
20
+
21
+ def self.track_data(tracked_data_method, attrs = {})
22
+ self.tracked_data_method = tracked_data_method
23
+ self.sent_email_data_class = attrs[:class] if attrs.has_key? :class
24
+ end
25
+
26
+ def self.__track_data?
27
+ # track the sent email if there's either an event handler or a custom track data method
28
+ self.event_handler.present? || self.tracked_data_method.present?
29
+ end
30
+
31
+ def __track_data_in_header
32
+ return unless self.class.__track_data?
33
+
34
+ EmailEvents::Service::TrackDataInHeader.call(
35
+ mailer: self,
36
+ sent_email_data_class: sent_email_data_class || EmailEvents::SentEmailData,
37
+ data: __tracked_data
38
+ )
39
+ end
40
+
41
+ def __tracked_data
42
+ return nil if tracked_data_method.nil?
43
+
44
+ if tracked_data_method.is_a? Symbol
45
+ self.send tracked_data_method
46
+ else
47
+ tracked_data_method.call
48
+ end
49
+ end
50
+
51
+ def __handle_event(event_data, email_data)
52
+ return if event_handler.nil?
53
+
54
+ if event_handler.is_a? Symbol
55
+ self.send event_handler, event_data, email_data
56
+ else
57
+ event_handler.call event_data, email_data
58
+ end
59
+ end
60
+
61
+ # pry into the deliver_mail method so as to intercept the SMTP response and
62
+ # parse out the provider message id
63
+ singleton_class.send(:alias_method, :base_deliver_mail, :deliver_mail)
64
+ def self.deliver_mail(message, &block)
65
+ response = base_deliver_mail(message, &block)
66
+
67
+ # no provider id to parse if no adapter has been set, or not tracking for this mailer
68
+ return response if EmailEvents.adapter.nil? || !self.__track_data?
69
+
70
+ # response won't be the smtp result unless the return_response setting is flagged on
71
+ # (but allow it for TestMailer in test environments)
72
+ unless (!Rails.configuration.action_mailer.smtp_settings.nil? && Rails.configuration.action_mailer.smtp_settings[:return_response]) ||
73
+ Rails.env.test?
74
+ raise 'Email events are enabled for this mailer, but you haven\'t turned on return_response = true in your smtp settings'
75
+ end
76
+
77
+ EmailEvents::Service::ParseSmtpResponseForProviderId.call(
78
+ mail_message: message,
79
+ raw_response: response,
80
+ sent_email_data_class: sent_email_data_class || EmailEvents::SentEmailData,
81
+ )
82
+ response
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,21 @@
1
+ require 'active_record'
2
+
3
+ # == Schema Information
4
+ #
5
+ # Table name: sent_email_data
6
+ #
7
+ # id :integer not null, primary key
8
+ # uuid :string
9
+ # mailer_class :string
10
+ # mailer_action :string not null
11
+ # to :string not null
12
+ # data :text
13
+ # created_at :datetime
14
+ # provider_message_id :string
15
+
16
+ module EmailEvents
17
+ class SentEmailData < ActiveRecord::Base
18
+ self.table_name = "sent_email_data"
19
+ serialize :data
20
+ end
21
+ end
@@ -0,0 +1,19 @@
1
+ require 'rails'
2
+
3
+ module EmailEvents
4
+ class Railtie < Rails::Railtie
5
+ initializer 'email_events.initialize', after: :load_config_initializers do
6
+ ActiveSupport.on_load(:action_mailer) do
7
+ include EmailEvents::Mailer
8
+ end
9
+
10
+ # Gridhook gets upset when it draws its routes if we haven't setup the event receive path, so always do so here immediately
11
+ # (even when sendgrid is not used)
12
+ # TODO: especially if there ever gets to be > 2 adapters, each adapters should ideally be broken out into its own gem
13
+ Gridhook.config.event_receive_path = '/email_events/null'
14
+ Gridhook.config.event_processor = Proc.new { raise 'Sendgrid adapter not loaded' }
15
+
16
+ EmailEvents.initialize
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,31 @@
1
+ class EmailEvents::Service::HandleEvent < EmailEvents::Service
2
+ def initialize(raw_response)
3
+ @raw_response = raw_response
4
+ end
5
+
6
+ def call
7
+ sent_emails = EmailEvents::Service::RetrieveDataFromHeader.call(event_data: event_data)
8
+ return if sent_emails.blank?
9
+
10
+ # in occasional cases (when there's no UUID), there will be multiple sent_emails that match the event: we
11
+ # apply the event handling to each one
12
+ sent_emails.each do |email_data|
13
+ begin
14
+ mailer = email_data.mailer_class.constantize.send :new
15
+ mailer.send :__handle_event, event_data, email_data
16
+ end
17
+ end
18
+
19
+ # no data to output back to Rack
20
+ nil
21
+ end
22
+
23
+ private
24
+ def event_data
25
+ @event_data ||= event_data_adapter_class.new(@raw_response)
26
+ end
27
+
28
+ def event_data_adapter_class
29
+ EmailEvents.adapter.const_get('EventData')
30
+ end
31
+ end
@@ -0,0 +1,29 @@
1
+ class EmailEvents::Service::ParseSmtpResponseForProviderId < EmailEvents::Service
2
+ include Virtus.model
3
+ attribute :mail_message
4
+ attribute :raw_response
5
+ attribute :sent_email_data_class
6
+
7
+ def call
8
+ # parse the response using the applicable SmtpResponse adapter
9
+ provider_id = parsed_response.provider_message_id
10
+ return if provider_id.nil?
11
+
12
+ # find our SentEmailData from our own UUID and store the provider id
13
+ sent_email_data = sent_email_data_class.find_by_uuid(message_uuid)
14
+ sent_email_data.update_attribute(:provider_message_id, provider_id)
15
+ end
16
+
17
+ private
18
+ def response_class
19
+ EmailEvents.adapter.const_get('SmtpResponse')
20
+ end
21
+
22
+ def parsed_response
23
+ @parsed_response = response_class.new(raw_response)
24
+ end
25
+
26
+ def message_uuid
27
+ mail_message.message_id.match(/(.+)\@uuid/)[1]
28
+ end
29
+ end
@@ -0,0 +1,52 @@
1
+ require 'virtus'
2
+
3
+ class EmailEvents::Service::RetrieveDataFromHeader < EmailEvents::Service
4
+ include Virtus.model
5
+ attribute :event_data, EmailEvents::Adapters::Abstract::EventData
6
+
7
+ def call
8
+ # try to find the SentEmailData by the following methods, listed in order of preference:
9
+ # 1) the Message-ID (in which we stored our own SentEmailData id in the first place);
10
+ # 2) the provider_message_id which Sendgrid or SES uses to track emails;
11
+ # 3) recipient email addresses to which we recently sent out emails
12
+
13
+ unless uuid_from_smtp_message_id.blank?
14
+ sent_email = EmailEvents::SentEmailData.find_by_uuid!(uuid_from_smtp_message_id)
15
+
16
+ # if this event gives us our Message-ID and Sendgrid's sg_message_id, we take the opportunity to store
17
+ # the latter, as other events (foremost, "open" events) don't necessarily provide us with the Message-ID again
18
+ if sent_email.provider_message_id.blank? && !self.event_data.provider_message_id.blank?
19
+ sent_email.update_attribute(:provider_message_id, self.event_data.provider_message_id)
20
+ end
21
+
22
+ # the UUID is always associated with just one original message
23
+ return [sent_email]
24
+ end
25
+
26
+ unless self.event_data.provider_message_id.blank?
27
+ sent_email = EmailEvents::SentEmailData.where(provider_message_id: self.event_data.provider_message_id).first
28
+ return [sent_email] unless sent_email.nil?
29
+ end
30
+
31
+ # if the destination mail server has clobbered our tracking data in the message_id, still try to determine
32
+ # the sent email data based on the sender's email address and any emails sent to it in the last 15 minutes.
33
+ # We only do this for bounces and drops, as it's safe to assume that a bounce and drop event should apply
34
+ # to ALL outstanding sent emails -- as opposed to eg. click and open events which are tightly associated with one
35
+ # sent email)
36
+ if self.event_data.event_type.in?([:bounce, :dropped])
37
+ return EmailEvents::SentEmailData.where('created_at > ? AND "to" = ?', self.event_data.event_timestamp-15.minutes, self.event_data.recipients.first)
38
+ end
39
+
40
+ []
41
+ end
42
+
43
+ private
44
+ def uuid_from_smtp_message_id
45
+ @uuid ||= begin
46
+ unless self.event_data.smtp_message_id.blank?
47
+ matching_data = self.event_data.smtp_message_id.match(/([^\<]+)\@uuid/)
48
+ matching_data.nil? ? nil : matching_data[1]
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,10 @@
1
+ # use a Service Objects architecture
2
+ class EmailEvents::Service
3
+ def self.call(*args, &block)
4
+ if block_given?
5
+ new(*args).call(&block)
6
+ else
7
+ new(*args).call
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,49 @@
1
+ require 'virtus'
2
+ require 'uuidtools'
3
+
4
+ class EmailEvents::Service::TrackDataInHeader < EmailEvents::Service
5
+ include Virtus.model
6
+ attribute :mailer
7
+ attribute :sent_email_data_class
8
+ attribute :data
9
+
10
+ def call
11
+ # a UUID is much more secure identifier to send along in an email header (as opposed to the db primary key,
12
+ # for which someone could easily guess a valid integer in an attack)
13
+ uuid = generate_uuid
14
+
15
+ data_obj = sent_email_data_class.create!(
16
+ mailer_class: mailer_class,
17
+ mailer_action: mailer_action,
18
+ to: recipient_email,
19
+ uuid: uuid,
20
+ data: data
21
+ )
22
+
23
+ # add the uuid to of the SentEmailData to the email Message-ID header for tracking it
24
+ add_data_uuid_to_email_headers(uuid)
25
+
26
+ data_obj
27
+ end
28
+
29
+ private
30
+ def mailer_class
31
+ mailer.class.to_s
32
+ end
33
+
34
+ def mailer_action
35
+ mailer.action_name
36
+ end
37
+
38
+ def recipient_email
39
+ mailer.headers.to.first
40
+ end
41
+
42
+ def generate_uuid
43
+ UUIDTools::UUID.random_create.to_s.gsub(/\-/,'')
44
+ end
45
+
46
+ def add_data_uuid_to_email_headers(content)
47
+ mailer.headers["Message-ID"] = "<#{content}@uuid.email_events>"
48
+ end
49
+ end
@@ -0,0 +1,3 @@
1
+ module EmailEvents
2
+ VERSION = "1.0"
3
+ end
@@ -0,0 +1,18 @@
1
+ require "rails/generators/active_record"
2
+
3
+ module EmailEvents
4
+ class InstallGenerator < ::Rails::Generators::Base
5
+ include ::Rails::Generators::Migration
6
+ desc "Generates migration for email_events"
7
+
8
+ source_paths << File.join(File.dirname(__FILE__), "templates")
9
+
10
+ def create_migration_file
11
+ migration_template "create_sent_email_data.rb", "db/migrate/create_sent_email_data.rb"
12
+ end
13
+
14
+ def self.next_migration_number(dirname)
15
+ ::ActiveRecord::Generators::Base.next_migration_number(dirname)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,13 @@
1
+ class CreateSentEmailData < ActiveRecord::Migration
2
+ def change
3
+ create_table :sent_email_data do |t|
4
+ t.string :uuid, unique: true
5
+ t.string :provider_message_id, :string
6
+ t.string :mailer_class, null: false
7
+ t.string :mailer_action, null: false
8
+ t.string :to, null: false
9
+ t.text :data
10
+ t.datetime :created_at
11
+ end
12
+ end
13
+ end
metadata ADDED
@@ -0,0 +1,199 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: email_events
3
+ version: !ruby/object:Gem::Version
4
+ version: '1.0'
5
+ platform: ruby
6
+ authors:
7
+ - Kent Mewhort @ Coupa
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2016-04-12 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: gridhook
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: sns_endpoint
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: uuidtools
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: virtus
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: bundler
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rake
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '10.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '10.0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rspec
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '3.0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '3.0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: sqlite3
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '1.0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '1.0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: combustion
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: 0.5.0
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: 0.5.0
139
+ description: Supports handling incoming events for SES/SNS or Sendgrid
140
+ email:
141
+ - kent.mewhort@coupa.com
142
+ executables: []
143
+ extensions: []
144
+ extra_rdoc_files: []
145
+ files:
146
+ - ".gitignore"
147
+ - ".rspec"
148
+ - Gemfile
149
+ - LICENSE.txt
150
+ - README.md
151
+ - Rakefile
152
+ - circle.yml
153
+ - email_events.gemspec
154
+ - lib/email_events.rb
155
+ - lib/email_events/adapters/abstract/event_data.rb
156
+ - lib/email_events/adapters/abstract/initializer.rb
157
+ - lib/email_events/adapters/abstract/smtp_response.rb
158
+ - lib/email_events/adapters/sendgrid/event_data.rb
159
+ - lib/email_events/adapters/sendgrid/initializer.rb
160
+ - lib/email_events/adapters/sendgrid/smtp_response.rb
161
+ - lib/email_events/adapters/ses/event_data.rb
162
+ - lib/email_events/adapters/ses/initializer.rb
163
+ - lib/email_events/adapters/ses/smtp_response.rb
164
+ - lib/email_events/mailer.rb
165
+ - lib/email_events/models/sent_email_data.rb
166
+ - lib/email_events/railtie.rb
167
+ - lib/email_events/services/handle_event.rb
168
+ - lib/email_events/services/parse_smtp_response_for_provider_id.rb
169
+ - lib/email_events/services/retrieve_data_from_header.rb
170
+ - lib/email_events/services/service.rb
171
+ - lib/email_events/services/track_data_in_header.rb
172
+ - lib/email_events/version.rb
173
+ - lib/generators/email_events/install_generator.rb
174
+ - lib/generators/email_events/templates/create_sent_email_data.rb
175
+ homepage: https://github.com/85x14/email_events
176
+ licenses:
177
+ - MIT
178
+ metadata: {}
179
+ post_install_message:
180
+ rdoc_options: []
181
+ require_paths:
182
+ - lib
183
+ required_ruby_version: !ruby/object:Gem::Requirement
184
+ requirements:
185
+ - - "~>"
186
+ - !ruby/object:Gem::Version
187
+ version: '2.0'
188
+ required_rubygems_version: !ruby/object:Gem::Requirement
189
+ requirements:
190
+ - - ">="
191
+ - !ruby/object:Gem::Version
192
+ version: '0'
193
+ requirements: []
194
+ rubyforge_project:
195
+ rubygems_version: 2.4.5.1
196
+ signing_key:
197
+ specification_version: 4
198
+ summary: Email event handling for delivery, bounces, drops, replies, etc.
199
+ test_files: []