courrier 0.5.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 +7 -0
  2. data/Gemfile +15 -0
  3. data/Gemfile.lock +109 -0
  4. data/README.md +243 -0
  5. data/Rakefile +9 -0
  6. data/bin/console +11 -0
  7. data/bin/release +19 -0
  8. data/bin/setup +8 -0
  9. data/courrier.gemspec +25 -0
  10. data/lib/courrier/configuration/preview.rb +23 -0
  11. data/lib/courrier/configuration/providers.rb +41 -0
  12. data/lib/courrier/configuration.rb +53 -0
  13. data/lib/courrier/email/address.rb +37 -0
  14. data/lib/courrier/email/layouts.rb +40 -0
  15. data/lib/courrier/email/options.rb +55 -0
  16. data/lib/courrier/email/provider.rb +68 -0
  17. data/lib/courrier/email/providers/base.rb +39 -0
  18. data/lib/courrier/email/providers/logger.rb +41 -0
  19. data/lib/courrier/email/providers/loops.rb +36 -0
  20. data/lib/courrier/email/providers/mailgun.rb +36 -0
  21. data/lib/courrier/email/providers/mailjet.rb +50 -0
  22. data/lib/courrier/email/providers/mailpace.rb +32 -0
  23. data/lib/courrier/email/providers/postmark.rb +34 -0
  24. data/lib/courrier/email/providers/preview/default.html.erb +119 -0
  25. data/lib/courrier/email/providers/preview.rb +51 -0
  26. data/lib/courrier/email/providers/sendgrid.rb +57 -0
  27. data/lib/courrier/email/providers/sparkpost.rb +38 -0
  28. data/lib/courrier/email/request.rb +73 -0
  29. data/lib/courrier/email/result.rb +43 -0
  30. data/lib/courrier/email/transformer.rb +46 -0
  31. data/lib/courrier/email.rb +97 -0
  32. data/lib/courrier/errors.rb +11 -0
  33. data/lib/courrier/railtie.rb +23 -0
  34. data/lib/courrier/tasks/courrier.rake +13 -0
  35. data/lib/courrier/version.rb +3 -0
  36. data/lib/courrier.rb +10 -0
  37. data/lib/generators/courrier/email_generator.rb +19 -0
  38. data/lib/generators/courrier/install_generator.rb +11 -0
  39. data/lib/generators/courrier/templates/email.rb.tt +15 -0
  40. data/lib/generators/courrier/templates/initializer.rb.tt +38 -0
  41. metadata +125 -0
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "courrier/email/transformer"
4
+
5
+ module Courrier
6
+ class Email
7
+ class Options
8
+ attr_reader :from, :to, :reply_to, :cc, :bcc, :subject
9
+
10
+ def initialize(options = {})
11
+ @from = options.fetch(:from, nil)
12
+
13
+ @to = options.fetch(:to, nil)
14
+ @reply_to = options.fetch(:reply_to, nil)
15
+ @cc = options.fetch(:cc, nil)
16
+ @bcc = options.fetch(:bcc, nil)
17
+
18
+ @subject = options.fetch(:subject, "")
19
+ @text = options.fetch(:text, "")
20
+ @html = options.fetch(:html, "")
21
+
22
+ @auto_generate_text = options.fetch(:auto_generate_text, false)
23
+
24
+ @layouts = Array(options[:layouts])
25
+
26
+ raise Courrier::ArgumentError, "Recipient (`to`) is required" unless @to
27
+ raise Courrier::ArgumentError, "Sender (`from`) is required" unless @from
28
+ end
29
+
30
+ def text = wrap(transformed_text, with_layout: :text)
31
+
32
+ def html = wrap(@html, with_layout: :html)
33
+
34
+ private
35
+
36
+ def wrap(content, with_layout:)
37
+ return content if content.nil? || content.empty?
38
+
39
+ @layouts.reduce(content) do |wrapped, layout_options|
40
+ layout = layout_options[with_layout]
41
+
42
+ next wrapped if !layout
43
+
44
+ layout % {content: wrapped}
45
+ end
46
+ end
47
+
48
+ def transformed_text
49
+ return Courrier::Email::Transformer.new(@html).to_text if @auto_generate_text
50
+
51
+ @text
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "courrier/email/providers/base"
4
+ require "courrier/email/providers/logger"
5
+ require "courrier/email/providers/loops"
6
+ require "courrier/email/providers/mailgun"
7
+ require "courrier/email/providers/mailjet"
8
+ require "courrier/email/providers/mailpace"
9
+ require "courrier/email/providers/postmark"
10
+ require "courrier/email/providers/preview"
11
+ require "courrier/email/providers/sendgrid"
12
+ require "courrier/email/providers/sparkpost"
13
+
14
+ module Courrier
15
+ class Email
16
+ class Provider
17
+ def initialize(provider: nil, api_key: nil, options: {}, provider_options: {})
18
+ @provider = provider
19
+ @api_key = api_key
20
+ @options = options
21
+ @provider_options = provider_options
22
+ end
23
+
24
+ def deliver
25
+ raise Courrier::ConfigurationError, "`provider` and `api_key` must be configured for production environment" if configuration_missing_in_production?
26
+ raise Courrier::ConfigurationError, "Unknown provider. Choose one of `#{comma_separated_providers}` or provide your own." if @provider.empty? || @provider.nil?
27
+
28
+ provider_class.new(
29
+ api_key: @api_key,
30
+ options: @options,
31
+ provider_options: @provider_options
32
+ ).deliver
33
+ end
34
+
35
+ private
36
+
37
+ PROVIDERS = {
38
+ logger: Courrier::Email::Providers::Logger,
39
+ loops: Courrier::Email::Providers::Loops,
40
+ mailgun: Courrier::Email::Providers::Mailgun,
41
+ mailjet: Courrier::Email::Providers::Mailjet,
42
+ mailpace: Courrier::Email::Providers::Mailpace,
43
+ postmark: Courrier::Email::Providers::Postmark,
44
+ preview: Courrier::Email::Providers::Preview,
45
+ sendgrid: Courrier::Email::Providers::Sendgrid,
46
+ sparkpost: Courrier::Email::Providers::Sparkpost
47
+ }.freeze
48
+
49
+ def configuration_missing_in_production?
50
+ production? && required_attributes_blank?
51
+ end
52
+
53
+ def comma_separated_providers = PROVIDERS.keys.join(", ")
54
+
55
+ def provider_class
56
+ return PROVIDERS[@provider.to_sym] if PROVIDERS.key?(@provider.to_sym)
57
+
58
+ Object.const_get(@provider)
59
+ end
60
+
61
+ def required_attributes_blank? = @api_key.empty?
62
+
63
+ def production?
64
+ defined?(Rails) && Rails.env.production?
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "courrier/email/request"
4
+
5
+ module Courrier
6
+ class Email
7
+ module Providers
8
+ class Base
9
+ def initialize(api_key: nil, options: {}, provider_options: {})
10
+ @api_key = api_key
11
+ @options = options
12
+ @provider_options = provider_options
13
+ end
14
+
15
+ def deliver
16
+ Request.new(
17
+ endpoint_url: endpoint_url,
18
+ body: body,
19
+ provider: provider,
20
+ headers: headers,
21
+ content_type: content_type
22
+ ).post
23
+ end
24
+
25
+ def body = raise Courrier::NotImplementedError, "Provider class must implement `body`"
26
+
27
+ private
28
+
29
+ def endpoint_url = self.class::ENDPOINT_URL
30
+
31
+ def content_type = "application/json"
32
+
33
+ def headers = {}
34
+
35
+ def provider = self.class.name.split("::").last
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module Courrier
6
+ class Email
7
+ module Providers
8
+ class Logger < Base
9
+ def deliver
10
+ logger = Courrier.configuration.logger
11
+ logger.formatter = proc do |_severity, _datetime, _progname, message|
12
+ "#{format_email_using(message)}\n"
13
+ end
14
+
15
+ logger.info(@options)
16
+ end
17
+
18
+ private
19
+
20
+ def format_email_using(options)
21
+ <<~EMAIL
22
+ #{separator}
23
+ Timestamp: #{Time.now.strftime("%Y-%m-%d %H:%M:%S %z")}
24
+ From: #{@options.from}
25
+ To: #{@options.to}
26
+ Subject: #{@options.subject}
27
+
28
+ Text:
29
+ #{@options.text || "(empty)"}
30
+
31
+ HTML:
32
+ #{@options.html || "(empty)"}
33
+ #{separator}
34
+ EMAIL
35
+ end
36
+
37
+ def separator = "-" * 80
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Courrier
4
+ class Email
5
+ module Providers
6
+ class Loops < Base
7
+ ENDPOINT_URL = "https://app.loops.so/api/v1/transactional"
8
+
9
+ def body
10
+ {
11
+ "email" => @options.to,
12
+ "transactionalId" => @provider_options.transactional_id || raise(Courrier::ArgumentError, "Loops requires a `transactionalId`"),
13
+ "dataVariables" => data_variables
14
+ }.compact
15
+ end
16
+
17
+ private
18
+
19
+ def headers
20
+ {
21
+ "Authorization" => "Bearer #{@api_key}"
22
+ }
23
+ end
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
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Courrier
4
+ class Email
5
+ module Providers
6
+ class Mailgun < Base
7
+ ENDPOINT_URL = "https://api.mailgun.net/v3/%{domain}/messages"
8
+
9
+ def body
10
+ {
11
+ "from" => @options.from,
12
+
13
+ "to" => @options.to,
14
+ "h:Reply-To" => @options.reply_to,
15
+
16
+ "subject" => @options.subject,
17
+ "text" => @options.text,
18
+ "html" => @options.html
19
+ }.compact
20
+ end
21
+
22
+ private
23
+
24
+ def endpoint_url
25
+ domain = @provider_options.domain || raise(Courrier::ArgumentError, "Mailgun requires a `domain`")
26
+
27
+ ENDPOINT_URL % {domain: domain}
28
+ end
29
+
30
+ def content_type = "multipart/form-data"
31
+
32
+ def authentication_for(request) = request.basic_auth("api", @api_key)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Courrier
4
+ class Email
5
+ module Providers
6
+ class Mailjet < Base
7
+ ENDPOINT_URL = "https://api.mailjet.com/v3.1/send"
8
+
9
+ def body
10
+ {
11
+ "Messages" => [
12
+ {
13
+ "From" => {
14
+ "Email" => @options.from
15
+ },
16
+
17
+ "To" => [
18
+ {
19
+ "Email" => @options.to
20
+ }
21
+ ],
22
+ "ReplyTo" => reply_to_object,
23
+
24
+ "Subject" => @options.subject,
25
+ "TextPart" => @options.text,
26
+ "HTMLPart" => @options.html
27
+ }.compact
28
+ ]
29
+ }
30
+ end
31
+
32
+ private
33
+
34
+ def headers
35
+ {
36
+ "Authorization" => "Basic " + Base64.strict_encode64("#{@api_key}:#{@provider_options.api_secret}")
37
+ }
38
+ end
39
+
40
+ def reply_to_object
41
+ return if @options.reply_to.nil?
42
+
43
+ {
44
+ "Email" => @options.reply_to
45
+ }
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Courrier
4
+ class Email
5
+ module Providers
6
+ class Mailpace < Base
7
+ ENDPOINT_URL = "https://app.mailpace.com/api/v1/send"
8
+
9
+ def body
10
+ {
11
+ "from" => @options.from,
12
+
13
+ "to" => @options.to,
14
+ "replyto" => @options.reply_to,
15
+
16
+ "subject" => @options.subject,
17
+ "textbody" => @options.text,
18
+ "htmlbody" => @options.html
19
+ }.compact
20
+ end
21
+
22
+ private
23
+
24
+ def headers
25
+ {
26
+ "MailPace-Server-Token" => @api_key
27
+ }
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Courrier
4
+ class Email
5
+ module Providers
6
+ class Postmark < Base
7
+ ENDPOINT_URL = "https://api.postmarkapp.com/email"
8
+
9
+ def body
10
+ {
11
+ "From" => @options.from,
12
+
13
+ "To" => @options.to,
14
+ "ReplyTo" => @options.reply_to,
15
+
16
+ "Subject" => @options.subject,
17
+ "TextBody" => @options.text,
18
+ "HtmlBody" => @options.html,
19
+
20
+ "MessageStream" => @provider_options.message_stream || "outbound"
21
+ }.compact
22
+ end
23
+
24
+ private
25
+
26
+ def headers
27
+ {
28
+ "X-Postmark-Server-Token" => @api_key
29
+ }
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,119 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title><%= @options.subject %></title>
6
+ <style>
7
+ *, *::before, *::after { box-sizing: border-box; }
8
+ * { 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); }
10
+ pre { margin: 0; white-space: pre-wrap; }
11
+ .email {
12
+ width: 100%;
13
+ max-width: 65rem;
14
+ margin-left: auto; margin-right: auto;
15
+ background-color: #fff;
16
+ 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;
18
+ }
19
+
20
+ .email__header { display: flex; align-items: center; padding: 1rem .75rem; -moz-column-gap: .75rem; column-gap: .75rem; }
21
+
22
+ .email__avatar {
23
+ flex-shrink: 0;
24
+ padding: .25rem;
25
+ width: 2.5rem; height: 2.5rem;
26
+ background-color: rgb(203, 213, 225);
27
+ color: rgb(51, 65, 85);
28
+ border-radius: 9999px;
29
+ }
30
+
31
+ .email__recipient { flex-grow: 1; font-size: .875rem; line-height: 1.25rem; }
32
+
33
+ .email__recipient-name { font-weight: 600; color: rgb(51, 65, 85); }
34
+
35
+ .email__metadata { display: flex; align-items: center; justify-content: space-between; }
36
+
37
+ .email__recipient-email { color: rgb(100, 116, 139); }
38
+
39
+ .email__datetime { font-size: .75rem; line-height: 1rem; color: rgb(148, 163, 184); }
40
+
41
+ .email__subject {
42
+ margin-left: 4rem;
43
+ font-size: 1.25rem; line-height: 1.75rem;
44
+ font-weight: 700;
45
+ letter-spacing: -.025em;
46
+ color: rgb(30, 41, 59)
47
+ }
48
+
49
+ .email__preview { margin-top: .75rem; margin-left: 4rem; padding: .5rem .25rem; }
50
+
51
+ .preview-toggle { display: none; }
52
+ .preview-toggle-label {
53
+ display: inline-block;
54
+ padding: .25rem .875rem;
55
+ font-size: .75rem; line-height: 1rem;
56
+ font-weight: 600;
57
+ color: rgb(30, 41, 59);
58
+ background-color: rgb(255, 255, 255);
59
+ border: 1px solid rgb(226, 232, 240);
60
+ border-radius: .375rem;
61
+ box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
62
+ cursor: pointer;
63
+
64
+ &:hover { border-color: rgb(203, 213, 225); }
65
+ &:active { transform: scale(.98); }
66
+ }
67
+ .preview-toggle[disabled] + .preview-toggle-label { opacity: 0.5; cursor: not-allowed; }
68
+
69
+ .email__preview-html, .email__preview-text { margin-top: 1.5rem;}
70
+ .email__preview-html { display: none; }
71
+ .preview-toggle:checked ~ .email__preview-text { display: none; }
72
+ .preview-toggle:checked ~ .email__preview-html { display: block; }
73
+ </style>
74
+ </head>
75
+ <body>
76
+ <article class="email">
77
+ <header class="email__header">
78
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" data-slot="icon" class="email__avatar">
79
+ <path fill-rule="evenodd" d="M7.5 6a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM3.751 20.105a8.25 8.25 0 0 1 16.498 0 .75.75 0 0 1-.437.695A18.683 18.683 0 0 1 12 22.5c-2.786 0-5.433-.608-7.812-1.7a.75.75 0 0 1-.437-.695Z" clip-rule="evenodd"/>
80
+ </svg>
81
+
82
+ <div class="email__recipient">
83
+ <% if name %>
84
+ <p class="email__recipient-name">
85
+ <%= name %>
86
+ </p>
87
+ <% end %>
88
+
89
+ <div class="email__metadata">
90
+ <p class="email__recipient-email">
91
+ <%= email %>
92
+ </p>
93
+
94
+ <time class="email__datetime" datetime="#{Time.now.strftime("%Y-%m-%d %H:%M:%S %z")}">
95
+ <%= Time.now.strftime("%Y-%m-%d %H:%M:%S %z") %>
96
+ </time>
97
+ </div>
98
+ </div>
99
+ </header>
100
+
101
+ <p class="email__subject">
102
+ <%= @options.subject %>
103
+ </p>
104
+
105
+ <div class="email__preview">
106
+ <input type="checkbox" id="preview-toggle" class="preview-toggle" <%= html ? "" : "disabled" %>>
107
+ <label for="preview-toggle" class="preview-toggle-label">Toggle HTML/Text</label>
108
+
109
+ <div class="email__preview-text">
110
+ <pre><%= text || "(no text content)" %></pre>
111
+ </div>
112
+
113
+ <div class="email__preview-html">
114
+ <%= html || "<p>(no HTML content)</p>" %>
115
+ </div>
116
+ </div>
117
+ </article>
118
+ </body>
119
+ </html>
@@ -0,0 +1,51 @@
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 Preview < 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)
19
+
20
+ "Preview email saved to #{file_path}#{config.auto_open ? " and opened in browser" : ""}"
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(URI::DEFAULT_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.preview
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Courrier
4
+ class Email
5
+ module Providers
6
+ class Sendgrid < Base
7
+ ENDPOINT_URL = "https://api.sendgrid.com/v3/mail/send"
8
+
9
+ def body
10
+ {
11
+ "from" => {
12
+ "email" => @options.from
13
+ },
14
+ "personalizations" => [
15
+ {
16
+ "to" => [
17
+ {
18
+ "email" => @options.to
19
+ }
20
+ ]
21
+ }
22
+ ],
23
+ "reply_to" => reply_to_object,
24
+
25
+ "subject" => @options.subject,
26
+ "content" => [
27
+ {
28
+ "type" => "text/plain",
29
+ "value" => @options.text
30
+ },
31
+ {
32
+ "type" => "text/html",
33
+ "value" => @options.html
34
+ }
35
+ ].compact
36
+ }.compact
37
+ end
38
+
39
+ private
40
+
41
+ def headers
42
+ {
43
+ "Authorization" => "Bearer #{@api_key}"
44
+ }
45
+ end
46
+
47
+ def reply_to_object
48
+ return if @options.reply_to.nil?
49
+
50
+ {
51
+ "email" => @options.reply_to
52
+ }
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Courrier
4
+ class Email
5
+ module Providers
6
+ class Sparkpost < Base
7
+ ENDPOINT_URL = "https://api.sparkpost.com/api/v1/transmissions"
8
+
9
+ def body
10
+ {
11
+ "content" => {
12
+ "reply_to" => @options.reply_to,
13
+ "from" => @options.from,
14
+ "subject" => @options.subject,
15
+ "text" => @options.text,
16
+ "html" => @options.html
17
+ }.compact,
18
+ "recipients" => [
19
+ {
20
+ "address" => {
21
+ "email" => @options.to
22
+ }
23
+ }
24
+ ]
25
+ }
26
+ end
27
+
28
+ private
29
+
30
+ def headers
31
+ {
32
+ "Authorization" => @api_key
33
+ }
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end