courrier 0.7.0 → 0.8.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: 1359782140920863fe902186544d8b34bcbc513ca4412b26bc3bea975196916e
4
- data.tar.gz: 493af39e04f09434689cb810a57c591b02ec9373b84660de4de0349c168eebfb
3
+ metadata.gz: e43b1cb6985e742ca84f68be14ddf25e72bf8f64a26ce7be151272983996ac45
4
+ data.tar.gz: ac7b82db6e5fd9975a76de6a630a4ac8e3f956595e175fc6d6c1ef18d9d4949c
5
5
  SHA512:
6
- metadata.gz: d7928a0eb201e6b4b5e36612a03641c49a862477c6338be08800e10c1a4b9cc5f8e1917253a2a2b9b414c51ef361c9dcbfe87a309e1fcba22fb8af43310f068e
7
- data.tar.gz: 9a4c22a760d6c9af901beaa076a30dcbe02db45b2c339932bef2a3e6df90531d3bdaf860c86bb954d0397243b73c4b2b07dd4072eef6cd5c9d641a9e2f3303fd
6
+ metadata.gz: 4f5ad1ecb424cca190a86bd10acbf69c906e3f7218724a3cdc29e8ce3e10c4bea4c1c43ba66d6de24af17f53c9db416c659e7b84aadd92fcece63b57ee5a13ce
7
+ data.tar.gz: 9050571fcc8050ecb6a92a321e466f92951c383d3ec1376809f70913440f38537f8245b37f2955bca3c263874d9de178818789dae89cc2a7bfe809c3d9d39eb9
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- courrier (0.7.0)
4
+ courrier (0.8.0)
5
5
  launchy (>= 3.1, < 4)
6
6
  nokogiri (>= 1.18, < 2)
7
7
 
data/README.md CHANGED
@@ -1,12 +1,23 @@
1
1
  # Courrier
2
2
 
3
- API-powered email delivery for Ruby apps.
3
+ API-powered email delivery and newsletter subscription management for Ruby apps
4
4
 
5
- ![A cute cartoon mascot wearing a blue postal uniform with red scarf and cap, carrying a leather messenger bag, representing an API-powered email delivery system for Ruby applications](https://raw.githubusercontent.com/Rails-Designer/courrier/HEAD/.github/cover.jpg)
5
+ ![A cute cartoon mascot wearing a blue postal uniform with red scarf and cap, carrying a leather messenger bag, representing an API-powered email delivery gem for Ruby apps](https://raw.githubusercontent.com/Rails-Designer/courrier/HEAD/.github/cover.jpg)
6
6
 
7
7
  ```ruby
8
8
  # Quick example
9
+ class OrderEmail < Courrier::Email
10
+ def subject = "Here is your order!"
11
+
12
+ def text = "Thanks for ordering"
13
+
14
+ def html = "<p>Thanks for ordering</p>"
15
+ end
16
+
9
17
  OrderEmail.deliver to: "recipient@railsdesigner.com"
18
+
19
+ # Manage newsletter subscriptions
20
+ Courrier::Subscriber.create "subscriber@example.com"
10
21
  ```
11
22
 
12
23
  <a href="https://railsdesigner.com/" target="_blank">
@@ -72,8 +83,11 @@ Courrier uses a configuration system with three levels (from lowest to highest p
72
83
  1. **Global configuration**
73
84
  ```ruby
74
85
  Courrier.configure do |config|
75
- config.provider = "postmark"
76
- config.api_key = "xyz"
86
+ config.email = {
87
+ provider: "postmark",
88
+ api_key: "xyz"
89
+ }
90
+
77
91
  config.from = "devs@railsdesigner.com"
78
92
  config.default_url_options = { host: "railsdesigner.com" }
79
93
 
@@ -88,7 +102,7 @@ end
88
102
  class OrderEmail < Courrier::Email
89
103
  configure from: "orders@railsdesigner.com",
90
104
  cc: "records@railsdesigner.com",
91
- provider: "mailgun",
105
+ provider: "mailgun"
92
106
  end
93
107
  ```
94
108
 
@@ -104,7 +118,7 @@ OrderEmail.deliver to: "recipient@railsdesigner.com",\
104
118
  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.
105
119
 
106
120
 
107
- ## Custom Attributes
121
+ ## Custom attributes
108
122
 
109
123
  Besides the standard email attributes (`from`, `to`, `reply_to`, etc.), you can pass any additional attributes that will be available in your email templates:
110
124
  ```ruby
@@ -122,12 +136,12 @@ end
122
136
  ```
123
137
 
124
138
 
125
- ## Result Object
139
+ ## Result object
126
140
 
127
141
  When sending an email through Courrier, a `Result` object is returned that provides information about the delivery attempt. This object offers a simple interface to check the status and access response data.
128
142
 
129
143
 
130
- ### Available Methods
144
+ ### Available methods
131
145
 
132
146
  | Method | Return Type | Description |
133
147
  |:-------|:-----------|:------------|
@@ -157,22 +171,28 @@ Courrier supports these transactional email providers:
157
171
 
158
172
  - [Loops](https://loops.so)
159
173
  - [Mailgun](https://mailgun.com)
160
- - [Mailjet](https://mailjet.com)
161
174
  - [MailPace](https://mailpace.com)
162
175
  - [Postmark](https://postmarkapp.com)
163
176
  - [Resend](https://resend.com)
164
- - [SendGrid](https://sendgrid.com)
165
- - [SparkPost](https://sparkpost.com)
166
177
  - [Userlist](https://userlist.com)
167
178
 
168
- ⚠️ Some providers still need manual verification of their implementation. If you're using one of these providers, please help verify the implementation by sharing your experience in [this GitHub issue](https://github.com/Rails-Designer/courrier/issues/4). 🙏
169
-
170
179
 
171
180
  ## More Features
172
181
 
173
182
  Additional functionality to help with development and testing:
174
183
 
175
184
 
185
+ ### Background jobs (Rails only)
186
+
187
+ Use `deliver_later` to enqueue delivering using Rails' ActiveJob. You can set
188
+ various ActiveJob-supported options in the email class, like so: `enqueue queue: "emails", wait: 5.minutes`.
189
+
190
+ - `queue`, enqueue the email on the specified queue;
191
+ - `wait`, enqueue the email to be delivered with a delay;
192
+ - `wait_until`, enqueue the email to be delivered at (after) a specific date/time;
193
+ - `priority`, enqueues the email with the specified priority.
194
+
195
+
176
196
  ### Inbox (Rails only)
177
197
 
178
198
  You can preview your emails in the inbox:
@@ -192,7 +212,7 @@ config.inbox.auto_open = true
192
212
  Emails are automatically cleared with `bin/rails tmp:clear`, or manually with `bin/rails courrier:clear`.
193
213
 
194
214
 
195
- ### Layout Support
215
+ ### Layout support
196
216
 
197
217
  Wrap your email content using layouts:
198
218
  ```ruby
@@ -235,7 +255,7 @@ end
235
255
  ```
236
256
 
237
257
 
238
- ### Auto-generate Text from HTML
258
+ ### Auto-generate text from HTML
239
259
 
240
260
  Automatically generate plain text versions from your HTML emails:
241
261
  ```ruby
@@ -243,7 +263,7 @@ config.auto_generate_text = true # Defaults to false
243
263
  ```
244
264
 
245
265
 
246
- ### Email Address Helper
266
+ ### Email address helper
247
267
 
248
268
  Compose email addresses with display names:
249
269
  ```ruby
@@ -270,16 +290,17 @@ end
270
290
  ```
271
291
 
272
292
 
273
- ### Logger Provider
293
+ ### Logger provider
274
294
 
275
295
  Use Ruby's built-in Logger for development and testing:
276
296
 
277
297
  ```ruby
278
- config.provider = "logger" # Outputs emails to STDOUT
279
- config.logger = custom_logger # Optional: defaults to ::Logger.new($stdout)
298
+ config.provider = "logger" # outputs emails to STDOUT
299
+ config.logger = custom_logger # optional: defaults to ::Logger.new($stdout)
280
300
  ```
281
301
 
282
- ### Custom Providers
302
+
303
+ ### Custom providers
283
304
 
284
305
  Create your own provider by inheriting from `Courrier::Email::Providers::Base`:
285
306
  ```ruby
@@ -300,6 +321,107 @@ config.provider = "CustomProvider"
300
321
  Check the [existing providers](https://github.com/Rails-Designer/courrier/tree/main/lib/courrier/email/providers) for implementation examples.
301
322
 
302
323
 
324
+ ## Newsletter subscriptions
325
+
326
+ Manage subscribers across popular email marketing platforms:
327
+ ```ruby
328
+ Courrier.configure do |config|
329
+ config.subscriber = {
330
+ provider: "buttondown",
331
+ api_key: "your_api_key"
332
+ }
333
+ end
334
+ ```
335
+
336
+ ```ruby
337
+ # Add a subscriber
338
+ subscriber = Courrier::Subscriber.create "subscriber@example.com"
339
+
340
+ # Remove a subscriber
341
+ subscriber = Courrier::Subscriber.destroy "subscriber@example.com"
342
+
343
+ if subscriber.success?
344
+ puts "Subscriber added!"
345
+ else
346
+ puts "Error: #{subscriber.error}"
347
+ end
348
+ ```
349
+
350
+
351
+ ### Supported providers
352
+
353
+ - [Beehiiv](https://www.beehiiv.com/) - requires `publication_id`
354
+ - [Buttondown](https://buttondown.com)
355
+ - [Kit](https://kit.com/) (formerly ConvertKit) - requires `form_id`
356
+ - [Loops](https://loops.so/)
357
+ - [Mailchimp](https://mailchimp.com/) - requires `dc` and `list_id`
358
+ - [MailerLite](https://www.mailerlite.com/)
359
+
360
+ Provider-specific configuration:
361
+ ```ruby
362
+ config.subscriber = {
363
+ provider: "mailchimp",
364
+ api_key: "your_api_key",
365
+ dc: "us19",
366
+ list_id: "abc123"
367
+ }
368
+ ```
369
+
370
+ ### Custom providers
371
+
372
+ Create custom providers by inheriting from `Courrier::Subscriber::Base`:
373
+ ```ruby
374
+ class CustomSubscriberProvider < Courrier::Subscriber::Base
375
+ ENDPOINT_URL = "https://api.example.com/subscribers"
376
+
377
+ def create(email)
378
+ request(:post, ENDPOINT_URL, {"email" => email})
379
+ end
380
+
381
+ def destroy(email)
382
+ request(:delete, "#{ENDPOINT_URL}/#{email}")
383
+ end
384
+
385
+ private
386
+
387
+ def headers
388
+ {
389
+ "Authorization" => "Bearer #{@api_key}",
390
+ "Content-Type" => "application/json"
391
+ }
392
+ end
393
+ end
394
+ ```
395
+
396
+ Then configure it:
397
+ ```ruby
398
+ config.subscriber = {
399
+ provider: CustomSubscriberProvider,
400
+ api_key: "your_api_key"
401
+ }
402
+ ```
403
+
404
+ See [existing providers](https://github.com/Rails-Designer/courrier/tree/main/lib/courrier/subscriber) for more examples.
405
+
406
+
407
+ ## FAQ
408
+
409
+ ### Is this a replacement for ActionMailer?
410
+ Yes! While different in approach, Courrier can fully replace ActionMailer. It's a modern alternative that focuses on API-based delivery. The main difference is in how emails are structured - Courrier uses a more straightforward, class-based approach.
411
+
412
+ ### Is this for Rails only?
413
+ Not at all! While Courrier has some Rails-specific goodies (like the inbox preview feature and generators), it works great with any Ruby application.
414
+
415
+ ### Can it send using SMTP?
416
+ No - Courrier is specifically built for API-based email delivery. If SMTP is needed, ActionMailer would be a better choices.
417
+
418
+ ### Can separate view templates be created (like ActionMailer)?
419
+ The approach is different here. Instead of separate view files, email content is defined right in the email class using `text` and `html` methods. Layouts can be used to share common templates. This makes emails more self-contained and easier to reason about.
420
+
421
+ ### What's the main benefit over ActionMailer?
422
+ Courrier offers a simpler, more modern approach to sending emails. Each email is a standalone class, configuration is straightforward (typically just only an API key is needed) and it packs few quality-of-life features (like the inbox feature and auto-generate text version).
423
+
424
+
303
425
  ## Contributing
304
426
 
305
427
  This project uses [Standard](https://github.com/testdouble/standard) for formatting Ruby code. Please make sure to run `rake` before submitting pull requests.
@@ -19,13 +19,15 @@ module Courrier
19
19
  end
20
20
 
21
21
  class Configuration
22
- attr_accessor :provider, :api_key, :logger, :email_path, :layouts, :default_url_options, :auto_generate_text,
22
+ attr_accessor :email, :subscriber, :logger, :email_path, :layouts, :default_url_options, :auto_generate_text,
23
23
  :from, :reply_to, :cc, :bcc
24
+
24
25
  attr_reader :providers, :inbox
25
26
 
26
27
  def initialize
27
- @provider = "logger"
28
- @api_key = nil
28
+ @email = {provider: "logger"}
29
+ @subscriber = {}
30
+
29
31
  @logger = ::Logger.new($stdout)
30
32
  @email_path = default_email_path
31
33
 
@@ -42,6 +44,30 @@ module Courrier
42
44
  @inbox = Courrier::Configuration::Inbox.new
43
45
  end
44
46
 
47
+ def provider
48
+ warn "[DEPRECATION] `provider` is deprecated. Use `email = { provider: '…' }` instead. Will be removed in 1.0.0"
49
+
50
+ @email[:provider]
51
+ end
52
+
53
+ def provider=(value)
54
+ warn "[DEPRECATION] `provider=` is deprecated. Use `email = { provider: '…' }` instead. Will be removed in 1.0.0"
55
+
56
+ @email[:provider] = value
57
+ end
58
+
59
+ def api_key
60
+ warn "[DEPRECATION] `api_key` is deprecated. Use `email = { api_key: '…' }` instead. Will be removed in 1.0.0"
61
+
62
+ @email[:api_key]
63
+ end
64
+
65
+ def api_key=(value)
66
+ warn "[DEPRECATION] `api_key=` is deprecated. Use `email = { api_key: '…' }` instead. Will be removed in 1.0.0"
67
+
68
+ @email[:api_key] = value
69
+ end
70
+
45
71
  private
46
72
 
47
73
  def default_email_path
@@ -31,6 +31,21 @@ module Courrier
31
31
 
32
32
  def html = wrap(@html, with_layout: :html)
33
33
 
34
+ def to_h
35
+ {
36
+ from: @from,
37
+ to: @to,
38
+ reply_to: @reply_to,
39
+ cc: @cc,
40
+ bcc: @bcc,
41
+ subject: @subject,
42
+ text: @text,
43
+ html: @html,
44
+ auto_generate_text: @auto_generate_text,
45
+ layouts: @layouts
46
+ }
47
+ end
48
+
34
49
  private
35
50
 
36
51
  def wrap(content, with_layout:)
@@ -16,9 +16,24 @@ require "courrier/email/providers/userlist"
16
16
  module Courrier
17
17
  class Email
18
18
  class Provider
19
+ PROVIDERS = {
20
+ inbox: Courrier::Email::Providers::Inbox,
21
+ logger: Courrier::Email::Providers::Logger,
22
+ loops: Courrier::Email::Providers::Loops,
23
+ mailgun: Courrier::Email::Providers::Mailgun,
24
+ mailjet: Courrier::Email::Providers::Mailjet,
25
+ mailpace: Courrier::Email::Providers::Mailpace,
26
+ postmark: Courrier::Email::Providers::Postmark,
27
+ resend: Courrier::Email::Providers::Resend,
28
+ sendgrid: Courrier::Email::Providers::Sendgrid,
29
+ sparkpost: Courrier::Email::Providers::Sparkpost,
30
+ userlist: Courrier::Email::Providers::Userlist
31
+ }
32
+
19
33
  def initialize(provider: nil, api_key: nil, options: {}, provider_options: {}, context_options: {})
20
34
  @provider = provider
21
35
  @api_key = api_key
36
+
22
37
  @options = options
23
38
  @provider_options = provider_options
24
39
  @context_options = context_options
@@ -26,7 +41,7 @@ module Courrier
26
41
 
27
42
  def deliver
28
43
  raise Courrier::ConfigurationError, "`provider` and `api_key` must be configured for production environment" if configuration_missing_in_production?
29
- raise Courrier::ConfigurationError, "Unknown provider. Choose one of `#{comma_separated_providers}` or provide your own." if @provider.empty? || @provider.nil?
44
+ raise Courrier::ConfigurationError, "Unknown provider. Choose one of `#{comma_separated_providers}` or provide your own." if @provider.nil? || @provider.to_s.strip.empty?
30
45
 
31
46
  provider_class.new(
32
47
  api_key: @api_key,
@@ -38,20 +53,6 @@ module Courrier
38
53
 
39
54
  private
40
55
 
41
- PROVIDERS = {
42
- inbox: Courrier::Email::Providers::Inbox,
43
- logger: Courrier::Email::Providers::Logger,
44
- loops: Courrier::Email::Providers::Loops,
45
- mailgun: Courrier::Email::Providers::Mailgun,
46
- mailjet: Courrier::Email::Providers::Mailjet,
47
- mailpace: Courrier::Email::Providers::Mailpace,
48
- postmark: Courrier::Email::Providers::Postmark,
49
- resend: Courrier::Email::Providers::Resend,
50
- sendgrid: Courrier::Email::Providers::Sendgrid,
51
- sparkpost: Courrier::Email::Providers::Sparkpost,
52
- userlist: Courrier::Email::Providers::Userlist
53
- }.freeze
54
-
55
56
  def configuration_missing_in_production?
56
57
  production? && required_attributes_blank?
57
58
  end
@@ -39,7 +39,7 @@ module Courrier
39
39
  end
40
40
 
41
41
  def prepare(content)
42
- content.to_s.gsub(URI::DEFAULT_PARSER.make_regexp(%w[http https])) do |url|
42
+ content.to_s.gsub(URL_PARSER.make_regexp(%w[http https])) do |url|
43
43
  %(<a href="#{url}">#{url}</a>)
44
44
  end
45
45
  end
@@ -58,6 +58,10 @@ module Courrier
58
58
  "available at #{path}"
59
59
  end
60
60
 
61
+ URL_PARSER = (
62
+ defined?(URI::RFC2396_PARSER) ? URI::RFC2396_PARSER : URI::DEFAULT_PARSER
63
+ )
64
+
61
65
  class Email < Data.define(:path, :filename, :metadata)
62
66
  Metadata = Data.define(:to, :subject)
63
67
 
@@ -21,9 +21,7 @@ module Courrier
21
21
  <<~EMAIL
22
22
  #{separator}
23
23
  Timestamp: #{Time.now.strftime("%Y-%m-%d %H:%M:%S %z")}
24
- From: #{@options.from}
25
- To: #{@options.to}
26
- Subject: #{@options.subject}
24
+ #{meta_fields(from: options)}
27
25
 
28
26
  Text:
29
27
  #{@options.text || "(empty)"}
@@ -35,6 +33,27 @@ module Courrier
35
33
  end
36
34
 
37
35
  def separator = "-" * 80
36
+
37
+ def meta_fields(from:)
38
+ fields = [
39
+ [:from, "From"],
40
+ [:to, "To"],
41
+ [:reply_to, "Reply-To"],
42
+ [:cc, "Cc"],
43
+ [:bcc, "Bcc"],
44
+ [:subject, "Subject"]
45
+ ]
46
+
47
+ fields.map do |field, label|
48
+ value = from.send(field)
49
+
50
+ next if value.nil? || value.to_s.strip.empty?
51
+
52
+ "#{label}:".ljust(11) + value
53
+ rescue NoMatchingPatternError
54
+ nil
55
+ end.compact.join("\n")
56
+ end
38
57
  end
39
58
  end
40
59
  end
@@ -33,10 +33,17 @@ module Courrier
33
33
  end
34
34
  end
35
35
 
36
- def text_document
36
+ def provider_options
37
+ {"theme" => nil}.merge(@provider_options)
38
+ end
39
+
40
+ def multipart_document
37
41
  {
38
- "type" => "text",
39
- "content" => @options.text
42
+ "type" => "multipart",
43
+ "content" => [
44
+ html_document,
45
+ text_document
46
+ ]
40
47
  }
41
48
  end
42
49
 
@@ -47,19 +54,12 @@ module Courrier
47
54
  }
48
55
  end
49
56
 
50
- def multipart_document
57
+ def text_document
51
58
  {
52
- "type" => "multipart",
53
- "content" => [
54
- html_document,
55
- text_document
56
- ]
59
+ "type" => "text",
60
+ "content" => @options.text
57
61
  }
58
62
  end
59
-
60
- def provider_options
61
- {"theme" => nil}.merge(@provider_options)
62
- end
63
63
  end
64
64
  end
65
65
  end
@@ -1,20 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "courrier/email/address"
4
+ require "courrier/jobs/email_delivery_job" if defined?(Rails)
4
5
  require "courrier/email/layouts"
5
6
  require "courrier/email/options"
6
7
  require "courrier/email/provider"
7
8
 
8
9
  module Courrier
9
10
  class Email
10
- attr_accessor :provider, :api_key, :default_url_options, :options
11
+ attr_accessor :provider, :api_key, :default_url_options, :options, :queue_options
12
+
13
+ @queue_options = {}
11
14
 
12
15
  class << self
13
16
  %w[provider api_key from reply_to cc bcc layouts default_url_options].each do |attribute|
14
17
  define_method(attribute) do
15
18
  instance_variable_get("@#{attribute}") ||
16
19
  (superclass.respond_to?(attribute) ? superclass.send(attribute) : nil) ||
17
- Courrier.configuration&.send(attribute)
20
+ (["provider", "api_key"].include?(attribute) ? Courrier.configuration&.email&.[](attribute.to_sym) : Courrier.configuration&.send(attribute))
18
21
  end
19
22
 
20
23
  define_method("#{attribute}=") do |value|
@@ -22,20 +25,35 @@ module Courrier
22
25
  end
23
26
  end
24
27
 
25
- def deliver(options = {})
26
- new(options).deliver_now
27
- end
28
- alias_method :deliver_now, :deliver
29
-
30
28
  def configure(**options)
31
29
  options.each { |key, value| send("#{key}=", value) if respond_to?("#{key}=") }
32
30
  end
33
31
  alias_method :set, :configure
34
32
 
35
- def layout(options = {})
33
+ def queue_options
34
+ @queue_options ||= {}
35
+ end
36
+
37
+ attr_writer :queue_options
38
+
39
+ def enqueue(**options)
40
+ self.queue_options = options
41
+ end
42
+ alias_method :enqueue_with, :enqueue
43
+
44
+ def layout(**options)
36
45
  self.layouts = options
37
46
  end
38
47
 
48
+ def deliver(**options)
49
+ new(options).deliver_now
50
+ end
51
+ alias_method :deliver_now, :deliver
52
+
53
+ def deliver_later(**options)
54
+ new(options).deliver_later
55
+ end
56
+
39
57
  def inherited(subclass)
40
58
  super
41
59
 
@@ -48,8 +66,8 @@ module Courrier
48
66
  end
49
67
 
50
68
  def initialize(options = {})
51
- @provider = options[:provider] || ENV["COURRIER_PROVIDER"] || self.class.provider || Courrier.configuration&.provider
52
- @api_key = options[:api_key] || ENV["COURRIER_API_KEY"] || self.class.api_key || Courrier.configuration&.api_key
69
+ @provider = options[:provider] || ENV["COURRIER_PROVIDER"] || self.class.provider || Courrier.configuration&.email&.[](:provider)
70
+ @api_key = options[:api_key] || ENV["COURRIER_API_KEY"] || self.class.api_key || Courrier.configuration&.email&.[](:api_key)
53
71
 
54
72
  @default_url_options = self.class.default_url_options.merge(options[:default_url_options] || {})
55
73
  @context_options = options.except(:provider, :api_key, :from, :to, :reply_to, :cc, :bcc, :subject, :text, :html)
@@ -85,6 +103,30 @@ module Courrier
85
103
  end
86
104
  alias_method :deliver_now, :deliver
87
105
 
106
+ def deliver_later
107
+ if delivery_disabled?
108
+ Courrier.configuration&.logger&.info "[Courrier] Email delivery skipped: delivery is disabled via environment variable"
109
+
110
+ return nil
111
+ end
112
+
113
+ data = {
114
+ email_class: self.class.name,
115
+ provider: @provider,
116
+ api_key: @api_key,
117
+ options: @options.to_h,
118
+ provider_options: Courrier.configuration&.providers&.[](@provider.to_s.downcase.to_sym),
119
+ context_options: @context_options
120
+ }
121
+
122
+ job = Courrier::Jobs::EmailDeliveryJob
123
+ job = job.set(**self.class.queue_options) if self.class.queue_options.any?
124
+
125
+ job.perform_later(data)
126
+ rescue => error
127
+ raise Courrier::BackgroundDeliveryError, "Failed to enqueue email: #{error.message}"
128
+ end
129
+
88
130
  private
89
131
 
90
132
  def delivery_disabled?
@@ -3,9 +3,11 @@
3
3
  module Courrier
4
4
  class Error < StandardError; end
5
5
 
6
+ class ConfigurationError < Error; end
7
+
6
8
  class ArgumentError < ::ArgumentError; end
7
9
 
8
10
  class NotImplementedError < ::NotImplementedError; end
9
11
 
10
- class ConfigurationError < Error; end
12
+ class BackgroundDeliveryError < StandardError; end
11
13
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Courrier
4
+ module Jobs
5
+ class EmailDeliveryJob < ActiveJob::Base
6
+ def perform(data)
7
+ email_class = data[:email_class].constantize
8
+
9
+ email_class.new(
10
+ provider: data[:provider],
11
+ api_key: data[:api_key],
12
+ from: data[:options][:from],
13
+ to: data[:options][:to],
14
+ reply_to: data[:options][:reply_to],
15
+ cc: data[:options][:cc],
16
+ bcc: data[:options][:bcc],
17
+ provider_options: data[:provider_options],
18
+ context_options: data[:context_options]
19
+ ).deliver_now
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "courrier/subscriber/result"
4
+
5
+ module Courrier
6
+ class Subscriber
7
+ class Base
8
+ def initialize(api_key:)
9
+ @api_key = api_key
10
+ end
11
+
12
+ def create(email)
13
+ raise NotImplementedError
14
+ end
15
+
16
+ def destroy(email)
17
+ raise NotImplementedError
18
+ end
19
+
20
+ private
21
+
22
+ def request(method, url, body = nil)
23
+ uri = URI(url)
24
+ request_class = case method
25
+ when :post then Net::HTTP::Post
26
+ when :delete then Net::HTTP::Delete
27
+ when :put then Net::HTTP::Put
28
+ when :patch then Net::HTTP::Patch
29
+ when :get then Net::HTTP::Get
30
+ end
31
+
32
+ request = request_class.new(uri)
33
+ request.body = body.to_json if body
34
+
35
+ headers.each { |key, value| request[key] = value }
36
+
37
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
38
+ http.request(request)
39
+ end
40
+
41
+ Courrier::Subscriber::Result.new(response: response)
42
+ rescue => error
43
+ Courrier::Subscriber::Result.new(error: error)
44
+ end
45
+
46
+ def headers
47
+ raise NotImplementedError
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "courrier/subscriber/base"
4
+
5
+ module Courrier
6
+ class Subscriber
7
+ class Beehiiv < Base
8
+ ENDPOINT_URL = "https://api.beehiiv.com/v2/publications"
9
+
10
+ def create(email)
11
+ publication_id = Courrier.configuration.subscriber[:publication_id]
12
+ raise Courrier::ConfigurationError, "Beehiiv requires `publication_id` in subscriber configuration" unless publication_id
13
+
14
+ request(:post, "#{ENDPOINT_URL}/#{publication_id}/subscriptions", {"email" => email})
15
+ end
16
+
17
+ def destroy(email)
18
+ publication_id = Courrier.configuration.subscriber[:publication_id]
19
+ raise Courrier::ConfigurationError, "Beehiiv requires `publication_id` in subscriber configuration" unless publication_id
20
+
21
+ subscription_id = subscription_id(publication_id, email)
22
+ return Courrier::Subscriber::Result.new(error: StandardError.new("Subscription not found")) unless subscription_id
23
+
24
+ request(:delete, "#{ENDPOINT_URL}/#{publication_id}/subscriptions/#{subscription_id}")
25
+ end
26
+
27
+ private
28
+
29
+ def subscription_id(publication_id, email)
30
+ response = request(:get, "#{ENDPOINT_URL}/#{publication_id}/subscriptions?email=#{email}")
31
+
32
+ return nil unless response.success?
33
+
34
+ response.data.dig("data", 0, "id")
35
+ end
36
+
37
+ def headers
38
+ {
39
+ "Authorization" => "Bearer #{@api_key}",
40
+ "Content-Type" => "application/json"
41
+ }
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "courrier/subscriber/base"
4
+
5
+ module Courrier
6
+ class Subscriber
7
+ class Buttondown < Base
8
+ ENDPOINT_URL = "https://api.buttondown.email/v1/subscribers"
9
+
10
+ def create(email)
11
+ request(:post, ENDPOINT_URL, {"email" => email})
12
+ end
13
+
14
+ def destroy(email)
15
+ request(:delete, "#{ENDPOINT_URL}/#{email}")
16
+ end
17
+
18
+ private
19
+
20
+ def headers
21
+ {
22
+ "Authorization" => "Token #{@api_key}",
23
+ "Content-Type" => "application/json"
24
+ }
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "courrier/subscriber/base"
4
+
5
+ module Courrier
6
+ class Subscriber
7
+ class Kit < Base
8
+ ENDPOINT_URL = "https://api.convertkit.com/v3/forms"
9
+
10
+ def create(email)
11
+ form_id = Courrier.configuration.subscriber[:form_id]
12
+ raise Courrier::ConfigurationError, "Kit requires `form_id` in subscriber configuration" unless form_id
13
+
14
+ request(:post, "#{ENDPOINT_URL}/#{form_id}/subscribe", {
15
+ "api_key" => @api_key,
16
+ "email" => email
17
+ })
18
+ end
19
+
20
+ def destroy(email)
21
+ request(:put, "https://api.convertkit.com/v3/unsubscribe", {
22
+ "api_secret" => @api_key,
23
+ "email" => email
24
+ })
25
+ end
26
+
27
+ private
28
+
29
+ def headers
30
+ {
31
+ "Content-Type" => "application/json"
32
+ }
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "courrier/subscriber/base"
4
+
5
+ module Courrier
6
+ class Subscriber
7
+ class Loops < Base
8
+ ENDPOINT_URL = "https://app.loops.so/api/v1/contacts"
9
+
10
+ def create(email)
11
+ request(:post, "#{ENDPOINT_URL}/create", {
12
+ "email" => email
13
+ })
14
+ end
15
+
16
+ def destroy(email)
17
+ request(:post, "#{ENDPOINT_URL}/delete", {
18
+ "email" => email
19
+ })
20
+ end
21
+
22
+ private
23
+
24
+ def headers
25
+ {
26
+ "Authorization" => "Bearer #{@api_key}",
27
+ "Content-Type" => "application/json"
28
+ }
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "courrier/subscriber/base"
4
+
5
+ module Courrier
6
+ class Subscriber
7
+ class Mailchimp < Base
8
+ def create(email)
9
+ dc = Courrier.configuration.subscriber[:dc]
10
+ list_id = Courrier.configuration.subscriber[:list_id]
11
+
12
+ raise Courrier::ConfigurationError, "Mailchimp requires `dc` and `list_id` in subscriber configuration" unless dc && list_id
13
+
14
+ request(:post, "https://#{dc}.api.mailchimp.com/3.0/lists/#{list_id}/members", {
15
+ "email_address" => email,
16
+ "status" => "subscribed"
17
+ })
18
+ end
19
+
20
+ def destroy(email)
21
+ dc = Courrier.configuration.subscriber[:dc]
22
+ list_id = Courrier.configuration.subscriber[:list_id]
23
+
24
+ raise Courrier::ConfigurationError, "Mailchimp requires `dc` and `list_id` in subscriber configuration" unless dc && list_id
25
+
26
+ request(:delete, "https://#{dc}.api.mailchimp.com/3.0/lists/#{list_id}/members/#{email}")
27
+ end
28
+
29
+ private
30
+
31
+ def headers
32
+ {
33
+ "Authorization" => "Bearer #{@api_key}",
34
+ "Content-Type" => "application/json"
35
+ }
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "courrier/subscriber/base"
4
+
5
+ module Courrier
6
+ class Subscriber
7
+ class Mailerlite < Base
8
+ ENDPOINT_URL = "https://connect.mailerlite.com/api/subscribers"
9
+
10
+ def create(email)
11
+ request(:post, ENDPOINT_URL, {"email" => email})
12
+ end
13
+
14
+ def destroy(email)
15
+ request(:delete, "#{ENDPOINT_URL}/#{email}")
16
+ end
17
+
18
+ private
19
+
20
+ def headers
21
+ {
22
+ "Authorization" => "Bearer #{@api_key}",
23
+ "Content-Type" => "application/json"
24
+ }
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Courrier
4
+ class Subscriber
5
+ class Result
6
+ attr_reader :success, :response, :data, :error
7
+
8
+ def initialize(response: nil, error: nil)
9
+ @response = response
10
+ @error = error
11
+ @data = parsed(@response&.body)
12
+ @success = successful?
13
+ end
14
+
15
+ def success? = @success
16
+
17
+ private
18
+
19
+ def parsed(body)
20
+ return {} if @response.nil?
21
+
22
+ begin
23
+ JSON.parse(body)
24
+ rescue JSON::ParserError
25
+ {}
26
+ end
27
+ end
28
+
29
+ def successful?
30
+ return false if response_failed?
31
+ return @data["success"] if @data.key?("success")
32
+
33
+ (200..299).cover?(status_code)
34
+ end
35
+
36
+ def response_failed? = @error || @response.nil?
37
+
38
+ def status_code = @response.code.to_i
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Courrier
4
+ class Subscriber
5
+ class << self
6
+ def create(email)
7
+ provider.create(email)
8
+ end
9
+ alias_method :add, :create
10
+
11
+ def destroy(email)
12
+ provider.destroy(email)
13
+ end
14
+ alias_method :delete, :destroy
15
+
16
+ private
17
+
18
+ def provider
19
+ @provider ||= provider_class.new(
20
+ api_key: Courrier.configuration.subscriber[:api_key]
21
+ )
22
+ end
23
+
24
+ def provider_class
25
+ provider_name = Courrier.configuration.subscriber[:provider]
26
+
27
+ return provider_name if provider_name.is_a?(Class)
28
+ require "courrier/subscriber/#{provider_name}"
29
+
30
+ Object.const_get("Courrier::Subscriber::#{provider_name.capitalize}")
31
+ end
32
+ end
33
+ end
34
+ end
@@ -1,3 +1,3 @@
1
1
  module Courrier
2
- VERSION = "0.7.0"
2
+ VERSION = "0.8.0"
3
3
  end
data/lib/courrier.rb CHANGED
@@ -4,6 +4,7 @@ require "courrier/version"
4
4
  require "courrier/errors"
5
5
  require "courrier/configuration"
6
6
  require "courrier/email"
7
+ require "courrier/subscriber"
7
8
  require "courrier/engine" if defined?(Rails)
8
9
  require "courrier/railtie" if defined?(Rails)
9
10
 
@@ -1,5 +1,7 @@
1
1
  module Courrier
2
2
  class EmailGenerator < Rails::Generators::NamedBase
3
+ AVAILABLE_TEMPLATES = %w[welcome password_reset]
4
+
3
5
  desc "Create a new Courrier Email class"
4
6
 
5
7
  source_root File.expand_path("templates", __dir__)
@@ -7,13 +9,34 @@ module Courrier
7
9
  check_class_collision suffix: "Email"
8
10
 
9
11
  class_option :skip_suffix, type: :boolean, default: false
12
+ class_option :template, type: :string, desc: "Template type (#{AVAILABLE_TEMPLATES.join(", ")})"
10
13
 
11
14
  def copy_mailer_file
12
- template "email.rb", File.join(Courrier.configuration.email_path, class_path, "#{file_name}#{options[:skip_suffix] ? "" : "_email"}.rb")
15
+ template template_file, destination_path
13
16
  end
14
17
 
15
18
  private
16
19
 
20
+ def file_name = super.delete_suffix("_email")
21
+
17
22
  def parent_class = defined?(ApplicationEmail) ? ApplicationEmail : Courrier::Email
23
+
24
+ def template_file
25
+ if options[:template] && template_exists?("email/#{options[:template]}.rb.tt")
26
+ "email/#{options[:template]}.rb.tt"
27
+ else
28
+ "email.rb.tt"
29
+ end
30
+ end
31
+
32
+ def destination_path
33
+ File.join(Courrier.configuration.email_path, class_path, "#{file_name}#{options[:skip_suffix] ? "" : "_email"}.rb")
34
+ end
35
+
36
+ def template_exists?(path)
37
+ find_in_source_paths(path)
38
+ rescue
39
+ nil
40
+ end
18
41
  end
19
42
  end
@@ -0,0 +1,29 @@
1
+ # Usage:
2
+ #
3
+ # PasswordResetEmail.deliver to: user.email, reset_url: edit_password_url(user.password_reset_token)
4
+ #
5
+ class <%= class_name %><%= options[:skip_suffix] ? "" : "Email" %> < <%= parent_class %>
6
+ def subject = "Reset your password"
7
+
8
+ def text
9
+ <<~TEXT
10
+ You can reset your password within the next 15 minutes on this password reset page:
11
+ #{reset_url}
12
+
13
+ If you didn't request a password reset, please ignore this email.
14
+ TEXT
15
+ end
16
+
17
+ def html
18
+ <<~HTML
19
+ <p>
20
+ You can reset your password within the next 15 minutes on
21
+ <a href="#{reset_url}">this password reset page</a>.
22
+ </p>
23
+
24
+ <p>
25
+ If you didn't request a password reset, please ignore this email.
26
+ </p>
27
+ HTML
28
+ end
29
+ end
@@ -0,0 +1,43 @@
1
+ # Usage:
2
+ #
3
+ # WelcomeEmail.deliver to: user.email, name: "John", login_url: "https://example.com/login"
4
+ #
5
+ class <%= class_name %><%= options[:skip_suffix] ? "" : "Email" %> < <%= parent_class %>
6
+ def subject = "Welcome to My First App, #{name}!"
7
+
8
+ def text
9
+ <<~TEXT
10
+ Welcome, #{name}!
11
+
12
+ We're excited to have you on board. Here's how to get started:
13
+
14
+ 1. Log in to your account: #{login_url}
15
+ 2. Complete your profile
16
+ 3. Explore our features
17
+
18
+ If you have any questions, our help center is available to assist you.
19
+
20
+ Thanks for joining us!
21
+ TEXT
22
+ end
23
+
24
+ def html
25
+ <<~HTML
26
+ <p>Welcome, #{name}!</p>
27
+
28
+ <p>We're excited to have you on board. Here's how to get started:</p>
29
+
30
+ <ol>
31
+ <li><a href="#{login_url}">Log in to your account</a></li>
32
+ <li>Complete your profile</li>
33
+ <li>Explore our features</li>
34
+ </ol>
35
+
36
+ <p>If you have any questions, our help center is available to assist you.</p>
37
+
38
+ <p>
39
+ Thanks for joining us!
40
+ </p>
41
+ HTML
42
+ end
43
+ end
@@ -1,12 +1,17 @@
1
1
  Courrier.configure do |config|
2
2
  include Courrier::Email::Address
3
3
 
4
- # Choose your email delivery provider
5
- # Default: `logger`
6
- # config.provider = ""
4
+ # Set your email delivery provider
5
+ # config.email = {
6
+ # provider = "", # default, `logger`, choose from: <%= Courrier::Email::Provider::PROVIDERS.keys.join(", ") %>
7
+ # api_key = "" your transactional email provider's API key
8
+ }
9
+
10
+ # Set your marketing email provider
11
+ # config.subscriber = {
12
+ # provider = ""
13
+ }
7
14
 
8
- # Add your email provider's API key
9
- # config.api_key = ""
10
15
 
11
16
  # Configure provider-specific settings
12
17
  # config.providers.loops.transactional_id = ""
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: courrier
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rails Designer
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-05-13 00:00:00.000000000 Z
11
+ date: 2025-12-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: launchy
@@ -97,12 +97,24 @@ files:
97
97
  - lib/courrier/email/transformer.rb
98
98
  - lib/courrier/engine.rb
99
99
  - lib/courrier/errors.rb
100
+ - lib/courrier/jobs/email_delivery_job.rb
100
101
  - lib/courrier/railtie.rb
102
+ - lib/courrier/subscriber.rb
103
+ - lib/courrier/subscriber/base.rb
104
+ - lib/courrier/subscriber/beehiiv.rb
105
+ - lib/courrier/subscriber/buttondown.rb
106
+ - lib/courrier/subscriber/kit.rb
107
+ - lib/courrier/subscriber/loops.rb
108
+ - lib/courrier/subscriber/mailchimp.rb
109
+ - lib/courrier/subscriber/mailerlite.rb
110
+ - lib/courrier/subscriber/result.rb
101
111
  - lib/courrier/tasks/courrier.rake
102
112
  - lib/courrier/version.rb
103
113
  - lib/generators/courrier/email_generator.rb
104
114
  - lib/generators/courrier/install_generator.rb
105
115
  - lib/generators/courrier/templates/email.rb.tt
116
+ - lib/generators/courrier/templates/email/password_reset.rb.tt
117
+ - lib/generators/courrier/templates/email/welcome.rb.tt
106
118
  - lib/generators/courrier/templates/initializer.rb.tt
107
119
  homepage: https://railsdesigner.com/courrier/
108
120
  licenses: