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.
- checksums.yaml +7 -0
- data/.gitignore +10 -0
- data/.rspec +2 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +136 -0
- data/Rakefile +6 -0
- data/circle.yml +4 -0
- data/email_events.gemspec +32 -0
- data/lib/email_events.rb +46 -0
- data/lib/email_events/adapters/abstract/event_data.rb +30 -0
- data/lib/email_events/adapters/abstract/initializer.rb +13 -0
- data/lib/email_events/adapters/abstract/smtp_response.rb +13 -0
- data/lib/email_events/adapters/sendgrid/event_data.rb +49 -0
- data/lib/email_events/adapters/sendgrid/initializer.rb +20 -0
- data/lib/email_events/adapters/sendgrid/smtp_response.rb +12 -0
- data/lib/email_events/adapters/ses/event_data.rb +62 -0
- data/lib/email_events/adapters/ses/initializer.rb +32 -0
- data/lib/email_events/adapters/ses/smtp_response.rb +12 -0
- data/lib/email_events/mailer.rb +87 -0
- data/lib/email_events/models/sent_email_data.rb +21 -0
- data/lib/email_events/railtie.rb +19 -0
- data/lib/email_events/services/handle_event.rb +31 -0
- data/lib/email_events/services/parse_smtp_response_for_provider_id.rb +29 -0
- data/lib/email_events/services/retrieve_data_from_header.rb +52 -0
- data/lib/email_events/services/service.rb +10 -0
- data/lib/email_events/services/track_data_in_header.rb +49 -0
- data/lib/email_events/version.rb +3 -0
- data/lib/generators/email_events/install_generator.rb +18 -0
- data/lib/generators/email_events/templates/create_sent_email_data.rb +13 -0
- metadata +199 -0
checksums.yaml
ADDED
@@ -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
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -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.
|
data/README.md
ADDED
@@ -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
|
+
|
data/Rakefile
ADDED
data/circle.yml
ADDED
@@ -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
|
data/lib/email_events.rb
ADDED
@@ -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,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,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,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: []
|