courrier 0.10.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +1 -1
  3. data/Gemfile.lock +16 -24
  4. data/README.md +120 -75
  5. data/courrier.gemspec +4 -4
  6. data/lib/courrier/configuration.rb +2 -4
  7. data/lib/courrier/email/provider.rb +10 -4
  8. data/lib/courrier/email/providers/cloudflare.rb +35 -0
  9. data/lib/courrier/email/providers/lettermint.rb +31 -0
  10. data/lib/courrier/email/providers/ses.rb +75 -0
  11. data/lib/courrier/email/providers/smtp2go.rb +29 -0
  12. data/lib/courrier/email/request.rb +1 -1
  13. data/lib/courrier/email/transformer.rb +9 -9
  14. data/lib/courrier/email.rb +22 -35
  15. data/lib/courrier/errors.rb +0 -2
  16. data/lib/courrier/test.rb +38 -0
  17. data/lib/courrier/test_helper.rb +65 -0
  18. data/lib/courrier/version.rb +1 -1
  19. data/lib/courrier.rb +2 -2
  20. metadata +17 -31
  21. data/app/controllers/courrier/previews/cleanups_controller.rb +0 -13
  22. data/app/controllers/courrier/previews_controller.rb +0 -23
  23. data/app/views/courrier/previews/index.html.erb +0 -171
  24. data/config/routes.rb +0 -8
  25. data/lib/courrier/configuration/inbox.rb +0 -21
  26. data/lib/courrier/email/providers/inbox/default.html.erb +0 -126
  27. data/lib/courrier/email/providers/inbox.rb +0 -83
  28. data/lib/courrier/engine.rb +0 -7
  29. data/lib/courrier/jobs/email_delivery_job.rb +0 -23
  30. data/lib/courrier/railtie.rb +0 -23
  31. data/lib/courrier/tasks/courrier.rake +0 -13
  32. data/lib/generators/courrier/email_generator.rb +0 -42
  33. data/lib/generators/courrier/install_generator.rb +0 -11
  34. data/lib/generators/courrier/templates/email/password_reset.rb.tt +0 -29
  35. data/lib/generators/courrier/templates/email/welcome.rb.tt +0 -43
  36. data/lib/generators/courrier/templates/email.rb.tt +0 -13
  37. data/lib/generators/courrier/templates/initializer.rb.tt +0 -43
@@ -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
@@ -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,7 +3,6 @@
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"
8
7
  require "courrier/markdown"
9
8
  require "courrier/email/options"
@@ -52,23 +51,29 @@ module Courrier
52
51
  self.layouts = options
53
52
  end
54
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
+
55
70
  def deliver(**options)
56
71
  new(options).deliver_now
57
72
  end
58
73
  alias_method :deliver_now, :deliver
59
74
 
60
- def deliver_later(**options)
61
- new(options).deliver_later
62
- end
63
-
64
75
  def inherited(subclass)
65
76
  super
66
-
67
- # If you read this and know how to move this Rails-specific logic somewhere
68
- # else, e.g. `lib/courrier/railtie.rb`, open a PR ❤️
69
- if defined?(Rails) && Rails.application
70
- subclass.include Rails.application.routes.url_helpers
71
- end
72
77
  end
73
78
  end
74
79
 
@@ -100,6 +105,8 @@ module Courrier
100
105
  return nil
101
106
  end
102
107
 
108
+ return nil if self.class.before_deliver_callbacks.any? { |callback| callback.call(self) == false }
109
+
103
110
  Provider.new(
104
111
  provider: @provider,
105
112
  api_key: @api_key,
@@ -107,33 +114,13 @@ module Courrier
107
114
  provider_options: Courrier.configuration&.providers&.[](@provider.to_s.downcase.to_sym),
108
115
  context_options: @context_options,
109
116
  custom_headers: self.class.headers
110
- ).deliver
111
- end
112
- alias_method :deliver_now, :deliver
117
+ ).deliver.tap do |result|
118
+ Test.record(self, result)
113
119
 
114
- def deliver_later
115
- if delivery_disabled?
116
- Courrier.configuration&.logger&.info "[Courrier] Email delivery skipped: delivery is disabled via environment variable"
117
-
118
- return nil
120
+ self.class.after_deliver_callbacks.each { |callback| callback.call(self, result) }
119
121
  end
120
-
121
- data = {
122
- email_class: self.class.name,
123
- provider: @provider,
124
- api_key: @api_key,
125
- options: @options.to_h,
126
- provider_options: Courrier.configuration&.providers&.[](@provider.to_s.downcase.to_sym),
127
- context_options: @context_options
128
- }
129
-
130
- job = Courrier::Jobs::EmailDeliveryJob
131
- job = job.set(**self.class.queue_options) if self.class.queue_options.any?
132
-
133
- job.perform_later(data)
134
- rescue => error
135
- raise Courrier::BackgroundDeliveryError, "Failed to enqueue email: #{error.message}"
136
122
  end
123
+ alias_method :deliver_now, :deliver
137
124
 
138
125
  private
139
126
 
@@ -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,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.10.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
metadata CHANGED
@@ -1,35 +1,34 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: courrier
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rails Designer
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-05-24 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
- name: launchy
13
+ name: logger
15
14
  requirement: !ruby/object:Gem::Requirement
16
15
  requirements:
17
16
  - - ">="
18
17
  - !ruby/object:Gem::Version
19
- version: '3.1'
18
+ version: '1.5'
20
19
  - - "<"
21
20
  - !ruby/object:Gem::Version
22
- version: '4'
21
+ version: '3'
23
22
  type: :runtime
24
23
  prerelease: false
25
24
  version_requirements: !ruby/object:Gem::Requirement
26
25
  requirements:
27
26
  - - ">="
28
27
  - !ruby/object:Gem::Version
29
- version: '3.1'
28
+ version: '1.5'
30
29
  - - "<"
31
30
  - !ruby/object:Gem::Version
32
- version: '4'
31
+ version: '3'
33
32
  - !ruby/object:Gem::Dependency
34
33
  name: nokogiri
35
34
  requirement: !ruby/object:Gem::Requirement
@@ -62,17 +61,12 @@ files:
62
61
  - Gemfile.lock
63
62
  - README.md
64
63
  - Rakefile
65
- - app/controllers/courrier/previews/cleanups_controller.rb
66
- - app/controllers/courrier/previews_controller.rb
67
- - app/views/courrier/previews/index.html.erb
68
64
  - bin/console
69
65
  - bin/release
70
66
  - bin/setup
71
- - config/routes.rb
72
67
  - courrier.gemspec
73
68
  - lib/courrier.rb
74
69
  - lib/courrier/configuration.rb
75
- - lib/courrier/configuration/inbox.rb
76
70
  - lib/courrier/configuration/providers.rb
77
71
  - lib/courrier/email.rb
78
72
  - lib/courrier/email/address.rb
@@ -80,8 +74,8 @@ files:
80
74
  - lib/courrier/email/options.rb
81
75
  - lib/courrier/email/provider.rb
82
76
  - lib/courrier/email/providers/base.rb
83
- - lib/courrier/email/providers/inbox.rb
84
- - lib/courrier/email/providers/inbox/default.html.erb
77
+ - lib/courrier/email/providers/cloudflare.rb
78
+ - lib/courrier/email/providers/lettermint.rb
85
79
  - lib/courrier/email/providers/logger.rb
86
80
  - lib/courrier/email/providers/loops.rb
87
81
  - lib/courrier/email/providers/mailgun.rb
@@ -90,16 +84,15 @@ files:
90
84
  - lib/courrier/email/providers/postmark.rb
91
85
  - lib/courrier/email/providers/resend.rb
92
86
  - lib/courrier/email/providers/sendgrid.rb
87
+ - lib/courrier/email/providers/ses.rb
88
+ - lib/courrier/email/providers/smtp2go.rb
93
89
  - lib/courrier/email/providers/sparkpost.rb
94
90
  - lib/courrier/email/providers/userlist.rb
95
91
  - lib/courrier/email/request.rb
96
92
  - lib/courrier/email/result.rb
97
93
  - lib/courrier/email/transformer.rb
98
- - lib/courrier/engine.rb
99
94
  - lib/courrier/errors.rb
100
- - lib/courrier/jobs/email_delivery_job.rb
101
95
  - lib/courrier/markdown.rb
102
- - lib/courrier/railtie.rb
103
96
  - lib/courrier/subscriber.rb
104
97
  - lib/courrier/subscriber/base.rb
105
98
  - lib/courrier/subscriber/beehiiv.rb
@@ -109,21 +102,15 @@ files:
109
102
  - lib/courrier/subscriber/mailchimp.rb
110
103
  - lib/courrier/subscriber/mailerlite.rb
111
104
  - lib/courrier/subscriber/result.rb
112
- - lib/courrier/tasks/courrier.rake
105
+ - lib/courrier/test.rb
106
+ - lib/courrier/test_helper.rb
113
107
  - lib/courrier/version.rb
114
- - lib/generators/courrier/email_generator.rb
115
- - lib/generators/courrier/install_generator.rb
116
- - lib/generators/courrier/templates/email.rb.tt
117
- - lib/generators/courrier/templates/email/password_reset.rb.tt
118
- - lib/generators/courrier/templates/email/welcome.rb.tt
119
- - lib/generators/courrier/templates/initializer.rb.tt
120
- homepage: https://railsdesigner.com/courrier/
108
+ homepage: https://railsdesigner.com/open-source/courrier/
121
109
  licenses:
122
110
  - MIT
123
111
  metadata:
124
- homepage_uri: https://railsdesigner.com/courrier/
112
+ homepage_uri: https://railsdesigner.com/open-source/courrier/
125
113
  source_code_uri: https://github.com/Rails-Designer/courrier/
126
- post_install_message:
127
114
  rdoc_options: []
128
115
  require_paths:
129
116
  - lib
@@ -131,15 +118,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
131
118
  requirements:
132
119
  - - ">="
133
120
  - !ruby/object:Gem::Version
134
- version: 3.2.0
121
+ version: 4.0.0
135
122
  required_rubygems_version: !ruby/object:Gem::Requirement
136
123
  requirements:
137
124
  - - ">="
138
125
  - !ruby/object:Gem::Version
139
126
  version: '0'
140
127
  requirements: []
141
- rubygems_version: 3.4.10
142
- signing_key:
128
+ rubygems_version: 4.0.3
143
129
  specification_version: 4
144
130
  summary: API-powered email delivery for Ruby apps
145
131
  test_files: []
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Courrier
4
- module Previews
5
- class CleanupsController < ActionController::Base
6
- def create
7
- system("bin/rails courrier:clear")
8
-
9
- redirect_to root_path
10
- end
11
- end
12
- end
13
- end
@@ -1,23 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Courrier
4
- class PreviewsController < ActionController::Base
5
- def index
6
- @emails = emails.map { Courrier::Email::Providers::Inbox::Email.from_file(_1) }
7
- end
8
-
9
- def show
10
- file_path = File.join(Courrier.configuration.inbox.destination, params[:id])
11
- content = File.read(file_path)
12
-
13
- render html: content.html_safe, layout: false
14
- end
15
-
16
- private
17
-
18
- def emails
19
- @emails ||= Dir.glob("#{Courrier.configuration.inbox.destination}/*.html")
20
- .sort_by { -File.basename(_1, ".html").to_i }
21
- end
22
- end
23
- end