mail_delivery_task 0.0.1

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.
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: []