courrier 0.9.0 → 0.11.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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +1 -1
  3. data/Gemfile.lock +16 -24
  4. data/README.md +178 -74
  5. data/courrier.gemspec +4 -4
  6. data/lib/courrier/configuration.rb +2 -4
  7. data/lib/courrier/email/provider.rb +14 -6
  8. data/lib/courrier/email/providers/base.rb +7 -2
  9. data/lib/courrier/email/providers/cloudflare.rb +35 -0
  10. data/lib/courrier/email/providers/lettermint.rb +31 -0
  11. data/lib/courrier/email/providers/loops.rb +1 -1
  12. data/lib/courrier/email/providers/mailgun.rb +1 -1
  13. data/lib/courrier/email/providers/mailjet.rb +1 -1
  14. data/lib/courrier/email/providers/mailpace.rb +1 -1
  15. data/lib/courrier/email/providers/postmark.rb +1 -1
  16. data/lib/courrier/email/providers/resend.rb +1 -1
  17. data/lib/courrier/email/providers/sendgrid.rb +1 -1
  18. data/lib/courrier/email/providers/ses.rb +75 -0
  19. data/lib/courrier/email/providers/smtp2go.rb +29 -0
  20. data/lib/courrier/email/providers/sparkpost.rb +1 -1
  21. data/lib/courrier/email/providers/userlist.rb +1 -1
  22. data/lib/courrier/email/request.rb +1 -1
  23. data/lib/courrier/email/transformer.rb +9 -9
  24. data/lib/courrier/email.rb +50 -38
  25. data/lib/courrier/errors.rb +0 -2
  26. data/lib/courrier/markdown.rb +52 -0
  27. data/lib/courrier/test.rb +38 -0
  28. data/lib/courrier/test_helper.rb +65 -0
  29. data/lib/courrier/version.rb +1 -1
  30. data/lib/courrier.rb +2 -2
  31. metadata +18 -31
  32. data/app/controllers/courrier/previews/cleanups_controller.rb +0 -13
  33. data/app/controllers/courrier/previews_controller.rb +0 -23
  34. data/app/views/courrier/previews/index.html.erb +0 -171
  35. data/config/routes.rb +0 -8
  36. data/lib/courrier/configuration/inbox.rb +0 -21
  37. data/lib/courrier/email/providers/inbox/default.html.erb +0 -126
  38. data/lib/courrier/email/providers/inbox.rb +0 -83
  39. data/lib/courrier/engine.rb +0 -7
  40. data/lib/courrier/jobs/email_delivery_job.rb +0 -23
  41. data/lib/courrier/railtie.rb +0 -23
  42. data/lib/courrier/tasks/courrier.rake +0 -13
  43. data/lib/generators/courrier/email_generator.rb +0 -42
  44. data/lib/generators/courrier/install_generator.rb +0 -11
  45. data/lib/generators/courrier/templates/email/password_reset.rb.tt +0 -29
  46. data/lib/generators/courrier/templates/email/welcome.rb.tt +0 -43
  47. data/lib/generators/courrier/templates/email.rb.tt +0 -15
  48. data/lib/generators/courrier/templates/initializer.rb.tt +0 -43
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Courrier
4
+ class Email
5
+ module Providers
6
+ class Cloudflare < Base
7
+ def body
8
+ {
9
+ "from" => @options.from,
10
+ "to" => @options.to,
11
+ "reply_to" => @options.reply_to,
12
+ "cc" => @options.cc,
13
+ "bcc" => @options.bcc,
14
+ "subject" => @options.subject,
15
+ "text" => @options.text,
16
+ "html" => @options.html
17
+ }.compact
18
+ end
19
+
20
+ private
21
+
22
+ def endpoint_url
23
+ account_id = @provider_options.account_id
24
+ raise Courrier::ArgumentError, "Cloudflare requires an `account_id`" unless account_id
25
+
26
+ "https://api.cloudflare.com/client/v4/accounts/#{account_id}/email/sending/send"
27
+ end
28
+
29
+ def default_headers
30
+ {"Authorization" => "Bearer #{@api_key}"}
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Courrier
4
+ class Email
5
+ module Providers
6
+ class Lettermint < Base
7
+ ENDPOINT_URL = "https://api.lettermint.co/v1/send"
8
+
9
+ def body
10
+ {
11
+ "route" => @provider_options.route,
12
+ "from" => @options.from,
13
+ "to" => @options.to.to_s.split(",").map(&:strip),
14
+ "cc" => @options.cc&.split(",")&.map(&:strip),
15
+ "bcc" => @options.bcc&.split(",")&.map(&:strip),
16
+ "reply_to" => @options.reply_to&.split(",")&.map(&:strip),
17
+ "subject" => @options.subject,
18
+ "html" => @options.html,
19
+ "text" => @options.text
20
+ }.compact
21
+ end
22
+
23
+ private
24
+
25
+ def default_headers
26
+ {"x-lettermint-token" => @api_key}
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -16,7 +16,7 @@ module Courrier
16
16
 
17
17
  private
18
18
 
19
- def headers
19
+ def default_headers
20
20
  {
21
21
  "Authorization" => "Bearer #{@api_key}"
22
22
  }
@@ -29,7 +29,7 @@ module Courrier
29
29
 
30
30
  def content_type = "multipart/form-data"
31
31
 
32
- def headers
32
+ def default_headers
33
33
  {
34
34
  "Authorization" => "Basic #{Base64.strict_encode64("api:#{@api_key}")}"
35
35
  }
@@ -31,7 +31,7 @@ module Courrier
31
31
 
32
32
  private
33
33
 
34
- def headers
34
+ def default_headers
35
35
  {
36
36
  "Authorization" => "Basic " + Base64.strict_encode64("#{@api_key}:#{@provider_options.api_secret}")
37
37
  }
@@ -21,7 +21,7 @@ module Courrier
21
21
 
22
22
  private
23
23
 
24
- def headers
24
+ def default_headers
25
25
  {
26
26
  "MailPace-Server-Token" => @api_key
27
27
  }
@@ -23,7 +23,7 @@ module Courrier
23
23
 
24
24
  private
25
25
 
26
- def headers
26
+ def default_headers
27
27
  {
28
28
  "X-Postmark-Server-Token" => @api_key
29
29
  }
@@ -21,7 +21,7 @@ module Courrier
21
21
 
22
22
  private
23
23
 
24
- def headers
24
+ def default_headers
25
25
  {
26
26
  "Authorization" => "Bearer #{@api_key}"
27
27
  }
@@ -38,7 +38,7 @@ module Courrier
38
38
 
39
39
  private
40
40
 
41
- def headers
41
+ def default_headers
42
42
  {
43
43
  "Authorization" => "Bearer #{@api_key}"
44
44
  }
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Courrier
4
+ class Email
5
+ module Providers
6
+ class Ses < Base
7
+ def deliver
8
+ uri = URI.parse("https://email.#{@provider_options[:region]}.amazonaws.com/v2/email/outbound-emails")
9
+
10
+ request = Net::HTTP::Post.new(uri)
11
+ default_headers.merge(@custom_headers).each { |name, value| request[name] = value }
12
+
13
+ sign!(request)
14
+
15
+ request.body = JSON.dump(body)
16
+
17
+ options = {use_ssl: uri.scheme == "https"}
18
+ response = Net::HTTP.start(uri.hostname, uri.port, options) { it.request(request) }
19
+
20
+ Courrier::Email::Result.new(response: response)
21
+ rescue => error
22
+ Courrier::Email::Result.new(error: error)
23
+ end
24
+
25
+ def body
26
+ {
27
+ "FromEmailAddress" => @options.from,
28
+ "Destination" => {
29
+ "ToAddresses" => Array(@options.to),
30
+ "CcAddresses" => @options.cc ? Array(@options.cc) : nil,
31
+ "BccAddresses" => @options.bcc ? Array(@options.bcc) : nil
32
+ }.compact,
33
+
34
+ "ReplyToAddresses" => @options.reply_to ? Array(@options.reply_to) : nil,
35
+ "Content" => {
36
+ "Simple" => {
37
+ "Subject" => {"Data" => @options.subject},
38
+
39
+ "Body" => {
40
+ "Text" => {"Data" => @options.text},
41
+ "Html" => {"Data" => @options.html}
42
+ }.compact
43
+ }
44
+ }
45
+ }.compact
46
+ end
47
+
48
+ private
49
+
50
+ def default_headers
51
+ {
52
+ "Content-Type" => "application/json",
53
+ "Accept" => "application/json"
54
+ }
55
+ end
56
+
57
+ def sign!(request)
58
+ require "aws-sigv4"
59
+
60
+ Aws::Sigv4::Signer.new(
61
+ service: "ses",
62
+ region: @provider_options[:region],
63
+ access_key_id: @provider_options[:access_key_id],
64
+ secret_access_key: @provider_options[:secret_access_key],
65
+ session_token: @provider_options[:session_token]
66
+ ).sign_request(
67
+ http_method: request.method,
68
+ url: request.uri.to_s,
69
+ headers: request.to_hash
70
+ ).headers.each { |name, value| request[name] = value }
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Courrier
4
+ class Email
5
+ module Providers
6
+ class Smtp2go < Base
7
+ ENDPOINT_URL = "https://api.smtp2go.com/v3/email/send"
8
+
9
+ def body
10
+ {
11
+ "sender" => @options.from,
12
+ "to" => @options.to.to_s.split(",").map(&:strip),
13
+ "cc" => @options.cc&.split(",")&.map(&:strip),
14
+ "bcc" => @options.bcc&.split(",")&.map(&:strip),
15
+ "subject" => @options.subject,
16
+ "html_body" => @options.html,
17
+ "text_body" => @options.text
18
+ }.compact
19
+ end
20
+
21
+ private
22
+
23
+ def default_headers
24
+ {"X-Smtp2go-Api-Key" => @api_key}
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -27,7 +27,7 @@ module Courrier
27
27
 
28
28
  private
29
29
 
30
- def headers
30
+ def default_headers
31
31
  {
32
32
  "Authorization" => @api_key
33
33
  }
@@ -17,7 +17,7 @@ module Courrier
17
17
 
18
18
  private
19
19
 
20
- def headers
20
+ def default_headers
21
21
  {
22
22
  "Authorization" => "Push #{@api_key}"
23
23
  }
@@ -25,7 +25,7 @@ module Courrier
25
25
  options = {use_ssl: uri.scheme == "https"}
26
26
 
27
27
  begin
28
- response = Net::HTTP.start(uri.hostname, uri.port, options) { _1.request(request) }
28
+ response = Net::HTTP.start(uri.hostname, uri.port, options) { it.request(request) }
29
29
 
30
30
  Result.new(response: response)
31
31
  rescue => error
@@ -11,30 +11,30 @@ module Courrier
11
11
 
12
12
  def to_text
13
13
  Nokogiri::HTML(@content)
14
- .then { remove_unwanted_elements(_1) }
15
- .then { process_links(_1) }
16
- .then { preserve_line_breaks(_1) }
17
- .then { clean_up(_1) }
14
+ .then { remove_unwanted_elements(it) }
15
+ .then { process_links(it) }
16
+ .then { preserve_line_breaks(it) }
17
+ .then { clean_up(it) }
18
18
  end
19
19
 
20
20
  private
21
21
 
22
22
  BLOCK_ELEMENTS = %w[p div h1 h2 h3 h4 h5 h6 pre blockquote li]
23
23
 
24
- def remove_unwanted_elements(html) = html.tap { _1.css("script, style").remove }
24
+ def remove_unwanted_elements(html) = html.tap { it.css("script, style").remove }
25
25
 
26
26
  def process_links(html)
27
27
  html.tap do |document|
28
28
  document.css("a")
29
- .select { valid?(_1) }
30
- .reject { _1.text.strip.empty? || _1.text.strip == _1["href"] }
31
- .each { _1.content = "#{_1.text.strip} (#{_1["href"]})" }
29
+ .select { valid?(it) }
30
+ .reject { it.text.strip.empty? || it.text.strip == it["href"] }
31
+ .each { it.content = "#{it.text.strip} (#{it["href"]})" }
32
32
  end
33
33
  end
34
34
 
35
35
  def preserve_line_breaks(html)
36
36
  html.tap do |document|
37
- document.css(BLOCK_ELEMENTS.join(",")).each { _1.after("\n") }
37
+ document.css(BLOCK_ELEMENTS.join(",")).each { it.after("\n") }
38
38
  end
39
39
  end
40
40
 
@@ -3,8 +3,8 @@
3
3
  require "erb"
4
4
 
5
5
  require "courrier/email/address"
6
- require "courrier/jobs/email_delivery_job" if defined?(Rails)
7
6
  require "courrier/email/layouts"
7
+ require "courrier/markdown"
8
8
  require "courrier/email/options"
9
9
  require "courrier/email/provider"
10
10
 
@@ -36,7 +36,11 @@ module Courrier
36
36
  @queue_options ||= {}
37
37
  end
38
38
 
39
- attr_writer :queue_options
39
+ attr_writer :queue_options, :headers
40
+
41
+ def headers(**options)
42
+ options.empty? ? (@headers ||= {}) : @headers = options
43
+ end
40
44
 
41
45
  def enqueue(**options)
42
46
  self.queue_options = options
@@ -47,23 +51,29 @@ module Courrier
47
51
  self.layouts = options
48
52
  end
49
53
 
54
+ def before_deliver(&block)
55
+ (@before_deliver ||= []) << block
56
+ end
57
+
58
+ def after_deliver(&block)
59
+ (@after_deliver ||= []) << block
60
+ end
61
+
62
+ def before_deliver_callbacks
63
+ @before_deliver || []
64
+ end
65
+
66
+ def after_deliver_callbacks
67
+ @after_deliver || []
68
+ end
69
+
50
70
  def deliver(**options)
51
71
  new(options).deliver_now
52
72
  end
53
73
  alias_method :deliver_now, :deliver
54
74
 
55
- def deliver_later(**options)
56
- new(options).deliver_later
57
- end
58
-
59
75
  def inherited(subclass)
60
76
  super
61
-
62
- # If you read this and know how to move this Rails-specific logic somewhere
63
- # else, e.g. `lib/courrier/railtie.rb`, open a PR ❤️
64
- if defined?(Rails) && Rails.application
65
- subclass.include Rails.application.routes.url_helpers
66
- end
67
77
  end
68
78
  end
69
79
 
@@ -95,39 +105,22 @@ module Courrier
95
105
  return nil
96
106
  end
97
107
 
108
+ return nil if self.class.before_deliver_callbacks.any? { |callback| callback.call(self) == false }
109
+
98
110
  Provider.new(
99
111
  provider: @provider,
100
112
  api_key: @api_key,
101
113
  options: @options,
102
114
  provider_options: Courrier.configuration&.providers&.[](@provider.to_s.downcase.to_sym),
103
- context_options: @context_options
104
- ).deliver
105
- end
106
- alias_method :deliver_now, :deliver
115
+ context_options: @context_options,
116
+ custom_headers: self.class.headers
117
+ ).deliver.tap do |result|
118
+ Test.record(self, result)
107
119
 
108
- def deliver_later
109
- if delivery_disabled?
110
- Courrier.configuration&.logger&.info "[Courrier] Email delivery skipped: delivery is disabled via environment variable"
111
-
112
- return nil
120
+ self.class.after_deliver_callbacks.each { |callback| callback.call(self, result) }
113
121
  end
114
-
115
- data = {
116
- email_class: self.class.name,
117
- provider: @provider,
118
- api_key: @api_key,
119
- options: @options.to_h,
120
- provider_options: Courrier.configuration&.providers&.[](@provider.to_s.downcase.to_sym),
121
- context_options: @context_options
122
- }
123
-
124
- job = Courrier::Jobs::EmailDeliveryJob
125
- job = job.set(**self.class.queue_options) if self.class.queue_options.any?
126
-
127
- job.perform_later(data)
128
- rescue => error
129
- raise Courrier::BackgroundDeliveryError, "Failed to enqueue email: #{error.message}"
130
122
  end
123
+ alias_method :deliver_now, :deliver
131
124
 
132
125
  private
133
126
 
@@ -137,7 +130,9 @@ module Courrier
137
130
 
138
131
  def method_missing(name, *)
139
132
  if name == :text || name == :html
140
- render_template(name.to_s)
133
+ render_template(name.to_s).tap do |result|
134
+ return result || markdown_rendered if name == :html
135
+ end
141
136
  else
142
137
  @context_options[name]
143
138
  end
@@ -149,6 +144,23 @@ module Courrier
149
144
  File.exist?(template_path) ? ERB.new(File.read(template_path)).result(binding) : nil
150
145
  end
151
146
 
147
+ def render_markdown_template
148
+ %w[md markdown].each do |ext|
149
+ template_path = template_file_path(ext)
150
+
151
+ return ERB.new(File.read(template_path)).result(binding) if File.exist?(template_path)
152
+ end
153
+
154
+ nil
155
+ end
156
+
157
+ def markdown_rendered
158
+ return unless Courrier::Markdown.available?
159
+
160
+ markdown_content = render_markdown_template || (respond_to?(:markdown, true) ? markdown : nil)
161
+ Courrier::Markdown.render(markdown_content) if markdown_content
162
+ end
163
+
152
164
  def template_file_path(format)
153
165
  class_path = self.class.name.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
154
166
 
@@ -8,6 +8,4 @@ module Courrier
8
8
  class ArgumentError < ::ArgumentError; end
9
9
 
10
10
  class NotImplementedError < ::NotImplementedError; end
11
-
12
- class BackgroundDeliveryError < StandardError; end
13
11
  end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Courrier
4
+ class Markdown
5
+ class << self
6
+ def available?
7
+ defined?(::Redcarpet) || defined?(::Kramdown) || defined?(::Commonmarker)
8
+ end
9
+
10
+ def render(text)
11
+ return unless available?
12
+
13
+ parser.parse(text.to_s)
14
+ end
15
+
16
+ private
17
+
18
+ def parser
19
+ @parser ||= available_parser.new
20
+ end
21
+
22
+ def available_parser
23
+ return RedcarpetParser if defined?(::Redcarpet)
24
+ return KramdownParser if defined?(::Kramdown)
25
+ return CommonmarkerParser if defined?(::Commonmarker)
26
+
27
+ Parser
28
+ end
29
+ end
30
+
31
+ class Parser
32
+ def parse(text) = text.to_s
33
+ end
34
+
35
+ class RedcarpetParser < Parser
36
+ def parse(text)
37
+ renderer = Redcarpet::Render::HTML.new
38
+ markdown = Redcarpet::Markdown.new(renderer)
39
+
40
+ markdown.render(text)
41
+ end
42
+ end
43
+
44
+ class KramdownParser < Parser
45
+ def parse(text) = Kramdown::Document.new(text).to_html
46
+ end
47
+
48
+ class CommonmarkerParser < Parser
49
+ def parse(text) = Commonmarker.to_html(text)
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Courrier
4
+ module Test
5
+ Delivery = Data.define(:email_class, :to, :from, :reply_to, :cc, :bcc, :subject, :body, :headers, :provider, :result, :timestamp) do
6
+ def success?
7
+ result.success?
8
+ end
9
+ end
10
+
11
+ class << self
12
+ def deliveries
13
+ @deliveries ||= []
14
+ end
15
+
16
+ def clear!
17
+ @deliveries = []
18
+ end
19
+
20
+ def record(email, result)
21
+ deliveries << Delivery.new(
22
+ email_class: email.class.name,
23
+ to: email.options.to,
24
+ from: email.options.from,
25
+ reply_to: email.options.reply_to,
26
+ cc: email.options.cc,
27
+ bcc: email.options.bcc,
28
+ subject: email.options.subject,
29
+ body: {text: email.options.text, html: email.options.html},
30
+ headers: email.class.headers,
31
+ provider: email.provider,
32
+ result: result,
33
+ timestamp: Time.now
34
+ )
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Courrier
4
+ module TestHelper
5
+ def assert_emails_delivered(count)
6
+ actual = Test.deliveries.size
7
+
8
+ assert_equal count, actual, "Expected #{count} email(s) to be delivered, but #{actual} were delivered"
9
+ end
10
+
11
+ def assert_no_emails_delivered
12
+ assert_emails_delivered(0)
13
+ end
14
+
15
+ def assert_email_delivered(email_class = nil, to: nil, from: nil, subject: nil, provider: nil)
16
+ deliveries = Test.deliveries
17
+
18
+ matching = deliveries.find do |delivery|
19
+ match_email_class(email_class, delivery.email_class) &&
20
+ match_recipient(to, delivery.to) &&
21
+ match_recipient(from, delivery.from) &&
22
+ match_subject(subject, delivery.subject) &&
23
+ match_provider(provider, delivery.provider)
24
+ end
25
+
26
+ assert matching, assertion_message(email_class, to: to, from: from, subject: subject, provider: provider, deliveries: deliveries)
27
+ end
28
+
29
+ private
30
+
31
+ def match_email_class(expected, actual)
32
+ return true if expected.nil?
33
+
34
+ expected == actual || (expected.is_a?(Class) && actual == expected.name)
35
+ end
36
+
37
+ def match_recipient(expected, actual)
38
+ return true if expected.nil?
39
+
40
+ actual.to_s.include?(expected.to_s)
41
+ end
42
+
43
+ def match_subject(expected, actual)
44
+ return true if expected.nil?
45
+
46
+ actual.to_s.include?(expected.to_s)
47
+ end
48
+
49
+ def match_provider(expected, actual)
50
+ return true if expected.nil?
51
+
52
+ actual.to_s == expected.to_s
53
+ end
54
+
55
+ def assertion_message(email_class, to:, from:, subject:, provider:, deliveries:)
56
+ "Expected email matching #{[].tap do |criteria|
57
+ criteria << "email_class=#{email_class}" if email_class
58
+ criteria << "to=#{to}" if to
59
+ criteria << "from=#{from}" if from
60
+ criteria << "subject=#{subject}" if subject
61
+ criteria << "provider=#{provider}" if provider
62
+ end.join(", ")} but none found. #{deliveries.size} email(s) delivered."
63
+ end
64
+ end
65
+ end
@@ -1,3 +1,3 @@
1
1
  module Courrier
2
- VERSION = "0.9.0"
2
+ VERSION = "0.11.0"
3
3
  end
data/lib/courrier.rb CHANGED
@@ -5,8 +5,8 @@ require "courrier/errors"
5
5
  require "courrier/configuration"
6
6
  require "courrier/email"
7
7
  require "courrier/subscriber"
8
- require "courrier/engine" if defined?(Rails)
9
- require "courrier/railtie" if defined?(Rails)
8
+ require "courrier/test"
9
+ require "courrier/test_helper"
10
10
 
11
11
  module Courrier
12
12
  end