courrier 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b0a97b8804b294b065433077d025cde44716b87821214649a6421cac278beca5
4
- data.tar.gz: 814110bf00d835bfacb15bc8f59bdeb04a0cb7f7b572d4418c3fd901e98c1115
3
+ metadata.gz: 802112a2d410713a25a67fca11af1a9021cf8f5bd15efe55590bb6d41dcf188b
4
+ data.tar.gz: ac7fd7ab96871921876c79e20a623baa5d7a8fd9ab5ad13b23e3ae9e2631cc57
5
5
  SHA512:
6
- metadata.gz: fc4503ec4cef48dbd12f67f093ccb3df9b29834d34b26f99af90b8020f9be2f7207b7629be79d50ba90af9b7b01c47e104568c1813b7b5296211ed788f453831
7
- data.tar.gz: d50436d4e7417f481f3f2ad2cc59962b30f42fd60d471f7226128e02e04c0378c117968a8c64c8346a735af2e1dae843d1d0447bdb9ce91153850d9e67af1d83
6
+ metadata.gz: 350843ff6b853bb92e797d87dd5b485ad095d3f8a70a2129863ea141e11dcf6f0fd90c8a680bf5f731ce38690e0bc0f47dba60ca61cb3ab9089d7b8d524bfaf8
7
+ data.tar.gz: 222f3088acd972aa5f282e4a29d5f7a2035b2373c10804ff97db89651cb09ead6938117734144c182b2b7268ff64285833674782b38d966145635818a49d2a14
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- courrier (1.0.0)
4
+ courrier (1.1.0)
5
5
  logger (>= 1.5, < 3)
6
6
  nokogiri (>= 1.18, < 2)
7
7
 
data/README.md CHANGED
@@ -96,6 +96,7 @@ Courrier.configure do |config|
96
96
  config.providers.cloudflare.account_id = "your-account-id"
97
97
  config.providers.loops.transactional_id = "default-template"
98
98
  config.providers.mailgun.domain = "notifications.railsdesigner.com"
99
+ config.providers.smtpcom.channel = "your-sender-channel"
99
100
 
100
101
  config.providers.ses.region = "us-east-1"
101
102
  config.providers.ses.access_key_id = "your-access-key-id"
@@ -196,12 +197,16 @@ Courrier supports these transactional email providers:
196
197
  - [Cloudflare Email Service](https://developers.cloudflare.com/email-service/)
197
198
  - [Lettermint](https://lettermint.co)
198
199
  - [Loops](https://loops.so)
200
+ - [MailerSend](https://www.mailersend.com/)
199
201
  - [Mailgun](https://mailgun.com)
202
+ - [MailKite](https://mailkite.dev)
200
203
  - [MailPace](https://mailpace.com)
204
+ - [Mailtrap](https://mailtrap.io)
201
205
  - [Postmark](https://postmarkapp.com)
202
206
  - [Resend](https://resend.com)
203
207
  - [SendGrid](https://sendgrid.com)
204
208
  - [SMTP2GO](https://www.smtp2go.com/)
209
+ - [SMTP.com](https://www.smtp.com/) — requires a `channel`
205
210
  - [SparkPost](https://www.sparkpost.com/)
206
211
  - [Userlist](https://userlist.com)
207
212
 
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ require "courrier/safe_string"
6
+
7
+ module Courrier
8
+ class ContextValue
9
+ def initialize(value, escape:)
10
+ @value = value
11
+ @escape = escape
12
+ end
13
+
14
+ def to_s
15
+ @escape ? CGI.escapeHTML(@value.to_s) : @value.to_s
16
+ end
17
+
18
+ def html_safe
19
+ SafeString.new(@value.to_s)
20
+ end
21
+ alias_method :h, :html_safe
22
+
23
+ def method_missing(name, *arguments, &block)
24
+ result = @value.send(name, *arguments, &block)
25
+
26
+ result.is_a?(String) ? ContextValue.new(result, escape: @escape) : result
27
+ end
28
+
29
+ def respond_to_missing?(name, include_private = false)
30
+ @value.respond_to?(name, include_private) || super
31
+ end
32
+ end
33
+ end
@@ -15,9 +15,9 @@ module Courrier
15
15
  @cc = options.fetch(:cc, nil)
16
16
  @bcc = options.fetch(:bcc, nil)
17
17
 
18
- @subject = options.fetch(:subject, "")
19
- @text = options.fetch(:text, "")
20
- @html = options.fetch(:html, "")
18
+ @subject = options.fetch(:subject, "").to_s
19
+ @text = options.fetch(:text, "").to_s
20
+ @html = options.fetch(:html, "").to_s
21
21
 
22
22
  @auto_generate_text = options.fetch(:auto_generate_text, false)
23
23
 
@@ -6,14 +6,18 @@ require "courrier/email/providers/cloudflare"
6
6
  require "courrier/email/providers/lettermint"
7
7
  require "courrier/email/providers/logger"
8
8
  require "courrier/email/providers/loops"
9
+ require "courrier/email/providers/mailersend"
9
10
  require "courrier/email/providers/mailgun"
11
+ require "courrier/email/providers/mailkite"
10
12
  require "courrier/email/providers/mailjet"
11
13
  require "courrier/email/providers/mailpace"
14
+ require "courrier/email/providers/mailtrap"
12
15
  require "courrier/email/providers/postmark"
13
16
  require "courrier/email/providers/resend"
14
17
  require "courrier/email/providers/ses"
15
18
  require "courrier/email/providers/sendgrid"
16
19
  require "courrier/email/providers/smtp2go"
20
+ require "courrier/email/providers/smtpcom"
17
21
  require "courrier/email/providers/sparkpost"
18
22
  require "courrier/email/providers/userlist"
19
23
 
@@ -26,14 +30,18 @@ module Courrier
26
30
  logger: Courrier::Email::Providers::Logger,
27
31
  lettermint: Courrier::Email::Providers::Lettermint,
28
32
  loops: Courrier::Email::Providers::Loops,
33
+ mailersend: Courrier::Email::Providers::Mailersend,
29
34
  mailgun: Courrier::Email::Providers::Mailgun,
35
+ mailkite: Courrier::Email::Providers::Mailkite,
30
36
  mailjet: Courrier::Email::Providers::Mailjet,
31
37
  mailpace: Courrier::Email::Providers::Mailpace,
38
+ mailtrap: Courrier::Email::Providers::Mailtrap,
32
39
  postmark: Courrier::Email::Providers::Postmark,
33
40
  resend: Courrier::Email::Providers::Resend,
34
41
  ses: Courrier::Email::Providers::Ses,
35
42
  sendgrid: Courrier::Email::Providers::Sendgrid,
36
43
  smtp2go: Courrier::Email::Providers::Smtp2go,
44
+ smtpcom: Courrier::Email::Providers::Smtpcom,
37
45
  sparkpost: Courrier::Email::Providers::Sparkpost,
38
46
  userlist: Courrier::Email::Providers::Userlist
39
47
  }
@@ -40,6 +40,23 @@ module Courrier
40
40
 
41
41
  def default_headers = {}
42
42
 
43
+ def address_list(value, as: :email)
44
+ list = value&.to_s&.split(",")&.map(&:strip)&.reject(&:empty?)
45
+ return unless list && !list.empty?
46
+
47
+ list.map { |address| address_element(address, as) }
48
+ end
49
+
50
+ def address_line(value) = address_list(value, as: :plain)&.join(", ")
51
+
52
+ def address_element(address, as)
53
+ case as
54
+ when :email then {"email" => address}
55
+ when :address then {"address" => address}
56
+ else address
57
+ end
58
+ end
59
+
43
60
  def provider = self.class.name.split("::").last
44
61
  end
45
62
  end
@@ -8,11 +8,11 @@ module Courrier
8
8
 
9
9
  def body
10
10
  {
11
- "sender" => email_address(@options.from),
12
- "to" => email_addresses(@options.to),
13
- "cc" => email_addresses(@options.cc),
14
- "bcc" => email_addresses(@options.bcc),
15
- "replyTo" => email_address(@options.reply_to),
11
+ "sender" => address_list(@options.from)&.first,
12
+ "to" => address_list(@options.to),
13
+ "cc" => address_list(@options.cc),
14
+ "bcc" => address_list(@options.bcc),
15
+ "replyTo" => address_list(@options.reply_to)&.first,
16
16
  "subject" => @options.subject,
17
17
  "htmlContent" => @options.html,
18
18
  "textContent" => @options.text
@@ -22,14 +22,6 @@ module Courrier
22
22
  private
23
23
 
24
24
  def default_headers = {"api-key" => @api_key}
25
-
26
- def email_addresses(addresses)
27
- addresses&.split(",")&.map { |address| email_address(address) }
28
- end
29
-
30
- def email_address(address)
31
- {"email" => address.strip} if address
32
- end
33
25
  end
34
26
  end
35
27
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Courrier
4
+ class Email
5
+ module Providers
6
+ class Mailersend < Base
7
+ ENDPOINT_URL = "https://api.mailersend.com/v1/email"
8
+
9
+ def body
10
+ {
11
+ "from" => address_list(@options.from)&.first,
12
+ "to" => address_list(@options.to),
13
+ "cc" => address_list(@options.cc),
14
+ "bcc" => address_list(@options.bcc),
15
+ "reply_to" => address_list(@options.reply_to)&.first,
16
+ "subject" => @options.subject,
17
+ "text" => @options.text,
18
+ "html" => @options.html
19
+ }.compact
20
+ end
21
+
22
+ private
23
+
24
+ def default_headers = {"Authorization" => "Bearer #{@api_key}"}
25
+ end
26
+ end
27
+ end
28
+ end
@@ -13,6 +13,8 @@ module Courrier
13
13
  "from" => @options.from,
14
14
 
15
15
  "to" => @options.to,
16
+ "cc" => address_line(@options.cc),
17
+ "bcc" => address_line(@options.bcc),
16
18
  "h:Reply-To" => @options.reply_to,
17
19
 
18
20
  "subject" => @options.subject,
@@ -33,7 +35,7 @@ module Courrier
33
35
 
34
36
  def default_headers
35
37
  {
36
- "Authorization" => "Basic #{Base64.strict_encode64("api:#{@api_key}")}"
38
+ "Authorization" => "Basic #{["api:#{@api_key}"].pack("m0")}"
37
39
  }
38
40
  end
39
41
  end
@@ -16,11 +16,9 @@ module Courrier
16
16
  "Email" => @options.from
17
17
  },
18
18
 
19
- "To" => [
20
- {
21
- "Email" => @options.to
22
- }
23
- ],
19
+ "To" => addresses(@options.to),
20
+ "Cc" => addresses(@options.cc),
21
+ "Bcc" => addresses(@options.bcc),
24
22
  "ReplyTo" => reply_to_object,
25
23
 
26
24
  "Subject" => @options.subject,
@@ -35,10 +33,14 @@ module Courrier
35
33
 
36
34
  def default_headers
37
35
  {
38
- "Authorization" => "Basic " + Base64.strict_encode64("#{@api_key}:#{@provider_options.api_secret}")
36
+ "Authorization" => "Basic #{["#{@api_key}:#{@provider_options.api_secret}"].pack("m0")}"
39
37
  }
40
38
  end
41
39
 
40
+ def addresses(value)
41
+ address_list(value, as: :plain)&.map { |address| {"Email" => address} }
42
+ end
43
+
42
44
  def reply_to_object
43
45
  return if @options.reply_to.nil?
44
46
 
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Courrier
4
+ class Email
5
+ module Providers
6
+ class Mailkite < Base
7
+ ENDPOINT_URL = "https://api.mailkite.dev/v1/send"
8
+
9
+ def body
10
+ {
11
+ "from" => @options.from,
12
+ "to" => address_list(@options.to, as: :plain),
13
+ "cc" => address_list(@options.cc, as: :plain),
14
+ "bcc" => address_list(@options.bcc, as: :plain),
15
+ "replyTo" => @options.reply_to,
16
+ "subject" => @options.subject,
17
+ "text" => @options.text,
18
+ "html" => @options.html
19
+ }.compact
20
+ end
21
+
22
+ private
23
+
24
+ def default_headers
25
+ {
26
+ "Authorization" => "Bearer #{@api_key}"
27
+ }
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -11,6 +11,8 @@ module Courrier
11
11
  "from" => @options.from,
12
12
 
13
13
  "to" => @options.to,
14
+ "cc" => address_line(@options.cc),
15
+ "bcc" => address_line(@options.bcc),
14
16
  "replyto" => @options.reply_to,
15
17
 
16
18
  "subject" => @options.subject,
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Courrier
4
+ class Email
5
+ module Providers
6
+ class Mailtrap < Base
7
+ ENDPOINT_URL = "https://send.api.mailtrap.io/api/send"
8
+
9
+ def body
10
+ {
11
+ "from" => address_list(@options.from)&.first,
12
+ "to" => address_list(@options.to),
13
+ "cc" => address_list(@options.cc),
14
+ "bcc" => address_list(@options.bcc),
15
+ "reply_to" => address_list(@options.reply_to)&.first,
16
+ "subject" => @options.subject,
17
+ "text" => @options.text,
18
+ "html" => @options.html
19
+ }.compact
20
+ end
21
+
22
+ private
23
+
24
+ def default_headers = {"Api-Token" => @api_key}
25
+ end
26
+ end
27
+ end
28
+ end
@@ -13,6 +13,8 @@ module Courrier
13
13
  "From" => @options.from,
14
14
 
15
15
  "To" => @options.to,
16
+ "Cc" => address_line(@options.cc),
17
+ "Bcc" => address_line(@options.bcc),
16
18
  "ReplyTo" => @options.reply_to,
17
19
 
18
20
  "Subject" => @options.subject,
@@ -13,12 +13,10 @@ module Courrier
13
13
  },
14
14
  "personalizations" => [
15
15
  {
16
- "to" => [
17
- {
18
- "email" => @options.to
19
- }
20
- ]
21
- }
16
+ "to" => address_list(@options.to),
17
+ "cc" => address_list(@options.cc),
18
+ "bcc" => address_list(@options.bcc)
19
+ }.compact
22
20
  ],
23
21
  "reply_to" => reply_to_object,
24
22
 
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Courrier
4
+ class Email
5
+ module Providers
6
+ class Smtpcom < Base
7
+ def self.config_options = %w[channel]
8
+
9
+ ENDPOINT_URL = "https://api.smtp.com/v4/messages"
10
+
11
+ def body
12
+ {
13
+ "channel" => channel,
14
+ "recipients" => {
15
+ "to" => address_list(@options.to, as: :address),
16
+ "cc" => address_list(@options.cc, as: :address),
17
+ "bcc" => address_list(@options.bcc, as: :address)
18
+ }.compact,
19
+ "originator" => {
20
+ "from" => address_list(@options.from, as: :address)&.first,
21
+ "reply_to" => address_list(@options.reply_to, as: :address)&.first
22
+ }.compact,
23
+ "subject" => @options.subject,
24
+ "body" => {"parts" => parts}
25
+ }
26
+ end
27
+
28
+ private
29
+
30
+ def default_headers
31
+ {"Authorization" => "Basic #{base64("api:#{@api_key}")}"}
32
+ end
33
+
34
+ def channel
35
+ @provider_options.channel || raise(Courrier::ArgumentError, "SMTP.com requires a `channel`")
36
+ end
37
+
38
+ def parts
39
+ [part("text/plain", @options.text), part("text/html", @options.html)].compact
40
+ end
41
+
42
+ def part(type, content)
43
+ return unless content
44
+
45
+ {
46
+ "type" => type,
47
+ "charset" => "UTF-8",
48
+ "encoding" => "base64",
49
+ "content" => base64(content)
50
+ }
51
+ end
52
+
53
+ def base64(value) = [value].pack("m0")
54
+ end
55
+ end
56
+ end
57
+ end
@@ -13,15 +13,10 @@ module Courrier
13
13
  "from" => @options.from,
14
14
  "subject" => @options.subject,
15
15
  "text" => @options.text,
16
- "html" => @options.html
16
+ "html" => @options.html,
17
+ "headers" => cc_header
17
18
  }.compact,
18
- "recipients" => [
19
- {
20
- "address" => {
21
- "email" => @options.to
22
- }
23
- }
24
- ]
19
+ "recipients" => recipients
25
20
  }
26
21
  end
27
22
 
@@ -32,6 +27,26 @@ module Courrier
32
27
  "Authorization" => @api_key
33
28
  }
34
29
  end
30
+
31
+ # SparkPost has no cc/bcc field: every copy is an entry in `recipients`, and only
32
+ # the addresses repeated in the CC header are shown as copied. `header_to` keeps the
33
+ # visible To line the same for all of them, so a bcc stays hidden and a cc is not
34
+ # mistaken for a to.
35
+ def recipients
36
+ [@options.to, @options.cc, @options.bcc].flat_map { |value| recipients_for(value) }
37
+ end
38
+
39
+ def recipients_for(value)
40
+ Array(address_list(value, as: :plain)).map do |address|
41
+ {"address" => {"email" => address, "header_to" => address_line(@options.to)}}
42
+ end
43
+ end
44
+
45
+ def cc_header
46
+ cc = address_line(@options.cc)
47
+
48
+ {"CC" => cc} if cc
49
+ end
35
50
  end
36
51
  end
37
52
  end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+
5
+ require "courrier/context_value"
6
+ require "courrier/safe_string"
7
+ require "courrier/markdown"
8
+
9
+ module Courrier
10
+ class Email
11
+ module Rendering
12
+ private
13
+
14
+ def method_missing(name, *)
15
+ return in_html_context { render_template("html") || markdown_rendered } if name == :html
16
+ return render_template("text") if name == :text
17
+
18
+ value = @context_options[name]
19
+ return value if value.is_a?(SafeString)
20
+
21
+ value.is_a?(String) ? ContextValue.new(value, escape: @html_context) : value
22
+ end
23
+
24
+ def in_html_context
25
+ previous = @html_context
26
+ @html_context = true
27
+
28
+ yield
29
+ ensure
30
+ @html_context = previous
31
+ end
32
+
33
+ def in_safe_context
34
+ previous = @html_context
35
+ @html_context = false
36
+
37
+ yield
38
+ ensure
39
+ @html_context = previous
40
+ end
41
+
42
+ def render_template(format)
43
+ template_path = template_file_path(format)
44
+
45
+ File.exist?(template_path) ? ERB.new(File.read(template_path)).result(binding) : nil
46
+ end
47
+
48
+ def render_markdown_template
49
+ %w[md markdown].each do |ext|
50
+ template_path = template_file_path(ext)
51
+
52
+ return ERB.new(File.read(template_path)).result(binding) if File.exist?(template_path)
53
+ end
54
+
55
+ nil
56
+ end
57
+
58
+ def markdown_rendered
59
+ return unless Courrier::Markdown.available?
60
+
61
+ markdown_content = render_markdown_template || (respond_to?(:markdown, true) ? markdown : nil)
62
+
63
+ Courrier::Markdown.render(markdown_content) if markdown_content
64
+ end
65
+
66
+ def template_file_path(format)
67
+ class_path = self.class.name.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
68
+
69
+ File.join(Courrier.configuration&.email_path, "#{class_path}.#{format}.erb")
70
+ end
71
+
72
+ def html_safe(value = nil, &block)
73
+ return in_safe_context { SafeString.new(block.call.to_s) } if block
74
+ return value.html_safe if value.is_a?(ContextValue)
75
+
76
+ SafeString.new(value.to_s)
77
+ end
78
+ alias_method :h, :html_safe
79
+
80
+ def respond_to_missing?(name, include_private = false) = true
81
+ end
82
+ end
83
+ end
@@ -1,15 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "erb"
4
-
3
+ require "courrier/email/rendering"
5
4
  require "courrier/email/address"
6
5
  require "courrier/email/layouts"
7
- require "courrier/markdown"
8
6
  require "courrier/email/options"
9
7
  require "courrier/email/provider"
10
8
 
11
9
  module Courrier
12
10
  class Email
11
+ include Rendering
12
+
13
13
  attr_accessor :provider, :api_key, :default_url_options, :options, :queue_options
14
14
 
15
15
  @queue_options = {}
@@ -97,7 +97,7 @@ module Courrier
97
97
  bcc: options[:bcc] || self.class.bcc || Courrier.configuration&.bcc,
98
98
  subject: subject,
99
99
  text: text,
100
- html: html,
100
+ html: in_html_context { html },
101
101
  auto_generate_text: Courrier.configuration&.auto_generate_text,
102
102
  layouts: Courrier::Email::Layouts.new(self).build
103
103
  )
@@ -133,46 +133,5 @@ module Courrier
133
133
  def delivery_disabled?
134
134
  ENV["COURRIER_EMAIL_DISABLED"] == "true" || ENV["COURRIER_EMAIL_ENABLED"] == "false"
135
135
  end
136
-
137
- def method_missing(name, *)
138
- if name == :text || name == :html
139
- render_template(name.to_s).tap do |result|
140
- return result || markdown_rendered if name == :html
141
- end
142
- else
143
- @context_options[name]
144
- end
145
- end
146
-
147
- def render_template(format)
148
- template_path = template_file_path(format)
149
-
150
- File.exist?(template_path) ? ERB.new(File.read(template_path)).result(binding) : nil
151
- end
152
-
153
- def render_markdown_template
154
- %w[md markdown].each do |ext|
155
- template_path = template_file_path(ext)
156
-
157
- return ERB.new(File.read(template_path)).result(binding) if File.exist?(template_path)
158
- end
159
-
160
- nil
161
- end
162
-
163
- def markdown_rendered
164
- return unless Courrier::Markdown.available?
165
-
166
- markdown_content = render_markdown_template || (respond_to?(:markdown, true) ? markdown : nil)
167
- Courrier::Markdown.render(markdown_content) if markdown_content
168
- end
169
-
170
- def template_file_path(format)
171
- class_path = self.class.name.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
172
-
173
- File.join(Courrier.configuration&.email_path, "#{class_path}.#{format}.erb")
174
- end
175
-
176
- def respond_to_missing?(name, include_private = false) = true
177
136
  end
178
137
  end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Courrier
4
+ class SafeString
5
+ def initialize(string) = @string = string.to_s
6
+ def to_s = @string
7
+ def html_safe? = true
8
+ end
9
+ end
@@ -1,3 +1,3 @@
1
1
  module Courrier
2
- VERSION = "1.0.0"
2
+ VERSION = "1.1.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: courrier
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rails Designer
@@ -68,6 +68,7 @@ files:
68
68
  - lib/courrier.rb
69
69
  - lib/courrier/configuration.rb
70
70
  - lib/courrier/configuration/providers.rb
71
+ - lib/courrier/context_value.rb
71
72
  - lib/courrier/email.rb
72
73
  - lib/courrier/email/address.rb
73
74
  - lib/courrier/email/layouts.rb
@@ -79,21 +80,27 @@ files:
79
80
  - lib/courrier/email/providers/lettermint.rb
80
81
  - lib/courrier/email/providers/logger.rb
81
82
  - lib/courrier/email/providers/loops.rb
83
+ - lib/courrier/email/providers/mailersend.rb
82
84
  - lib/courrier/email/providers/mailgun.rb
83
85
  - lib/courrier/email/providers/mailjet.rb
86
+ - lib/courrier/email/providers/mailkite.rb
84
87
  - lib/courrier/email/providers/mailpace.rb
88
+ - lib/courrier/email/providers/mailtrap.rb
85
89
  - lib/courrier/email/providers/postmark.rb
86
90
  - lib/courrier/email/providers/resend.rb
87
91
  - lib/courrier/email/providers/sendgrid.rb
88
92
  - lib/courrier/email/providers/ses.rb
89
93
  - lib/courrier/email/providers/smtp2go.rb
94
+ - lib/courrier/email/providers/smtpcom.rb
90
95
  - lib/courrier/email/providers/sparkpost.rb
91
96
  - lib/courrier/email/providers/userlist.rb
97
+ - lib/courrier/email/rendering.rb
92
98
  - lib/courrier/email/request.rb
93
99
  - lib/courrier/email/result.rb
94
100
  - lib/courrier/email/transformer.rb
95
101
  - lib/courrier/errors.rb
96
102
  - lib/courrier/markdown.rb
103
+ - lib/courrier/safe_string.rb
97
104
  - lib/courrier/subscriber.rb
98
105
  - lib/courrier/subscriber/base.rb
99
106
  - lib/courrier/subscriber/beehiiv.rb