courrier 0.11.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: d793cbc73ad9612cbb5e010e6aed85f9885e41fc8b672d16f82a6d155f63b726
4
- data.tar.gz: 1143fc17abc330c3f94192ec95aea7618583266491f109b62aee3178826ede97
3
+ metadata.gz: 802112a2d410713a25a67fca11af1a9021cf8f5bd15efe55590bb6d41dcf188b
4
+ data.tar.gz: ac7fd7ab96871921876c79e20a623baa5d7a8fd9ab5ad13b23e3ae9e2631cc57
5
5
  SHA512:
6
- metadata.gz: 64991e00fdb46c81164c29ffc7dc9de6078bcbc6cad12407355424f19f54e3a767e8732abf1438105d1f14b3ebe8ecf8367aa88bd7e53fadd7787fa8dd163609
7
- data.tar.gz: eed7664f6a7211fc98e8cd53e0dc24ad1784e5373575c006feb04fb7d3a2706da3127c0db801b3c960697f774cd3df68df3622b975f365ed5fdfa211327aa3d3
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 (0.11.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"
@@ -121,12 +122,10 @@ OrderEmail.deliver to: "recipient@railsdesigner.com",\
121
122
  ```
122
123
 
123
124
 
124
- Provider and API key settings can be overridden using environment variables (`COURRIER_PROVIDER` and `COURRIER_API_KEY`) for both global configuration and email class defaults.
125
-
126
125
 
127
126
  ## Custom headers
128
127
 
129
- Email classes can define custom HTTP headers that are sent with every email:
128
+ Email classes can define custom headers that are sent with every email:
130
129
  ```ruby
131
130
  class OrderEmail < Courrier::Email
132
131
  headers list_unsubscribe_post: "List-Unsubscribe=One-Click"
@@ -194,71 +193,34 @@ end
194
193
  Courrier supports these transactional email providers:
195
194
 
196
195
  - [AWS SES](https://aws.amazon.com/ses/) — requires `aws-sigv4` gem
196
+ - [Brevo](https://www.brevo.com/)
197
197
  - [Cloudflare Email Service](https://developers.cloudflare.com/email-service/)
198
198
  - [Lettermint](https://lettermint.co)
199
199
  - [Loops](https://loops.so)
200
+ - [MailerSend](https://www.mailersend.com/)
200
201
  - [Mailgun](https://mailgun.com)
202
+ - [MailKite](https://mailkite.dev)
201
203
  - [MailPace](https://mailpace.com)
204
+ - [Mailtrap](https://mailtrap.io)
202
205
  - [Postmark](https://postmarkapp.com)
203
206
  - [Resend](https://resend.com)
204
207
  - [SendGrid](https://sendgrid.com)
205
208
  - [SMTP2GO](https://www.smtp2go.com/)
209
+ - [SMTP.com](https://www.smtp.com/) — requires a `channel`
206
210
  - [SparkPost](https://www.sparkpost.com/)
207
211
  - [Userlist](https://userlist.com)
208
212
 
209
213
 
210
214
  ## More Features
211
215
 
212
- Additional functionality to help with development and testing:
213
-
214
-
215
- ### Layout support
216
-
217
- Wrap your email content using layouts:
218
- ```ruby
219
- class OrderEmail < Courrier::Email
220
- layout text: "%{content}\n\nThanks for your order!",
221
- html: "<div>\n%{content}\n</div>"
222
- end
223
- ```
224
-
225
- Using a method:
226
- ```ruby
227
- class OrderEmail < Courrier::Email
228
- layout html: :html_layout
229
-
230
- def html_layout
231
- <<~HTML
232
- <div style='font-family: ui-sans-serif, system-ui;'>
233
- %{content}
234
- </div>
235
- HTML
236
- end
237
- end
238
- ```
239
-
240
- Using a separate class:
241
- ```ruby
242
- class OrderEmail < Courrier::Email
243
- layout html: OrderLayout
244
- end
245
-
246
- class OrderLayout
247
- self.call
248
- <<~HTML
249
- <div style='font-family: ui-sans-serif, system-ui;'>
250
- %{content}
251
- </div>
252
- HTML
253
- end
254
- end
255
- ```
216
+ Additional functionality to go beyond the basics:
256
217
 
257
218
 
258
219
  ### Template files
259
220
 
260
221
  Instead of defining `text` and `html` methods, you can create ERB template files:
261
222
  ```ruby
223
+ # courrier/emails/order_email.rb
262
224
  class OrderEmail < Courrier::Email
263
225
  def subject = "Your order is ready!"
264
226
 
@@ -291,7 +253,7 @@ end
291
253
 
292
254
  ## Markdown support
293
255
 
294
- Courrier supports rendering markdown content to HTML when a markdown gem is available. Simply bundle any supported markdown gem (`redcarpet`, `kramdown` or `commonmarker`) and it will be used.
256
+ Courrier supports rendering markdown to HTML. Bundle any supported markdown gem (`redcarpet`, `kramdown` or `commonmarker`) and it will be used.
295
257
 
296
258
 
297
259
  ### Markdown methods
@@ -336,6 +298,49 @@ Your order **#<%= order_id %>** is ready for pickup.
336
298
  Method definitions take precedence over template files. You can mix approaches. For example, define `text` in a method and use a markdown template for HTML content.
337
299
 
338
300
 
301
+ ### Layout support
302
+
303
+ Wrap your email content using layouts:
304
+ ```ruby
305
+ class OrderEmail < Courrier::Email
306
+ layout text: "%{content}\n\nThanks for your order!",
307
+ html: "<div>\n%{content}\n</div>"
308
+ end
309
+ ```
310
+
311
+ Using a method:
312
+ ```ruby
313
+ class OrderEmail < Courrier::Email
314
+ layout html: :html_layout
315
+
316
+ def html_layout
317
+ <<~HTML
318
+ <div style='font-family: ui-sans-serif, system-ui;'>
319
+ %{content}
320
+ </div>
321
+ HTML
322
+ end
323
+ end
324
+ ```
325
+
326
+ Using a separate class:
327
+ ```ruby
328
+ class OrderEmail < Courrier::Email
329
+ layout html: OrderLayout
330
+ end
331
+
332
+ class OrderLayout
333
+ self.call
334
+ <<~HTML
335
+ <div style='font-family: ui-sans-serif, system-ui;'>
336
+ %{content}
337
+ </div>
338
+ HTML
339
+ end
340
+ end
341
+ ```
342
+
343
+
339
344
  ### Auto-generate text from HTML
340
345
 
341
346
  Automatically generate plain text versions from your HTML emails:
@@ -344,6 +349,22 @@ config.auto_generate_text = true # defaults to false
344
349
  ```
345
350
 
346
351
 
352
+ ### Delivery callbacks
353
+
354
+ Hook into the email delivery lifecycle with `before_deliver` and `after_deliver` callbacks:
355
+ ```ruby
356
+ class OrderEmail < Courrier::Email
357
+ before_deliver do |email|
358
+ puts "Sending to #{email.options.to}" # access email options, abort delivery by returning false
359
+ end
360
+
361
+ after_deliver do |email, result|
362
+ puts "Delivered: #{result.success?}" # access email and delivery result
363
+ end
364
+ end
365
+ ```
366
+
367
+
347
368
  ### Email address helper
348
369
 
349
370
  Compose email addresses with display names:
@@ -365,7 +386,7 @@ end
365
386
  Use Ruby's built-in Logger for development and testing:
366
387
 
367
388
  ```ruby
368
- config.provider = "logger" # outputs emails to STDOUT
389
+ config.email = { provider: "logger" } # outputs emails to STDOUT
369
390
  config.logger = custom_logger # optional: defaults to ::Logger.new($stdout)
370
391
  ```
371
392
 
@@ -385,7 +406,7 @@ end
385
406
 
386
407
  Then configure it:
387
408
  ```ruby
388
- config.provider = "CustomProvider"
409
+ config.email = { provider: "CustomProvider" }
389
410
  ```
390
411
 
391
412
  Check the [existing providers](https://github.com/Rails-Designer/courrier/tree/main/lib/courrier/email/providers) for implementation examples.
@@ -449,27 +470,9 @@ Available assertions:
449
470
  - `assert_email_delivered(email_class, to:, from:, subject:, provider:)`; assert an email matching criteria was delivered
450
471
 
451
472
 
452
- ### Delivery callbacks
453
-
454
- Hook into the email delivery lifecycle with `before_deliver` and `after_deliver` callbacks:
455
- ```ruby
456
- class OrderEmail < Courrier::Email
457
- before_deliver do |email|
458
- puts "Sending to #{email.options.to}" # access email options, abort delivery by returning false
459
- end
460
-
461
- after_deliver do |email, result|
462
- puts "Delivered: #{result.success?}" # access email and delivery result
463
- end
464
- end
465
- ```
466
-
467
- Callbacks are isolated per class (subclasses don't inherit parent callbacks).
468
-
469
-
470
473
  ## Newsletter subscriptions
471
474
 
472
- Manage subscribers across popular email marketing platforms:
475
+ Use Courrier to manage subscribers across popular email marketing platforms:
473
476
  ```ruby
474
477
  Courrier.configure do |config|
475
478
  config.subscriber = {
@@ -497,6 +500,7 @@ end
497
500
  ### Supported providers
498
501
 
499
502
  - [Beehiiv](https://www.beehiiv.com/) - requires `publication_id`
503
+ - [Brevo](https://www.brevo.com/) - accepts optional `list_ids`
500
504
  - [Buttondown](https://buttondown.com)
501
505
  - [Kit](https://kit.com/) (formerly ConvertKit) - requires `form_id`
502
506
  - [Loops](https://loops.so/)
@@ -513,6 +517,14 @@ config.subscriber = {
513
517
  }
514
518
  ```
515
519
 
520
+ For Brevo, pass one or more list IDs when new contacts should be added to specific lists:
521
+ ```ruby
522
+ config.subscriber = {
523
+ provider: "brevo",
524
+ api_key: "your_api_key",
525
+ list_ids: [12, 34]
526
+ }
527
+ ```
516
528
 
517
529
  ### Custom providers
518
530
 
data/courrier.gemspec CHANGED
@@ -18,7 +18,7 @@ Gem::Specification.new do |spec|
18
18
 
19
19
  spec.files = Dir["{bin,lib}/**/*", "Rakefile", "README.md", "courrier.gemspec", "Gemfile", "Gemfile.lock"]
20
20
 
21
- spec.required_ruby_version = ">= 4.0.0"
21
+ spec.required_ruby_version = ">= 3.4.0"
22
22
 
23
23
  spec.add_dependency "logger", ">= 1.5", "< 3"
24
24
  spec.add_dependency "nokogiri", ">= 1.18", "< 2"
@@ -42,30 +42,6 @@ module Courrier
42
42
  @providers = Courrier::Configuration::Providers.new
43
43
  end
44
44
 
45
- def provider
46
- warn "[DEPRECATION] `provider` is deprecated. Use `email = { provider: '…' }` instead. Will be removed in 1.0.0"
47
-
48
- @email[:provider]
49
- end
50
-
51
- def provider=(value)
52
- warn "[DEPRECATION] `provider=` is deprecated. Use `email = { provider: '…' }` instead. Will be removed in 1.0.0"
53
-
54
- @email[:provider] = value
55
- end
56
-
57
- def api_key
58
- warn "[DEPRECATION] `api_key` is deprecated. Use `email = { api_key: '…' }` instead. Will be removed in 1.0.0"
59
-
60
- @email[:api_key]
61
- end
62
-
63
- def api_key=(value)
64
- warn "[DEPRECATION] `api_key=` is deprecated. Use `email = { api_key: '…' }` instead. Will be removed in 1.0.0"
65
-
66
- @email[:api_key] = value
67
- end
68
-
69
45
  private
70
46
 
71
47
  def default_email_path
@@ -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
 
@@ -1,18 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "courrier/email/providers/base"
4
+ require "courrier/email/providers/brevo"
4
5
  require "courrier/email/providers/cloudflare"
5
6
  require "courrier/email/providers/lettermint"
6
7
  require "courrier/email/providers/logger"
7
8
  require "courrier/email/providers/loops"
9
+ require "courrier/email/providers/mailersend"
8
10
  require "courrier/email/providers/mailgun"
11
+ require "courrier/email/providers/mailkite"
9
12
  require "courrier/email/providers/mailjet"
10
13
  require "courrier/email/providers/mailpace"
14
+ require "courrier/email/providers/mailtrap"
11
15
  require "courrier/email/providers/postmark"
12
16
  require "courrier/email/providers/resend"
13
17
  require "courrier/email/providers/ses"
14
18
  require "courrier/email/providers/sendgrid"
15
19
  require "courrier/email/providers/smtp2go"
20
+ require "courrier/email/providers/smtpcom"
16
21
  require "courrier/email/providers/sparkpost"
17
22
  require "courrier/email/providers/userlist"
18
23
 
@@ -20,18 +25,23 @@ module Courrier
20
25
  class Email
21
26
  class Provider
22
27
  PROVIDERS = {
28
+ brevo: Courrier::Email::Providers::Brevo,
23
29
  cloudflare: Courrier::Email::Providers::Cloudflare,
24
30
  logger: Courrier::Email::Providers::Logger,
25
31
  lettermint: Courrier::Email::Providers::Lettermint,
26
32
  loops: Courrier::Email::Providers::Loops,
33
+ mailersend: Courrier::Email::Providers::Mailersend,
27
34
  mailgun: Courrier::Email::Providers::Mailgun,
35
+ mailkite: Courrier::Email::Providers::Mailkite,
28
36
  mailjet: Courrier::Email::Providers::Mailjet,
29
37
  mailpace: Courrier::Email::Providers::Mailpace,
38
+ mailtrap: Courrier::Email::Providers::Mailtrap,
30
39
  postmark: Courrier::Email::Providers::Postmark,
31
40
  resend: Courrier::Email::Providers::Resend,
32
41
  ses: Courrier::Email::Providers::Ses,
33
42
  sendgrid: Courrier::Email::Providers::Sendgrid,
34
43
  smtp2go: Courrier::Email::Providers::Smtp2go,
44
+ smtpcom: Courrier::Email::Providers::Smtpcom,
35
45
  sparkpost: Courrier::Email::Providers::Sparkpost,
36
46
  userlist: Courrier::Email::Providers::Userlist
37
47
  }
@@ -6,6 +6,8 @@ module Courrier
6
6
  class Email
7
7
  module Providers
8
8
  class Base
9
+ def self.config_options = []
10
+
9
11
  def initialize(api_key: nil, options: {}, provider_options: {}, context_options: {}, custom_headers: {})
10
12
  @api_key = api_key
11
13
  @options = options
@@ -38,6 +40,23 @@ module Courrier
38
40
 
39
41
  def default_headers = {}
40
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
+
41
60
  def provider = self.class.name.split("::").last
42
61
  end
43
62
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Courrier
4
+ class Email
5
+ module Providers
6
+ class Brevo < Base
7
+ ENDPOINT_URL = "https://api.brevo.com/v3/smtp/email"
8
+
9
+ def body
10
+ {
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
+ "subject" => @options.subject,
17
+ "htmlContent" => @options.html,
18
+ "textContent" => @options.text
19
+ }.compact
20
+ end
21
+
22
+ private
23
+
24
+ def default_headers = {"api-key" => @api_key}
25
+ end
26
+ end
27
+ end
28
+ end
@@ -4,6 +4,8 @@ module Courrier
4
4
  class Email
5
5
  module Providers
6
6
  class Cloudflare < Base
7
+ def self.config_options = %w[account_id]
8
+
7
9
  def body
8
10
  {
9
11
  "from" => @options.from,
@@ -4,6 +4,8 @@ module Courrier
4
4
  class Email
5
5
  module Providers
6
6
  class Lettermint < Base
7
+ def self.config_options = %w[route]
8
+
7
9
  ENDPOINT_URL = "https://api.lettermint.co/v1/send"
8
10
 
9
11
  def body
@@ -4,6 +4,8 @@ module Courrier
4
4
  class Email
5
5
  module Providers
6
6
  class Loops < Base
7
+ def self.config_options = %w[transactional_id]
8
+
7
9
  ENDPOINT_URL = "https://app.loops.so/api/v1/transactional"
8
10
 
9
11
  def body
@@ -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
@@ -4,6 +4,8 @@ module Courrier
4
4
  class Email
5
5
  module Providers
6
6
  class Mailgun < Base
7
+ def self.config_options = %w[domain]
8
+
7
9
  ENDPOINT_URL = "https://api.mailgun.net/v3/%{domain}/messages"
8
10
 
9
11
  def body
@@ -11,6 +13,8 @@ module Courrier
11
13
  "from" => @options.from,
12
14
 
13
15
  "to" => @options.to,
16
+ "cc" => address_line(@options.cc),
17
+ "bcc" => address_line(@options.bcc),
14
18
  "h:Reply-To" => @options.reply_to,
15
19
 
16
20
  "subject" => @options.subject,
@@ -31,7 +35,7 @@ module Courrier
31
35
 
32
36
  def default_headers
33
37
  {
34
- "Authorization" => "Basic #{Base64.strict_encode64("api:#{@api_key}")}"
38
+ "Authorization" => "Basic #{["api:#{@api_key}"].pack("m0")}"
35
39
  }
36
40
  end
37
41
  end
@@ -4,6 +4,8 @@ module Courrier
4
4
  class Email
5
5
  module Providers
6
6
  class Mailjet < Base
7
+ def self.config_options = %w[api_secret]
8
+
7
9
  ENDPOINT_URL = "https://api.mailjet.com/v3.1/send"
8
10
 
9
11
  def body
@@ -14,11 +16,9 @@ module Courrier
14
16
  "Email" => @options.from
15
17
  },
16
18
 
17
- "To" => [
18
- {
19
- "Email" => @options.to
20
- }
21
- ],
19
+ "To" => addresses(@options.to),
20
+ "Cc" => addresses(@options.cc),
21
+ "Bcc" => addresses(@options.bcc),
22
22
  "ReplyTo" => reply_to_object,
23
23
 
24
24
  "Subject" => @options.subject,
@@ -33,10 +33,14 @@ module Courrier
33
33
 
34
34
  def default_headers
35
35
  {
36
- "Authorization" => "Basic " + Base64.strict_encode64("#{@api_key}:#{@provider_options.api_secret}")
36
+ "Authorization" => "Basic #{["#{@api_key}:#{@provider_options.api_secret}"].pack("m0")}"
37
37
  }
38
38
  end
39
39
 
40
+ def addresses(value)
41
+ address_list(value, as: :plain)&.map { |address| {"Email" => address} }
42
+ end
43
+
40
44
  def reply_to_object
41
45
  return if @options.reply_to.nil?
42
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
@@ -4,6 +4,8 @@ module Courrier
4
4
  class Email
5
5
  module Providers
6
6
  class Postmark < Base
7
+ def self.config_options = %w[message_stream]
8
+
7
9
  ENDPOINT_URL = "https://api.postmarkapp.com/email"
8
10
 
9
11
  def body
@@ -11,6 +13,8 @@ module Courrier
11
13
  "From" => @options.from,
12
14
 
13
15
  "To" => @options.to,
16
+ "Cc" => address_line(@options.cc),
17
+ "Bcc" => address_line(@options.bcc),
14
18
  "ReplyTo" => @options.reply_to,
15
19
 
16
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
 
@@ -4,6 +4,8 @@ module Courrier
4
4
  class Email
5
5
  module Providers
6
6
  class Ses < Base
7
+ def self.config_options = %w[region access_key_id secret_access_key session_token]
8
+
7
9
  def deliver
8
10
  uri = URI.parse("https://email.#{@provider_options[:region]}.amazonaws.com/v2/email/outbound-emails")
9
11
 
@@ -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
@@ -4,6 +4,8 @@ module Courrier
4
4
  class Email
5
5
  module Providers
6
6
  class Userlist < Base
7
+ def self.config_options = %w[theme]
8
+
7
9
  ENDPOINT_URL = "https://push.userlist.com/messages"
8
10
 
9
11
  def body
@@ -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 = {}
@@ -60,11 +60,17 @@ module Courrier
60
60
  end
61
61
 
62
62
  def before_deliver_callbacks
63
- @before_deliver || []
63
+ return @before_deliver if @before_deliver
64
+ return superclass.before_deliver_callbacks if superclass.respond_to?(:before_deliver_callbacks)
65
+
66
+ []
64
67
  end
65
68
 
66
69
  def after_deliver_callbacks
67
- @after_deliver || []
70
+ return @after_deliver if @after_deliver
71
+ return superclass.after_deliver_callbacks if superclass.respond_to?(:after_deliver_callbacks)
72
+
73
+ []
68
74
  end
69
75
 
70
76
  def deliver(**options)
@@ -78,8 +84,8 @@ module Courrier
78
84
  end
79
85
 
80
86
  def initialize(options = {})
81
- @provider = options[:provider] || ENV["COURRIER_PROVIDER"] || self.class.provider || Courrier.configuration&.email&.[](:provider)
82
- @api_key = options[:api_key] || ENV["COURRIER_API_KEY"] || self.class.api_key || Courrier.configuration&.email&.[](:api_key)
87
+ @provider = options[:provider] || self.class.provider || Courrier.configuration&.email&.[](:provider)
88
+ @api_key = options[:api_key] || self.class.api_key || Courrier.configuration&.email&.[](:api_key)
83
89
 
84
90
  @default_url_options = self.class.default_url_options.merge(options[:default_url_options] || {})
85
91
  @context_options = options.except(:provider, :api_key, :from, :to, :reply_to, :cc, :bcc, :subject, :text, :html)
@@ -91,7 +97,7 @@ module Courrier
91
97
  bcc: options[:bcc] || self.class.bcc || Courrier.configuration&.bcc,
92
98
  subject: subject,
93
99
  text: text,
94
- html: html,
100
+ html: in_html_context { html },
95
101
  auto_generate_text: Courrier.configuration&.auto_generate_text,
96
102
  layouts: Courrier::Email::Layouts.new(self).build
97
103
  )
@@ -127,46 +133,5 @@ module Courrier
127
133
  def delivery_disabled?
128
134
  ENV["COURRIER_EMAIL_DISABLED"] == "true" || ENV["COURRIER_EMAIL_ENABLED"] == "false"
129
135
  end
130
-
131
- def method_missing(name, *)
132
- if name == :text || name == :html
133
- render_template(name.to_s).tap do |result|
134
- return result || markdown_rendered if name == :html
135
- end
136
- else
137
- @context_options[name]
138
- end
139
- end
140
-
141
- def render_template(format)
142
- template_path = template_file_path(format)
143
-
144
- File.exist?(template_path) ? ERB.new(File.read(template_path)).result(binding) : nil
145
- end
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
-
164
- def template_file_path(format)
165
- class_path = self.class.name.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
166
-
167
- File.join(Courrier.configuration&.email_path, "#{class_path}.#{format}.erb")
168
- end
169
-
170
- def respond_to_missing?(name, include_private = false) = true
171
136
  end
172
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
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "courrier/subscriber/base"
5
+
6
+ module Courrier
7
+ class Subscriber
8
+ class Brevo < Base
9
+ ENDPOINT_URL = "https://api.brevo.com/v3/contacts"
10
+
11
+ def create(email)
12
+ body = {
13
+ "email" => email,
14
+ "updateEnabled" => true
15
+ }
16
+
17
+ list_ids = Array(Courrier.configuration.subscriber[:list_ids]).compact
18
+ body["listIds"] = list_ids unless list_ids.empty?
19
+
20
+ request(:post, ENDPOINT_URL, body)
21
+ end
22
+
23
+ def destroy(email)
24
+ identifier = URI.encode_www_form_component(email)
25
+
26
+ request(:delete, "#{ENDPOINT_URL}/#{identifier}")
27
+ end
28
+
29
+ private
30
+
31
+ def headers
32
+ {
33
+ "api-key" => @api_key,
34
+ "Content-Type" => "application/json"
35
+ }
36
+ end
37
+ end
38
+ end
39
+ end
@@ -1,3 +1,3 @@
1
1
  module Courrier
2
- VERSION = "0.11.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: 0.11.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rails Designer
@@ -68,34 +68,43 @@ 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
74
75
  - lib/courrier/email/options.rb
75
76
  - lib/courrier/email/provider.rb
76
77
  - lib/courrier/email/providers/base.rb
78
+ - lib/courrier/email/providers/brevo.rb
77
79
  - lib/courrier/email/providers/cloudflare.rb
78
80
  - lib/courrier/email/providers/lettermint.rb
79
81
  - lib/courrier/email/providers/logger.rb
80
82
  - lib/courrier/email/providers/loops.rb
83
+ - lib/courrier/email/providers/mailersend.rb
81
84
  - lib/courrier/email/providers/mailgun.rb
82
85
  - lib/courrier/email/providers/mailjet.rb
86
+ - lib/courrier/email/providers/mailkite.rb
83
87
  - lib/courrier/email/providers/mailpace.rb
88
+ - lib/courrier/email/providers/mailtrap.rb
84
89
  - lib/courrier/email/providers/postmark.rb
85
90
  - lib/courrier/email/providers/resend.rb
86
91
  - lib/courrier/email/providers/sendgrid.rb
87
92
  - lib/courrier/email/providers/ses.rb
88
93
  - lib/courrier/email/providers/smtp2go.rb
94
+ - lib/courrier/email/providers/smtpcom.rb
89
95
  - lib/courrier/email/providers/sparkpost.rb
90
96
  - lib/courrier/email/providers/userlist.rb
97
+ - lib/courrier/email/rendering.rb
91
98
  - lib/courrier/email/request.rb
92
99
  - lib/courrier/email/result.rb
93
100
  - lib/courrier/email/transformer.rb
94
101
  - lib/courrier/errors.rb
95
102
  - lib/courrier/markdown.rb
103
+ - lib/courrier/safe_string.rb
96
104
  - lib/courrier/subscriber.rb
97
105
  - lib/courrier/subscriber/base.rb
98
106
  - lib/courrier/subscriber/beehiiv.rb
107
+ - lib/courrier/subscriber/brevo.rb
99
108
  - lib/courrier/subscriber/buttondown.rb
100
109
  - lib/courrier/subscriber/kit.rb
101
110
  - lib/courrier/subscriber/loops.rb
@@ -118,14 +127,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
118
127
  requirements:
119
128
  - - ">="
120
129
  - !ruby/object:Gem::Version
121
- version: 4.0.0
130
+ version: 3.4.0
122
131
  required_rubygems_version: !ruby/object:Gem::Requirement
123
132
  requirements:
124
133
  - - ">="
125
134
  - !ruby/object:Gem::Version
126
135
  version: '0'
127
136
  requirements: []
128
- rubygems_version: 4.0.3
137
+ rubygems_version: 3.6.7
129
138
  specification_version: 4
130
139
  summary: API-powered email delivery for Ruby apps
131
140
  test_files: []