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.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -1
- data/README.md +159 -26
- data/app/controllers/courrier/previews/cleanups_controller.rb +13 -0
- data/app/controllers/courrier/previews_controller.rb +23 -0
- data/app/views/courrier/previews/index.html.erb +171 -0
- data/config/routes.rb +8 -0
- data/courrier.gemspec +3 -3
- data/lib/courrier/configuration/{preview.rb → inbox.rb} +3 -3
- data/lib/courrier/configuration.rb +32 -6
- data/lib/courrier/email/options.rb +15 -0
- data/lib/courrier/email/provider.rb +22 -17
- data/lib/courrier/email/providers/base.rb +2 -1
- data/lib/courrier/email/providers/{preview → inbox}/default.html.erb +13 -6
- data/lib/courrier/email/providers/inbox.rb +83 -0
- data/lib/courrier/email/providers/logger.rb +22 -3
- data/lib/courrier/email/providers/loops.rb +1 -8
- data/lib/courrier/email/providers/resend.rb +32 -0
- data/lib/courrier/email/providers/userlist.rb +13 -13
- data/lib/courrier/email.rb +54 -11
- data/lib/courrier/engine.rb +7 -0
- data/lib/courrier/errors.rb +3 -1
- data/lib/courrier/jobs/email_delivery_job.rb +23 -0
- data/lib/courrier/subscriber/base.rb +51 -0
- data/lib/courrier/subscriber/beehiiv.rb +45 -0
- data/lib/courrier/subscriber/buttondown.rb +28 -0
- data/lib/courrier/subscriber/kit.rb +36 -0
- data/lib/courrier/subscriber/loops.rb +32 -0
- data/lib/courrier/subscriber/mailchimp.rb +39 -0
- data/lib/courrier/subscriber/mailerlite.rb +28 -0
- data/lib/courrier/subscriber/result.rb +41 -0
- data/lib/courrier/subscriber.rb +34 -0
- data/lib/courrier/tasks/courrier.rake +2 -2
- data/lib/courrier/version.rb +1 -1
- data/lib/courrier.rb +2 -0
- data/lib/generators/courrier/email_generator.rb +24 -1
- data/lib/generators/courrier/templates/email/password_reset.rb.tt +29 -0
- data/lib/generators/courrier/templates/email/welcome.rb.tt +43 -0
- data/lib/generators/courrier/templates/initializer.rb.tt +10 -5
- metadata +26 -8
- 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/
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
36
|
+
def provider_options
|
|
37
|
+
{"theme" => nil}.merge(@provider_options)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def multipart_document
|
|
37
41
|
{
|
|
38
|
-
"type" => "
|
|
39
|
-
"content" =>
|
|
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
|
|
57
|
+
def text_document
|
|
51
58
|
{
|
|
52
|
-
"type" => "
|
|
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
|
data/lib/courrier/email.rb
CHANGED
|
@@ -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
|
|
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?
|
data/lib/courrier/errors.rb
CHANGED
|
@@ -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
|
|
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
|