courrier 0.6.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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/README.md +159 -26
  4. data/app/controllers/courrier/previews/cleanups_controller.rb +13 -0
  5. data/app/controllers/courrier/previews_controller.rb +23 -0
  6. data/app/views/courrier/previews/index.html.erb +171 -0
  7. data/config/routes.rb +8 -0
  8. data/courrier.gemspec +3 -3
  9. data/lib/courrier/configuration/{preview.rb → inbox.rb} +3 -3
  10. data/lib/courrier/configuration.rb +32 -6
  11. data/lib/courrier/email/options.rb +15 -0
  12. data/lib/courrier/email/provider.rb +22 -17
  13. data/lib/courrier/email/providers/base.rb +2 -1
  14. data/lib/courrier/email/providers/{preview → inbox}/default.html.erb +13 -6
  15. data/lib/courrier/email/providers/inbox.rb +83 -0
  16. data/lib/courrier/email/providers/logger.rb +22 -3
  17. data/lib/courrier/email/providers/loops.rb +1 -8
  18. data/lib/courrier/email/providers/resend.rb +32 -0
  19. data/lib/courrier/email/providers/userlist.rb +13 -13
  20. data/lib/courrier/email.rb +54 -11
  21. data/lib/courrier/engine.rb +7 -0
  22. data/lib/courrier/errors.rb +3 -1
  23. data/lib/courrier/jobs/email_delivery_job.rb +23 -0
  24. data/lib/courrier/subscriber/base.rb +51 -0
  25. data/lib/courrier/subscriber/beehiiv.rb +45 -0
  26. data/lib/courrier/subscriber/buttondown.rb +28 -0
  27. data/lib/courrier/subscriber/kit.rb +36 -0
  28. data/lib/courrier/subscriber/loops.rb +32 -0
  29. data/lib/courrier/subscriber/mailchimp.rb +39 -0
  30. data/lib/courrier/subscriber/mailerlite.rb +28 -0
  31. data/lib/courrier/subscriber/result.rb +41 -0
  32. data/lib/courrier/subscriber.rb +34 -0
  33. data/lib/courrier/tasks/courrier.rake +2 -2
  34. data/lib/courrier/version.rb +1 -1
  35. data/lib/courrier.rb +2 -0
  36. data/lib/generators/courrier/email_generator.rb +24 -1
  37. data/lib/generators/courrier/templates/email/password_reset.rb.tt +29 -0
  38. data/lib/generators/courrier/templates/email/welcome.rb.tt +43 -0
  39. data/lib/generators/courrier/templates/initializer.rb.tt +10 -5
  40. metadata +26 -8
  41. data/lib/courrier/email/providers/preview.rb +0 -51
@@ -1,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "courrier/email/providers/base"
4
+ require "courrier/email/providers/inbox"
4
5
  require "courrier/email/providers/logger"
5
6
  require "courrier/email/providers/loops"
6
7
  require "courrier/email/providers/mailgun"
7
8
  require "courrier/email/providers/mailjet"
8
9
  require "courrier/email/providers/mailpace"
9
10
  require "courrier/email/providers/postmark"
10
- require "courrier/email/providers/preview"
11
+ require "courrier/email/providers/resend"
11
12
  require "courrier/email/providers/sendgrid"
12
13
  require "courrier/email/providers/sparkpost"
13
14
  require "courrier/email/providers/userlist"
@@ -15,39 +16,43 @@ require "courrier/email/providers/userlist"
15
16
  module Courrier
16
17
  class Email
17
18
  class Provider
18
- def initialize(provider: nil, api_key: nil, options: {}, provider_options: {})
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
+
33
+ def initialize(provider: nil, api_key: nil, options: {}, provider_options: {}, context_options: {})
19
34
  @provider = provider
20
35
  @api_key = api_key
36
+
21
37
  @options = options
22
38
  @provider_options = provider_options
39
+ @context_options = context_options
23
40
  end
24
41
 
25
42
  def deliver
26
43
  raise Courrier::ConfigurationError, "`provider` and `api_key` must be configured for production environment" if configuration_missing_in_production?
27
- 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?
28
45
 
29
46
  provider_class.new(
30
47
  api_key: @api_key,
31
48
  options: @options,
32
- provider_options: @provider_options
49
+ provider_options: @provider_options,
50
+ context_options: @context_options
33
51
  ).deliver
34
52
  end
35
53
 
36
54
  private
37
55
 
38
- PROVIDERS = {
39
- logger: Courrier::Email::Providers::Logger,
40
- loops: Courrier::Email::Providers::Loops,
41
- mailgun: Courrier::Email::Providers::Mailgun,
42
- mailjet: Courrier::Email::Providers::Mailjet,
43
- mailpace: Courrier::Email::Providers::Mailpace,
44
- postmark: Courrier::Email::Providers::Postmark,
45
- preview: Courrier::Email::Providers::Preview,
46
- sendgrid: Courrier::Email::Providers::Sendgrid,
47
- sparkpost: Courrier::Email::Providers::Sparkpost,
48
- userlist: Courrier::Email::Providers::Userlist
49
- }.freeze
50
-
51
56
  def configuration_missing_in_production?
52
57
  production? && required_attributes_blank?
53
58
  end
@@ -6,10 +6,11 @@ module Courrier
6
6
  class Email
7
7
  module Providers
8
8
  class Base
9
- def initialize(api_key: nil, options: {}, provider_options: {})
9
+ def initialize(api_key: nil, options: {}, provider_options: {}, context_options: {})
10
10
  @api_key = api_key
11
11
  @options = options
12
12
  @provider_options = provider_options
13
+ @context_options = context_options
13
14
  end
14
15
 
15
16
  def deliver
@@ -1,3 +1,10 @@
1
+ <!--
2
+ {
3
+ "to": "<%= email %>",
4
+ "subject": "<%= @options.subject %>"
5
+ }
6
+ -->
7
+
1
8
  <!DOCTYPE html>
2
9
  <html>
3
10
  <head>
@@ -6,7 +13,7 @@
6
13
  <style>
7
14
  *, *::before, *::after { box-sizing: border-box; }
8
15
  * { margin: 0; padding: 0; }
9
- body { margin: 0; padding: .5rem; line-height: 1.5; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; background-color: rgb(241, 245, 249); }
16
+ body { margin: 0; padding: 0; line-height: 1.5; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; background-color: rgb(241, 245, 249); }
10
17
  pre { margin: 0; white-space: pre-wrap; }
11
18
  .email {
12
19
  width: 100%;
@@ -14,15 +21,15 @@
14
21
  margin-left: auto; margin-right: auto;
15
22
  background-color: #fff;
16
23
  box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
17
- border-radius: 1rem;
24
+ border-bottom-right-radius: 1rem; border-bottom-left-radius: 1rem;
18
25
  }
19
26
 
20
- .email__header { display: flex; align-items: center; padding: 1rem .75rem; -moz-column-gap: .75rem; column-gap: .75rem; }
27
+ .email__header { display: flex; align-items: center; padding: .75rem; -moz-column-gap: .75rem; column-gap: .75rem; }
21
28
 
22
29
  .email__avatar {
23
30
  flex-shrink: 0;
24
31
  padding: .25rem;
25
- width: 2.5rem; height: 2.5rem;
32
+ width: 2rem; height: 2rem;
26
33
  background-color: rgb(203, 213, 225);
27
34
  color: rgb(51, 65, 85);
28
35
  border-radius: 9999px;
@@ -39,14 +46,14 @@
39
46
  .email__datetime { font-size: .75rem; line-height: 1rem; color: rgb(148, 163, 184); }
40
47
 
41
48
  .email__subject {
42
- margin-left: 4rem;
49
+ margin-left: 3.5rem;
43
50
  font-size: 1.25rem; line-height: 1.75rem;
44
51
  font-weight: 700;
45
52
  letter-spacing: -.025em;
46
53
  color: rgb(30, 41, 59)
47
54
  }
48
55
 
49
- .email__preview { margin-top: .75rem; margin-left: 4rem; padding: .5rem .25rem; }
56
+ .email__preview { margin-top: .75rem; margin-left: 3.5rem; padding-bottom: 1rem; }
50
57
 
51
58
  .preview-toggle { display: none; }
52
59
  .preview-toggle-label {
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+ require "fileutils"
5
+ require "launchy"
6
+
7
+ module Courrier
8
+ class Email
9
+ module Providers
10
+ class Inbox < Base
11
+ def deliver
12
+ FileUtils.mkdir_p(config.destination)
13
+
14
+ file_path = File.join(config.destination, "#{Time.now.to_i}.html")
15
+
16
+ File.write(file_path, ERB.new(File.read(config.template_path)).result(binding))
17
+
18
+ Launchy.open(file_path) if config.auto_open
19
+
20
+ "📮 Email saved to #{file_path} and #{email_destination}"
21
+ end
22
+
23
+ def name = extract(@options.to)[:name]
24
+
25
+ def email = extract(@options.to)[:email]
26
+
27
+ def text = prepare(@options.text)
28
+
29
+ def html = prepare(@options.html)
30
+
31
+ private
32
+
33
+ def extract(to)
34
+ if to.to_s =~ /(.*?)\s*<(.+?)>/
35
+ {name: $1.strip, email: $2.strip}
36
+ else
37
+ {name: nil, email: to.to_s.strip}
38
+ end
39
+ end
40
+
41
+ def prepare(content)
42
+ content.to_s.gsub(URL_PARSER.make_regexp(%w[http https])) do |url|
43
+ %(<a href="#{url}">#{url}</a>)
44
+ end
45
+ end
46
+
47
+ def config = @config ||= Courrier.configuration.inbox
48
+
49
+ def email_destination
50
+ return "opened in your default browser" if config.auto_open
51
+
52
+ path = begin
53
+ Rails.application.routes.url_helpers.courrier_path
54
+ rescue
55
+ "/courrier/ (Note: Add `mount Courrier::Engine => \"/courrier\"` to your routes.rb to enable proper routing)"
56
+ end
57
+
58
+ "available at #{path}"
59
+ end
60
+
61
+ URL_PARSER = (
62
+ defined?(URI::RFC2396_PARSER) ? URI::RFC2396_PARSER : URI::DEFAULT_PARSER
63
+ )
64
+
65
+ class Email < Data.define(:path, :filename, :metadata)
66
+ Metadata = Data.define(:to, :subject)
67
+
68
+ def self.from_file(path)
69
+ content = File.read(path)
70
+ json = content[/<!--\s*(.*?)\s*-->/m, 1]
71
+ metadata = JSON.parse(json, symbolize_names: true)
72
+
73
+ new(
74
+ path: path,
75
+ filename: File.basename(path),
76
+ metadata: Metadata.new(**metadata)
77
+ )
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -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
@@ -22,14 +22,7 @@ module Courrier
22
22
  }
23
23
  end
24
24
 
25
- def data_variables
26
- {
27
- "from" => @options.from,
28
- "subject" => @options.subject,
29
- "text_content" => @options.text,
30
- "html_content" => @options.html
31
- }.compact
32
- end
25
+ def data_variables = @context_options.compact
33
26
  end
34
27
  end
35
28
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Courrier
4
+ class Email
5
+ module Providers
6
+ class Resend < Base
7
+ ENDPOINT_URL = "https://api.resend.com/emails"
8
+
9
+ def body
10
+ {
11
+ "from" => @options.from,
12
+ "to" => @options.to,
13
+ "reply_to" => @options.reply_to,
14
+ "cc" => @options.cc,
15
+ "bcc" => @options.bcc,
16
+ "subject" => @options.subject,
17
+ "text" => @options.text,
18
+ "html" => @options.html
19
+ }.compact
20
+ end
21
+
22
+ private
23
+
24
+ def headers
25
+ {
26
+ "Authorization" => "Bearer #{@api_key}"
27
+ }
28
+ end
29
+ end
30
+ end
31
+ end
32
+ 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)
@@ -79,11 +97,36 @@ module Courrier
79
97
  provider: @provider,
80
98
  api_key: @api_key,
81
99
  options: @options,
82
- provider_options: Courrier.configuration&.providers&.[](@provider.to_s.downcase.to_sym)
100
+ provider_options: Courrier.configuration&.providers&.[](@provider.to_s.downcase.to_sym),
101
+ context_options: @context_options
83
102
  ).deliver
84
103
  end
85
104
  alias_method :deliver_now, :deliver
86
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
+
87
130
  private
88
131
 
89
132
  def delivery_disabled?
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Courrier
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Courrier
6
+ end
7
+ end
@@ -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