mail_delivery_task 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (30) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +22 -0
  3. data/.rspec +2 -0
  4. data/.rubocop.yml +29 -0
  5. data/CHANGES.md +3 -0
  6. data/Gemfile +22 -0
  7. data/LICENSE +13 -0
  8. data/README.md +283 -0
  9. data/Rakefile +14 -0
  10. data/bin/console +14 -0
  11. data/bin/setup +8 -0
  12. data/lib/generators/mail_delivery_task/install_generator.rb +37 -0
  13. data/lib/generators/mail_delivery_task/templates/create_mail_delivery_task_attempts.rb +39 -0
  14. data/lib/generators/mail_delivery_task/templates/mail_delivery_batch_job.rb.erb +3 -0
  15. data/lib/generators/mail_delivery_task/templates/mail_delivery_batch_job_spec.rb.erb +5 -0
  16. data/lib/generators/mail_delivery_task/templates/mail_delivery_job.rb.erb +3 -0
  17. data/lib/generators/mail_delivery_task/templates/mail_delivery_job_spec.rb.erb +5 -0
  18. data/lib/generators/mail_delivery_task/templates/mail_delivery_task_attempt.rb.erb +24 -0
  19. data/lib/generators/mail_delivery_task/templates/mail_delivery_task_attempt_spec.rb.erb +5 -0
  20. data/lib/generators/mail_delivery_task/templates/mail_delivery_task_attempts.rb.erb +27 -0
  21. data/lib/mail_delivery_task.rb +12 -0
  22. data/lib/mail_delivery_task/base_attempt.rb +125 -0
  23. data/lib/mail_delivery_task/jobs/base_delivery_batch_job.rb +18 -0
  24. data/lib/mail_delivery_task/jobs/base_delivery_job.rb +23 -0
  25. data/lib/mail_delivery_task/jobs/base_job.rb +16 -0
  26. data/lib/mail_delivery_task/testing.rb +1 -0
  27. data/lib/mail_delivery_task/testing/mailer_helper.rb +114 -0
  28. data/lib/mail_delivery_task/version.rb +3 -0
  29. data/mail_delivery_task.gemspec +28 -0
  30. metadata +161 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 0d058a7ce383210d1d6769a0e59a3e43e606086a
4
+ data.tar.gz: 0ba26ba17147024483910e2836b5645a67a61ea8
5
+ SHA512:
6
+ metadata.gz: b071d4dbf1ee7de728d02513cfb74530b581cc7a00a6884c07a1ac6e7110dbb9d2a9c673d8152e8246705ee7d70142c010ece9a08cf03db42838a14db491294c
7
+ data.tar.gz: c09934358f9a0541ec55ad986e6e53bbf362a8379047e31fac7d8d6b3991844d5ce06a6be2edcb64d81090f617020baa67a07719757820601eefb68cf395f8b6
data/.gitignore ADDED
@@ -0,0 +1,22 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+
11
+ # rspec failure tracking
12
+ .rspec_status
13
+
14
+ # Ignore all logfiles and tempfiles
15
+ *.log
16
+ *.swp
17
+
18
+ # Ignore all sqlite3 files
19
+ *.sqlite3
20
+
21
+ # rspec failure tracking
22
+ .rspec_status
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format documentation
2
+ --color
data/.rubocop.yml ADDED
@@ -0,0 +1,29 @@
1
+ Lint/AmbiguousBlockAssociation:
2
+ Enabled: false
3
+
4
+ Metrics/AbcSize:
5
+ Enabled: false
6
+
7
+ Metrics/BlockLength:
8
+ Enabled: false
9
+
10
+ Metrics/CyclomaticComplexity:
11
+ Enabled: false
12
+
13
+ Metrics/MethodLength:
14
+ Enabled: false
15
+
16
+ Metrics/LineLength:
17
+ Enabled: false
18
+
19
+ Metrics/PerceivedComplexity:
20
+ Enabled: false
21
+
22
+ Security/YAMLLoad:
23
+ Enabled: false
24
+
25
+ Style/Documentation:
26
+ Enabled: false
27
+
28
+ Style/FrozenStringLiteralComment:
29
+ Enabled: false
data/CHANGES.md ADDED
@@ -0,0 +1,3 @@
1
+ ### 0.0.1 / 2018-1-22
2
+
3
+ * Initial release
data/Gemfile ADDED
@@ -0,0 +1,22 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in mail_delivery_task.gemspec
4
+ gemspec
5
+
6
+ # Core
7
+ gem 'bundler', '~> 1.14'
8
+ gem 'rake', '~> 11.0'
9
+
10
+ # Development Experience
11
+ gem 'pry'
12
+ gem 'pry-byebug'
13
+ gem 'rubocop'
14
+
15
+ # Testing (see spec/dummy)
16
+ gem 'factory_bot'
17
+ gem 'rails', '~> 5.1.0'
18
+ gem 'rspec', '~> 3.5'
19
+ gem 'rspec-rails', '~> 3.0'
20
+ gem 'sqlite3'
21
+
22
+ gem 'timecop'
data/LICENSE ADDED
@@ -0,0 +1,13 @@
1
+ Copyright 2017 Square, Inc.
2
+
3
+ Licensed under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License.
5
+ You may obtain a copy of the License at
6
+
7
+ http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ Unless required by applicable law or agreed to in writing, software
10
+ distributed under the License is distributed on an "AS IS" BASIS,
11
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ See the License for the specific language governing permissions and
13
+ limitations under the License.
data/README.md ADDED
@@ -0,0 +1,283 @@
1
+ # MailDeliveryTask
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/mail_delivery_task.svg)](http://badge.fury.io/rb/mail_delivery_task)
4
+ [![License](https://img.shields.io/badge/license-Apache-green.svg?style=flat)](https://github.com/square/mail_delivery_task/blob/master/LICENSE)
5
+
6
+ This gem provides generators and mixins to queue up mail delivery in database transactions to be
7
+ delivered later. Doing so prevents mail from being sent twice if the transaction is rolled back:
8
+
9
+ ```ruby
10
+ transaction do
11
+ model = MyModel.create!(foo: 'hello')
12
+ MyMailer.mailer_action(model: model).deliver
13
+ raise
14
+ end
15
+ ```
16
+
17
+ Despite database transaction rolling back the creation of the `MyModel` record, the mail is still
18
+ delivered. This problem becomes more difficult in nested transactions. To avoid this, we create a
19
+ a `MailDeliveryTask::Attempt` record inside the database. These records are then delivered at a
20
+ later time using a job:
21
+
22
+ ```ruby
23
+ transaction do
24
+ model = MyModel.create!(foo: 'hello')
25
+
26
+ # To be sent by a job later
27
+ MailDeliveryTask::Attempt.create(
28
+ mailer_class: MyMailer,
29
+ mailer_action_name: :mailer_action,
30
+ mailer_args: { my_model: model },
31
+ idempotence_token: "my_model##{id}"
32
+ )
33
+
34
+ raise
35
+ end
36
+ ```
37
+
38
+ The above pattern ensures mail delivery tasks will not be created nor sent when the transaction
39
+ fails.
40
+
41
+ The gem provides the following:
42
+
43
+ * Models
44
+ * Generators for the `MailDeliveryTask::Attempt` migration, model, factory, and specs.
45
+ * Tracking completion using `completed_at`.
46
+ * Fields for `mailer_class_name`, `mailer_action_name`, and `mailer_args`.
47
+ * `MailDeliveryTask::BaseAttempt` mixin to provide model methods.
48
+ * Persistence token support.
49
+ * A `num_attempts` field gives you flexibility to handle retries and other failure scenarios.
50
+ * `status` and `completed_at` are fields that track state.
51
+ * Jobs
52
+ * Generators for `MailDeliveryTaskJob` and `MailDeliveryBatchJob` jobs and specs
53
+ * `MailDeliveryTask::BaseDeliveryJob` and `MailDeliveryTask::BaseDeliveryBatchJob` mixins.
54
+
55
+ ## Design Motivations
56
+
57
+ We're relying heavily on generators and mixins. Including the `MailDeliveryTask::BaseAttempt` module
58
+ allows us to generate a model that can inherit from both `ActiveRecord::Base` (Rails 4) and
59
+ `ApplicationRecord` (Rails 5). The `BaseAttempt` module's methods can easily be overridden, giving
60
+ callers flexibility to handle errors, extend functionality, and inherit (STI). Lastly, the generated
61
+ migrations provide fields used by the `BaseAttempt` module, but the developer is free to add their
62
+ own fields and extend the module's methods while calling `super`.
63
+
64
+ This gem is also designed to be compatible with any `ApplicationMailer` implementation through the
65
+ use of the `mailer_class_name`, `mailer_action_name`, and `mailer_args` (keyword args) fields.
66
+
67
+ ## Getting Started
68
+
69
+ 1. Add the gem to your application's Gemfile and execute `bundle install` to install it:
70
+
71
+ ```ruby
72
+ gem 'mail_delivery_task'
73
+ ```
74
+
75
+ 2. Generate migrations, base models, jobs, and specs. Feel free to add any additional columns you
76
+ may need to the generated migration file:
77
+
78
+ `$ rails generate mail_delivery_task:install`
79
+
80
+ 3. You will need a working `ActionMailer` class to send mail through SMTP / Butter. **Note: the
81
+ mailer's arguments MUST be keyword arguments to be compatible with the `mailer_args` field in the
82
+ `MailDeliveryTask` model.**
83
+
84
+ ```ruby
85
+ class DummyMailer < ApplicationMailer
86
+ # Keyword args required!!!
87
+ def action_name(to_address:)
88
+ mail(
89
+ to: to_address,
90
+ subject: 'How to setup mail_delivery_task',
91
+ body: "It's really easy.",
92
+ content_type: 'text/plain',
93
+ )
94
+ end
95
+ end
96
+ ```
97
+
98
+ 4. Rename the model and migrations as you see fit. Make sure your model contains
99
+ `include MailDeliveryTask::BaseAttempt`.
100
+
101
+ ```ruby
102
+ class MailDeliveryTask < ActiveRecord::Base
103
+ include MailDeliveryTask::BaseAttempt
104
+ end
105
+ ```
106
+
107
+ 5. Implement the `handle_deliver_mail_error` and `handle_persist_mail_error` in your `MailDeliveryTask`
108
+ model. These two methods are used by `MailDeliveryTask::BaseAttempt` when exceptions are thrown
109
+ delivering and persisting the mail. See cookbook below for details on persistence and error
110
+ handling.
111
+
112
+ 6. Do not send mail directly using the `ActionMailer` class above. Instead, create
113
+ `MailDeliveryTask`s to be sent later by a job (generated) that includes a
114
+ `MailDeliveryTask::BaseDeliveryJob`:
115
+
116
+ ```ruby
117
+ class MailDeliveryJob < ActiveJob::Base
118
+ include MailDeliveryTask::BaseDeliveryJob
119
+ end
120
+ ```
121
+
122
+ ```ruby
123
+ transaction do
124
+ # Using the DummyMailer class above...
125
+ MailDeliveryTask::Attempt.create(
126
+ mailer_class: DummyMailer,
127
+ mailer_action_name: :action_name,
128
+ mailer_args: { to_address: 'jchang@squareup.com' },
129
+ idempotence_token: 'token',
130
+ )
131
+ end
132
+ ```
133
+
134
+ 7. **Make sure to schedule the mail delivery job to run frequently using [`Clockwork`](https://github.com/adamwiggins/clockwork).**
135
+
136
+ 8. If you would like to use something other than Trunk to persist mail, see the cookbook below on
137
+ using a different method of persistence.
138
+
139
+ ## Improper Uses of the Gem
140
+
141
+ Below are patterns that defeat the purpose of using this gem:
142
+
143
+ ```ruby
144
+ # DO NOT DO THIS
145
+ transaction do
146
+ task = create_mail_delivery_task
147
+ task.deliver!
148
+ raise
149
+ end
150
+ ```
151
+
152
+ The above example allows mail to be delivered even if the transaction fails.
153
+
154
+ ```ruby
155
+ # DO NOT DO THIS
156
+ MailDeliverytask::Attempt.create(
157
+ mailer_class: 'DummyMailer',
158
+ mailer_action_name: 'action_name',
159
+ mailer_args: {},
160
+ ).deliver!
161
+ ```
162
+
163
+ These two examples above do not make use of a job to deliver mail.
164
+
165
+ ## Cookbook
166
+
167
+ ### Delayed Execution
168
+
169
+ Setting the `scheduled_at` field allows delayed execution to be possible. A task that has an
170
+ `scheduled_at` before `Time.current` will be executed by `MailDeliveryTask::BaseDeliveryBatchJob`.
171
+
172
+ ### Overriding MailDeliveryTask::Base Error Handlers
173
+
174
+ By default, when persistence or deliverance fails, it just raises the error
175
+ encountered. However, if you want to raise a custom error or wrap the error,
176
+ you can override both of these by overriding the `handle_deliver_mail_error`
177
+ and `handle_persist_mail_error` methods.
178
+
179
+ ```ruby
180
+ class MailDeliveryTask::Attempt < ApplicationRecord
181
+ include MailDeliveryTask::BaseAttempt
182
+
183
+ class DeliverMailError < StandardError; end
184
+ class PersistMailError < StandardError; end
185
+
186
+ def handle_deliver_mail_error(error)
187
+ raise DeliverMailError, 'my custom error message'
188
+ end
189
+
190
+ def handle_persist_mail_error(error)
191
+ raise PersistMailError, 'my custom error message'
192
+ end
193
+ end
194
+ ```
195
+
196
+ Lastly, the `num_attempts` field in `MailDeliveryTask::Attempt` allows you to track the number of
197
+ delivery attempts the mail has. Use this to implement retries and permanent failure thresholds for
198
+ your mail delivery tasks.
199
+
200
+ ### Proper Usage of `expire!` / `fail!`
201
+
202
+ `expire!` should be used for mail that is no longer applicable, such as a mail for plan past due
203
+ when the plan is no longer past due.
204
+
205
+ `fail!` should be used to mark delivery as failed when the mail should have been, but was not,
206
+ delivered successfully.
207
+
208
+ ### Persistence
209
+
210
+ If you wish to persist mail, override the `persist_mail` method:
211
+
212
+ ```ruby
213
+ class MailDeliveryTask::Attempt < ApplicationRecord
214
+ include MailDeliveryTask::BaseAttempt
215
+
216
+ private
217
+
218
+ def persist_mail(mail)
219
+ store_in_s3(mail.to_s)
220
+ end
221
+ end
222
+ ```
223
+
224
+ Don't forget to set the `persistence_token`.
225
+
226
+ ### Custom Matchers for RSpec
227
+
228
+ Add the following lines to `rails_helper.rb`:
229
+
230
+ ```ruby
231
+ require 'mail_delivery_task/testing'
232
+
233
+ RSpec.configure do |config|
234
+ config.include MailDeliveryTask::Testing::MailerHelper, type: :mailer
235
+ end
236
+ ```
237
+
238
+ Now custom matchers like `be_deliverable` are enabled:
239
+
240
+ ```ruby
241
+ expect(mail).to be_deliverable
242
+ ```
243
+
244
+ For a full list of matchers, see [here](https://github.com/square/mail_delivery_task/tree/master/lib/mail_delivery_task/testing/mailer_helper.rb).
245
+
246
+ ### Overriding the Mail Delivery Mechanism
247
+
248
+ Sometimes the mail delivery method found in the [BaseAttempt](https://github.com/square/mail_delivery_task/tree/master/lib/mail_delivery_task/base_attempt.rb) is insufficient. In this case you can override the method in your `MailDeliveryTask::Attempt`:
249
+
250
+ ```ruby
251
+ class MailDeliveryTask::Attempt < ApplicationRecord
252
+ include MailDeliveryTask::BaseAttempt
253
+
254
+ private
255
+
256
+ def deliver_mail(mail)
257
+ mail.deliver_some_other_way
258
+ end
259
+ end
260
+ ```
261
+
262
+ ## Development
263
+
264
+ * Install dependencies with `bin/setup`.
265
+ * Run tests/lints with `rake`
266
+ * For an interactive prompt that will allow you to experiment, run `bin/console`.
267
+
268
+ ## License
269
+
270
+ ```
271
+ Copyright 2017 Square, Inc.
272
+
273
+ Licensed under the Apache License, Version 2.0 (the "License");
274
+ you may not use this file except in compliance with the License.
275
+ You may obtain a copy of the License at
276
+
277
+ http://www.apache.org/licenses/LICENSE-2.0
278
+
279
+ Unless required by applicable law or agreed to in writing, software
280
+ distributed under the License is distributed on an "AS IS" BASIS,
281
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
282
+ See the License for the specific language governing permissions and
283
+ limitations under the License.
data/Rakefile ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+ require 'bundler/setup'
3
+ require 'sq/gem_tasks'
4
+
5
+ require 'rspec/core/rake_task'
6
+ RSpec::Core::RakeTask.new
7
+
8
+ require 'rubocop/rake_task'
9
+ RuboCop::RakeTask.new
10
+
11
+ require 'sq/lint/rake_task'
12
+ Sq::Lint::RakeTask.new
13
+
14
+ task default: %w(spec rubocop sq:lint:gem)
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "mail_delivery_task"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,37 @@
1
+ require 'rails/generators'
2
+ require 'rails/generators/active_record'
3
+
4
+ module MailDeliveryTask
5
+ class InstallGenerator < ::Rails::Generators::Base
6
+ include ::Rails::Generators::Migration
7
+
8
+ source_root File.expand_path('../templates/', __FILE__)
9
+
10
+ desc 'Generates (but does not run) migrations to add the' \
11
+ ' mail_delivery_task_attempts table and creates the base model'
12
+
13
+ def self.next_migration_number(dirname)
14
+ ::ActiveRecord::Generators::Base.next_migration_number(dirname)
15
+ end
16
+
17
+ def create_migration_file
18
+ migration_template 'create_mail_delivery_task_attempts.rb', 'db/migrate/create_mail_delivery_task_attempts.rb'
19
+ end
20
+
21
+ def create_mail_delivery_task_files
22
+ template 'mail_delivery_task_attempt.rb.erb', 'app/models/mail_delivery_task/attempt.rb'
23
+ template 'mail_delivery_job.rb.erb', 'app/jobs/mail_delivery_job.rb'
24
+ template 'mail_delivery_batch_job.rb.erb', 'app/jobs/mail_delivery_batch_job.rb'
25
+
26
+ if defined?(RSpec)
27
+ template 'mail_delivery_task_attempt_spec.rb.erb', 'spec/models/mail_delivery_task/attempt_spec.rb'
28
+ template 'mail_delivery_job_spec.rb.erb', 'spec/jobs/mail_delivery_job_spec.rb'
29
+ template 'mail_delivery_batch_job_spec.rb.erb', 'spec/jobs/mail_delivery_batch_job_spec.rb'
30
+ end
31
+
32
+ if defined?(FactoryBot) || defined?(FactoryGirl)
33
+ template 'mail_delivery_task_attempts.rb.erb', 'spec/factories/mail_delivery_task/attempts.rb'
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,39 @@
1
+ class CreateMailDeliveryTaskAttempts < ActiveRecord::Migration[5.1]
2
+ def change
3
+ create_table :mail_delivery_task_attempts do |t|
4
+ t.integer :lock_version, null: false, default: 0
5
+
6
+ t.string :status
7
+ t.string :idempotence_token, null: false
8
+
9
+ t.string :mailer_class_name, null: false
10
+ t.string :mailer_action_name, null: false
11
+ t.text :mailer_args
12
+
13
+ t.boolean :should_persist, default: false
14
+ t.string :mailer_message_id
15
+ t.string :persistence_token
16
+
17
+ t.integer :num_attempts, null: false, default: 0
18
+
19
+ t.datetime :scheduled_at
20
+ t.datetime :completed_at
21
+
22
+ t.timestamps null: false
23
+
24
+ t.index :status
25
+ t.index [:idempotence_token, :mailer_class_name, :mailer_action_name], unique: true, name: 'index_mdt_attempts_on_idempotence_token_and_mailer'
26
+
27
+ t.index [:mailer_class_name, :mailer_action_name], name: 'index_mdt_attempts_on_mailer_and_template'
28
+
29
+ t.index :should_persist
30
+ t.index :mailer_message_id
31
+
32
+ t.index :scheduled_at
33
+ t.index :completed_at
34
+
35
+ t.index :created_at
36
+ t.index :updated_at
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,3 @@
1
+ class MailDeliveryBatchJob < <% if Rails::VERSION::STRING >= '5' %>ApplicationJob<% else %>ActiveJob::Base<% end %>
2
+ include MailDeliveryTask::BaseDeliveryBatchJob
3
+ end
@@ -0,0 +1,5 @@
1
+ require 'rails_helper'
2
+
3
+ RSpec.describe MailDeliveryBatchJob, type: :job do
4
+ pending "add some examples to (or delete) #{__FILE__}"
5
+ end
@@ -0,0 +1,3 @@
1
+ class MailDeliveryJob < <% if Rails::VERSION::STRING >= '5' %>ApplicationJob<% else %>ActiveJob::Base<% end %>
2
+ include MailDeliveryTask::BaseDeliveryJob
3
+ end
@@ -0,0 +1,5 @@
1
+ require 'rails_helper'
2
+
3
+ RSpec.describe MailDeliveryJob, type: :job do
4
+ pending "add some examples to (or delete) #{__FILE__}"
5
+ end
@@ -0,0 +1,24 @@
1
+ class MailDeliveryTask::Attempt < <% if Rails::VERSION::STRING >= '5' %>ApplicationRecord<% else %>ActiveRecord::Base<% end %>
2
+ include MailDeliveryTask::BaseAttempt
3
+
4
+ # @override
5
+ #
6
+ # Override this method to deliver mail.
7
+ def persist_mail(mail)
8
+ raise NotImplementedError
9
+ end
10
+
11
+ # @override
12
+ #
13
+ # This method is used by MailDeliveryTask::BaseAttempt when #perform! fails.
14
+ def handle_deliver_mail_error(error)
15
+ raise error
16
+ end
17
+
18
+ # @override
19
+ #
20
+ # This method is used by MailDeliveryTask::BaseAttempt when #perform! fails.
21
+ def handle_persist_mail_error(error)
22
+ raise error
23
+ end
24
+ end
@@ -0,0 +1,5 @@
1
+ require 'rails_helper'
2
+
3
+ RSpec.describe MailDeliveryTask::Attempt, type: :model do
4
+ pending "add some examples to (or delete) #{__FILE__}"
5
+ end
@@ -0,0 +1,27 @@
1
+ <% if defined?(FactoryBot) %>FactoryBot<% else %>FactoryGirl<%end%>.define do
2
+ factory :mail_delivery_task_attempt, class: 'MailDeliveryTask::Attempt' do
3
+ status { 'pending' }
4
+
5
+ idempotence_token { SecureRandom.hex }
6
+
7
+ mailer_class_name { 'DummyMailer' }
8
+ mailer_action_name { 'action_name' }
9
+ mailer_args { {} }
10
+
11
+ trait :delivered do
12
+ status { :delivered }
13
+ mailer_message_id { SecureRandom.hex(6) }
14
+ completed_at { Time.current }
15
+ end
16
+
17
+ trait :expired do
18
+ status { 'expired' }
19
+ completed_at { Time.current }
20
+ end
21
+
22
+ trait :failed do
23
+ status { 'failed' }
24
+ completed_at { Time.current }
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,12 @@
1
+ require 'active_job'
2
+ require 'active_record'
3
+ require 'active_support'
4
+ require 'active_support/core_ext'
5
+ require 'enumerize'
6
+ require 'with_advisory_lock'
7
+
8
+ require 'mail_delivery_task/version'
9
+
10
+ require 'mail_delivery_task/base_attempt'
11
+
12
+ Dir["#{File.dirname(__FILE__)}/mail_delivery_task/jobs/**/*.rb"].each { |file| require file }
@@ -0,0 +1,125 @@
1
+ module MailDeliveryTask
2
+ class InvalidStateError < StandardError; end
3
+
4
+ module BaseAttempt
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ extend Enumerize
9
+
10
+ self.table_name = 'mail_delivery_task_attempts'
11
+
12
+ validates :mailer_class_name, presence: true
13
+ validates :mailer_action_name, presence: true
14
+ validates :mailer_message_id, uniqueness: true, allow_nil: true
15
+
16
+ serialize :mailer_args, JSON
17
+
18
+ scope :persisted, -> { where.not(persistence_token: nil) }
19
+ scope :pending, -> { where(status: :pending) }
20
+ scope :delivered, -> { where(status: :delivered) }
21
+ scope :expired, -> { where(status: :expired) }
22
+ scope :failed, -> { where(status: :failed) }
23
+
24
+ enumerize :status,
25
+ in: [:pending, :delivered, :expired, :failed],
26
+ predicates: true
27
+
28
+ before_create do
29
+ self.status ||= 'pending'
30
+ end
31
+
32
+ # For backward compatibility
33
+ alias_method :handle_deliver_error, :handle_deliver_mail_error
34
+ end
35
+
36
+ def deliver!
37
+ return unless may_schedule?
38
+
39
+ begin
40
+ reload
41
+ raise MailDeliveryTask::InvalidStateError unless pending?
42
+ increment!(:num_attempts)
43
+ rescue ActiveRecord::StaleObjectError
44
+ retry
45
+ end
46
+
47
+ mailer_class = mailer_class_name.constantize
48
+
49
+ mail = if mailer_args.present?
50
+ mailer_class.send(mailer_action_name, **mailer_args.symbolize_keys)
51
+ else
52
+ mailer_class.send(mailer_action_name)
53
+ end
54
+
55
+ with_lock do
56
+ raise MailDeliveryTask::InvalidStateError unless pending?
57
+
58
+ begin
59
+ self.persistence_token = persist_mail(mail) if should_persist?
60
+ rescue StandardError => e
61
+ # If Trunk is down, simply catch the exception so that we won't retry
62
+ # and thus send the mail multiple times.
63
+ handle_persist_mail_error(e)
64
+ end
65
+
66
+ begin
67
+ # Perform the actual delivery through SMTP / Butter
68
+ deliver_mail(mail)
69
+
70
+ # Note that this fails silently.
71
+ self.mailer_message_id = mail.message_id
72
+ update_status!('delivered')
73
+ rescue StandardError => e
74
+ handle_deliver_error(e)
75
+ end
76
+ end
77
+ end
78
+
79
+ def expire!
80
+ with_lock do
81
+ raise MailDeliveryTask::InvalidStateError unless pending?
82
+ update_status!('expired')
83
+ end
84
+ end
85
+
86
+ def fail!
87
+ with_lock do
88
+ raise MailDeliveryTask::InvalidStateError unless pending?
89
+ update_status!('failed')
90
+ end
91
+ end
92
+
93
+ def may_schedule?
94
+ scheduled_at.blank? || scheduled_at < Time.current
95
+ end
96
+
97
+ private
98
+
99
+ def deliver_mail(mail)
100
+ mail.deliver
101
+ end
102
+
103
+ # Override this if needed.
104
+ def persist_mail(mail)
105
+ raise NotImplementedError
106
+ end
107
+
108
+ # Override this if needed.
109
+ def handle_deliver_mail_error(e)
110
+ raise(e)
111
+ end
112
+
113
+ # Override this if needed.
114
+ def handle_persist_mail_error(e)
115
+ raise(e)
116
+ end
117
+
118
+ def update_status!(status)
119
+ update!(
120
+ status: status,
121
+ completed_at: Time.current,
122
+ )
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,18 @@
1
+ module MailDeliveryTask
2
+ module BaseDeliveryBatchJob
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ include MailDeliveryTask::BaseJob
7
+ queue_as :default
8
+ end
9
+
10
+ def perform
11
+ unless_already_executing do
12
+ ::MailDeliveryTask::Attempt.pending.where('scheduled_at IS ? || scheduled_at < ?', nil, Time.current).find_each do |task|
13
+ ::MailDeliveryJob.perform_later(task)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,23 @@
1
+ module MailDeliveryTask
2
+ module BaseDeliveryJob
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ include MailDeliveryTask::BaseJob
7
+ queue_as :default
8
+
9
+ # @override
10
+ private def lock_key
11
+ [self.class.name, @task.id]
12
+ end
13
+ end
14
+
15
+ def perform(task)
16
+ @task = task
17
+
18
+ unless_already_executing do
19
+ @task.deliver! if @task.reload.pending?
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,16 @@
1
+ module MailDeliveryTask
2
+ module BaseJob
3
+ extend ActiveSupport::Concern
4
+
5
+ private
6
+
7
+ def lock_key
8
+ self.class.name
9
+ end
10
+
11
+ def unless_already_executing(&block)
12
+ result = ActiveRecord::Base.with_advisory_lock_result(lock_key, timeout_seconds: 0, &block)
13
+ warn("AdvisoryLock owned by other instance of job: #{lock_key}. Exiting.") unless result.lock_was_acquired?
14
+ end
15
+ end
16
+ end
@@ -0,0 +1 @@
1
+ Dir["#{File.dirname(__FILE__)}/testing/**/*.rb"].each { |file| require file }
@@ -0,0 +1,114 @@
1
+ module MailDeliveryTask
2
+ module Testing
3
+ module MailerHelper
4
+ extend RSpec::Matchers::DSL
5
+
6
+ # Tests that a provided mail object is deliverable.
7
+ #
8
+ # Usage:
9
+ #
10
+ # expect(mail).to be_deliverable
11
+ #
12
+ matcher :be_deliverable do
13
+ match do |actual|
14
+ expect { actual.deliver_now }.to change { ActionMailer::Base.deliveries.count }.by(1)
15
+ end
16
+ end
17
+
18
+ # Tests that a provided mail object has the expected attributes.
19
+ #
20
+ # Usage:
21
+ #
22
+ # expect(mail).to have_mailer_attributes(
23
+ # from: 'from@.squareup.com',
24
+ # reply_to: 'reply_to@.squareup.com',
25
+ # to: ['person_1@squareup.com', 'person_2@squareup.com'],
26
+ # bcc: 'bcc@squareup.com',
27
+ # subject: 'My random subject',
28
+ # )
29
+ #
30
+ matcher :have_mailer_attributes do |from:, reply_to: nil, to:, bcc: nil, subject:|
31
+ match(notify_expectation_failures: true) do |actual|
32
+ expect(actual).to have_attributes(
33
+ from: Array(from),
34
+ reply_to: Array(reply_to),
35
+ to: Array(to),
36
+ bcc: Array(bcc),
37
+ subject: subject,
38
+ )
39
+ end
40
+ end
41
+
42
+ # Tests that a string matches the content of a fixture file. Also provides
43
+ # convenience methods to be able to update the given fixture.
44
+ #
45
+ # Set `update_fixture` to true in order to automatically overwrite existing
46
+ # fixture files with latest expected text. Note that tests will *always*
47
+ # fail as long as `update_fixture` is set to true.
48
+ #
49
+ # Usage:
50
+ #
51
+ # expect('my_random_text').to match_fixture('spec/fixtures/dummy.html')
52
+ # expect('my_random_text').to match_fixture('spec/fixtures/dummy.html', update_fixture: true)
53
+ #
54
+ matcher :match_fixture do |fixture_file, update_fixture: false|
55
+ match do |actual|
56
+ # Trim all trailing whitespace from the actual generated (to avoid
57
+ # codebase from having any trailing whitespace)
58
+ trimmed_actual = actual.gsub(/[ \t]+$/, '')
59
+
60
+ if update_fixture
61
+ # Update the fixture file with actual content
62
+ path = File.dirname(fixture_file)
63
+ FileUtils.mkdir_p(path)
64
+ FileUtils.touch(fixture_file)
65
+ File.open(fixture_file, 'w+') do |file|
66
+ file.write(trimmed_actual)
67
+ end
68
+
69
+ # Make sure the spec fails
70
+ false
71
+ else
72
+ begin
73
+ expect(trimmed_actual).to eq(File.read(fixture_file))
74
+ rescue
75
+ # Make sure the spec fails
76
+ false
77
+ end
78
+ end
79
+ end
80
+
81
+ failure_message do |actual|
82
+ if update_fixture
83
+ 'Expected update_fixture to be false'
84
+ else
85
+ differ = RSpec::Support::Differ.new(
86
+ object_preparer: proc { |o| RSpec::Matchers::Composable.surface_descriptions_in(o) },
87
+ color: RSpec::Matchers.configuration.color?,
88
+ )
89
+ begin
90
+ expected = File.read(fixture_file)
91
+ <<~MESSAGE
92
+ expected: #{expected.inspect}
93
+ got: #{actual.inspect}
94
+
95
+ Diff: #{differ.diff(actual, expected)}
96
+
97
+ To update existing fixtures, toggle the `update_fixture` option.
98
+
99
+ Example: match_fixture('spec/fixtures/dummy.html', update_fixture: true)
100
+ MESSAGE
101
+ rescue
102
+ <<~MESSAGE
103
+ Fixture file #{fixture_file} does not exist yet. Please
104
+ generate fixtures using the `update_fixture` option.
105
+
106
+ Example: match_fixture('spec/fixtures/dummy.html', update_fixture: true)
107
+ MESSAGE
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,3 @@
1
+ module MailDeliveryTask
2
+ VERSION = '0.0.1'
3
+ end
@@ -0,0 +1,28 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'mail_delivery_task/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'mail_delivery_task'
8
+ spec.version = MailDeliveryTask::VERSION
9
+ spec.authors = ['James Chang']
10
+ spec.email = ['jchang@squareup.com']
11
+
12
+ spec.summary = 'Async email delivery'
13
+ spec.homepage = 'https://github.com/square/mail_delivery_task'
14
+
15
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
16
+ spec.executables = spec.files.grep(%r{^bin/}).map { |f| File.basename(f) }
17
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
18
+ spec.require_paths = ['lib']
19
+
20
+ spec.required_ruby_version = '>= 2.3'
21
+
22
+ spec.add_runtime_dependency 'activejob', '>= 4.2.0', '< 5.2'
23
+ spec.add_runtime_dependency 'activerecord', '>= 4.2.0', '< 5.2'
24
+ spec.add_runtime_dependency 'activesupport', '>= 4.2.0', '< 5.2'
25
+
26
+ spec.add_runtime_dependency 'enumerize'
27
+ spec.add_runtime_dependency 'with_advisory_lock'
28
+ end
metadata ADDED
@@ -0,0 +1,161 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mail_delivery_task
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - James Chang
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2018-01-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activejob
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 4.2.0
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '5.2'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: 4.2.0
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '5.2'
33
+ - !ruby/object:Gem::Dependency
34
+ name: activerecord
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 4.2.0
40
+ - - "<"
41
+ - !ruby/object:Gem::Version
42
+ version: '5.2'
43
+ type: :runtime
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: 4.2.0
50
+ - - "<"
51
+ - !ruby/object:Gem::Version
52
+ version: '5.2'
53
+ - !ruby/object:Gem::Dependency
54
+ name: activesupport
55
+ requirement: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: 4.2.0
60
+ - - "<"
61
+ - !ruby/object:Gem::Version
62
+ version: '5.2'
63
+ type: :runtime
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: 4.2.0
70
+ - - "<"
71
+ - !ruby/object:Gem::Version
72
+ version: '5.2'
73
+ - !ruby/object:Gem::Dependency
74
+ name: enumerize
75
+ requirement: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: '0'
80
+ type: :runtime
81
+ prerelease: false
82
+ version_requirements: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '0'
87
+ - !ruby/object:Gem::Dependency
88
+ name: with_advisory_lock
89
+ requirement: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: '0'
94
+ type: :runtime
95
+ prerelease: false
96
+ version_requirements: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: '0'
101
+ description:
102
+ email:
103
+ - jchang@squareup.com
104
+ executables:
105
+ - console
106
+ - setup
107
+ extensions: []
108
+ extra_rdoc_files: []
109
+ files:
110
+ - ".gitignore"
111
+ - ".rspec"
112
+ - ".rubocop.yml"
113
+ - CHANGES.md
114
+ - Gemfile
115
+ - LICENSE
116
+ - README.md
117
+ - Rakefile
118
+ - bin/console
119
+ - bin/setup
120
+ - lib/generators/mail_delivery_task/install_generator.rb
121
+ - lib/generators/mail_delivery_task/templates/create_mail_delivery_task_attempts.rb
122
+ - lib/generators/mail_delivery_task/templates/mail_delivery_batch_job.rb.erb
123
+ - lib/generators/mail_delivery_task/templates/mail_delivery_batch_job_spec.rb.erb
124
+ - lib/generators/mail_delivery_task/templates/mail_delivery_job.rb.erb
125
+ - lib/generators/mail_delivery_task/templates/mail_delivery_job_spec.rb.erb
126
+ - lib/generators/mail_delivery_task/templates/mail_delivery_task_attempt.rb.erb
127
+ - lib/generators/mail_delivery_task/templates/mail_delivery_task_attempt_spec.rb.erb
128
+ - lib/generators/mail_delivery_task/templates/mail_delivery_task_attempts.rb.erb
129
+ - lib/mail_delivery_task.rb
130
+ - lib/mail_delivery_task/base_attempt.rb
131
+ - lib/mail_delivery_task/jobs/base_delivery_batch_job.rb
132
+ - lib/mail_delivery_task/jobs/base_delivery_job.rb
133
+ - lib/mail_delivery_task/jobs/base_job.rb
134
+ - lib/mail_delivery_task/testing.rb
135
+ - lib/mail_delivery_task/testing/mailer_helper.rb
136
+ - lib/mail_delivery_task/version.rb
137
+ - mail_delivery_task.gemspec
138
+ homepage: https://github.com/square/mail_delivery_task
139
+ licenses: []
140
+ metadata: {}
141
+ post_install_message:
142
+ rdoc_options: []
143
+ require_paths:
144
+ - lib
145
+ required_ruby_version: !ruby/object:Gem::Requirement
146
+ requirements:
147
+ - - ">="
148
+ - !ruby/object:Gem::Version
149
+ version: '2.3'
150
+ required_rubygems_version: !ruby/object:Gem::Requirement
151
+ requirements:
152
+ - - ">="
153
+ - !ruby/object:Gem::Version
154
+ version: '0'
155
+ requirements: []
156
+ rubyforge_project:
157
+ rubygems_version: 2.5.1
158
+ signing_key:
159
+ specification_version: 4
160
+ summary: Async email delivery
161
+ test_files: []