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.
- checksums.yaml +7 -0
- data/Gemfile +15 -0
- data/Gemfile.lock +109 -0
- data/README.md +243 -0
- data/Rakefile +9 -0
- data/bin/console +11 -0
- data/bin/release +19 -0
- data/bin/setup +8 -0
- data/courrier.gemspec +25 -0
- data/lib/courrier/configuration/preview.rb +23 -0
- data/lib/courrier/configuration/providers.rb +41 -0
- data/lib/courrier/configuration.rb +53 -0
- data/lib/courrier/email/address.rb +37 -0
- data/lib/courrier/email/layouts.rb +40 -0
- data/lib/courrier/email/options.rb +55 -0
- data/lib/courrier/email/provider.rb +68 -0
- data/lib/courrier/email/providers/base.rb +39 -0
- data/lib/courrier/email/providers/logger.rb +41 -0
- data/lib/courrier/email/providers/loops.rb +36 -0
- data/lib/courrier/email/providers/mailgun.rb +36 -0
- data/lib/courrier/email/providers/mailjet.rb +50 -0
- data/lib/courrier/email/providers/mailpace.rb +32 -0
- data/lib/courrier/email/providers/postmark.rb +34 -0
- data/lib/courrier/email/providers/preview/default.html.erb +119 -0
- data/lib/courrier/email/providers/preview.rb +51 -0
- data/lib/courrier/email/providers/sendgrid.rb +57 -0
- data/lib/courrier/email/providers/sparkpost.rb +38 -0
- data/lib/courrier/email/request.rb +73 -0
- data/lib/courrier/email/result.rb +43 -0
- data/lib/courrier/email/transformer.rb +46 -0
- data/lib/courrier/email.rb +97 -0
- data/lib/courrier/errors.rb +11 -0
- data/lib/courrier/railtie.rb +23 -0
- data/lib/courrier/tasks/courrier.rake +13 -0
- data/lib/courrier/version.rb +3 -0
- data/lib/courrier.rb +10 -0
- data/lib/generators/courrier/email_generator.rb +19 -0
- data/lib/generators/courrier/install_generator.rb +11 -0
- data/lib/generators/courrier/templates/email.rb.tt +15 -0
- data/lib/generators/courrier/templates/initializer.rb.tt +38 -0
- metadata +125 -0
@@ -0,0 +1,73 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
require "net/http"
|
5
|
+
require "courrier/email/result"
|
6
|
+
|
7
|
+
module Courrier
|
8
|
+
class Email
|
9
|
+
class Request
|
10
|
+
def initialize(endpoint_url:, body:, provider:, headers: {}, content_type: "application/json")
|
11
|
+
@endpoint_url = endpoint_url
|
12
|
+
@body = body
|
13
|
+
@provider = provider
|
14
|
+
@headers = headers
|
15
|
+
@content_type = content_type
|
16
|
+
end
|
17
|
+
|
18
|
+
def post
|
19
|
+
uri = URI.parse(@endpoint_url)
|
20
|
+
request = Net::HTTP::Post.new(uri)
|
21
|
+
|
22
|
+
headers_for request
|
23
|
+
body_for request
|
24
|
+
|
25
|
+
options = {use_ssl: uri.scheme == "https"}
|
26
|
+
|
27
|
+
begin
|
28
|
+
response = Net::HTTP.start(uri.hostname, uri.port, options) { _1.request(request) }
|
29
|
+
|
30
|
+
Result.new(response: response)
|
31
|
+
rescue => error
|
32
|
+
Result.new(error: error)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def headers_for(request)
|
39
|
+
default_headers.merge(@headers).each do |key, value|
|
40
|
+
request[key] = value
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def body_for(request)
|
45
|
+
if requires_multipart_form?
|
46
|
+
set_multipart_form(request)
|
47
|
+
else
|
48
|
+
set_json_body(request)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def default_headers
|
53
|
+
{
|
54
|
+
"Content-Type" => @content_type,
|
55
|
+
"Accept" => "application/json"
|
56
|
+
}
|
57
|
+
end
|
58
|
+
|
59
|
+
def requires_multipart_form?
|
60
|
+
["Mailgun"].include?(@provider)
|
61
|
+
end
|
62
|
+
|
63
|
+
def set_multipart_form(request)
|
64
|
+
request.set_form(@body, "multipart/form-data")
|
65
|
+
end
|
66
|
+
|
67
|
+
def set_json_body(request)
|
68
|
+
request.content_type = "application/json"
|
69
|
+
request.body = JSON.dump(@body)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
|
5
|
+
module Courrier
|
6
|
+
class Email
|
7
|
+
class Result
|
8
|
+
attr_reader :success, :response, :data, :error
|
9
|
+
|
10
|
+
def initialize(response: nil, error: nil)
|
11
|
+
@response = response
|
12
|
+
@error = error
|
13
|
+
@data = parse_body(@response&.body)
|
14
|
+
@success = successful?
|
15
|
+
end
|
16
|
+
|
17
|
+
def success? = @success
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def parse_body(body)
|
22
|
+
return {} if @response.nil?
|
23
|
+
|
24
|
+
begin
|
25
|
+
JSON.parse(body)
|
26
|
+
rescue JSON::ParserError
|
27
|
+
{}
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def successful?
|
32
|
+
return false if response_failed?
|
33
|
+
return @data["success"] if @data.key?("success")
|
34
|
+
|
35
|
+
(200..299).cover?(status_code)
|
36
|
+
end
|
37
|
+
|
38
|
+
def response_failed? = @error || @response.nil?
|
39
|
+
|
40
|
+
def status_code = @response.code.to_i
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "nokogiri"
|
4
|
+
|
5
|
+
module Courrier
|
6
|
+
class Email
|
7
|
+
class Transformer
|
8
|
+
def initialize(content)
|
9
|
+
@content = content
|
10
|
+
end
|
11
|
+
|
12
|
+
def to_text
|
13
|
+
Nokogiri::HTML(@content)
|
14
|
+
.then { remove_unwanted_elements(_1) }
|
15
|
+
.then { process_links(_1) }
|
16
|
+
.then { preserve_line_breaks(_1) }
|
17
|
+
.then { clean_up(_1) }
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
BLOCK_ELEMENTS = %w[p div h1 h2 h3 h4 h5 h6 pre blockquote li]
|
23
|
+
|
24
|
+
def remove_unwanted_elements(html) = html.tap { _1.css("script, style").remove }
|
25
|
+
|
26
|
+
def process_links(html)
|
27
|
+
html.tap do |document|
|
28
|
+
document.css("a")
|
29
|
+
.select { valid?(_1) }
|
30
|
+
.reject { _1.text.strip.empty? || _1.text.strip == _1["href"] }
|
31
|
+
.each { _1.content = "#{_1.text.strip} (#{_1["href"]})" }
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def preserve_line_breaks(html)
|
36
|
+
html.tap do |document|
|
37
|
+
document.css(BLOCK_ELEMENTS.join(",")).each { _1.after("\n") }
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def clean_up(html) = html.text.strip.gsub(/ *\n */m, "\n").squeeze(" \n")
|
42
|
+
|
43
|
+
def valid?(link) = link["href"] && !link["href"].start_with?("#")
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "courrier/email/address"
|
4
|
+
require "courrier/email/layouts"
|
5
|
+
require "courrier/email/options"
|
6
|
+
require "courrier/email/provider"
|
7
|
+
|
8
|
+
module Courrier
|
9
|
+
class Email
|
10
|
+
attr_accessor :provider, :api_key, :default_url_options, :options
|
11
|
+
|
12
|
+
class << self
|
13
|
+
%w[provider api_key from reply_to cc bcc layouts default_url_options].each do |attribute|
|
14
|
+
define_method(attribute) do
|
15
|
+
instance_variable_get("@#{attribute}") ||
|
16
|
+
(superclass.respond_to?(attribute) ? superclass.send(attribute) : nil) ||
|
17
|
+
Courrier.configuration&.send(attribute)
|
18
|
+
end
|
19
|
+
|
20
|
+
define_method("#{attribute}=") do |value|
|
21
|
+
instance_variable_set("@#{attribute}", value)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def deliver(options = {})
|
26
|
+
new(options).deliver_now
|
27
|
+
end
|
28
|
+
alias_method :deliver_now, :deliver
|
29
|
+
|
30
|
+
def configure(**options)
|
31
|
+
options.each { |key, value| send("#{key}=", value) if respond_to?("#{key}=") }
|
32
|
+
end
|
33
|
+
alias_method :set, :configure
|
34
|
+
|
35
|
+
def layout(options = {})
|
36
|
+
self.layouts = options
|
37
|
+
end
|
38
|
+
|
39
|
+
def inherited(subclass)
|
40
|
+
super
|
41
|
+
|
42
|
+
# If you read this and know how to move this Rails-specific logic somewhere
|
43
|
+
# else, e.g. `lib/courrier/railtie.rb`, open a PR ❤️
|
44
|
+
if defined?(Rails) && Rails.application
|
45
|
+
subclass.include Rails.application.routes.url_helpers
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def initialize(options = {})
|
51
|
+
@provider = options[:provider] || ENV["COURRIER_PROVIDER"] || self.class.provider || Courrier.configuration&.provider
|
52
|
+
@api_key = options[:api_key] || ENV["COURRIER_PROVIDER"] || self.class.api_key || Courrier.configuration&.api_key
|
53
|
+
|
54
|
+
@default_url_options = self.class.default_url_options.merge(options[:default_url_options] || {})
|
55
|
+
@context_options = options.except(:provider, :api_key, :from, :to, :reply_to, :cc, :bcc, :subject, :text, :html)
|
56
|
+
@options = Email::Options.new(
|
57
|
+
options.merge(
|
58
|
+
from: options[:from] || self.class.from || Courrier.configuration&.from,
|
59
|
+
reply_to: options[:reply_to] || self.class.reply_to || Courrier.configuration&.reply_to,
|
60
|
+
cc: options[:cc] || self.class.cc || Courrier.configuration&.cc,
|
61
|
+
bcc: options[:bcc] || self.class.bcc || Courrier.configuration&.bcc,
|
62
|
+
subject: subject,
|
63
|
+
text: text,
|
64
|
+
html: html,
|
65
|
+
auto_generate_text: Courrier.configuration&.auto_generate_text,
|
66
|
+
layouts: Courrier::Email::Layouts.new(self).build
|
67
|
+
)
|
68
|
+
)
|
69
|
+
end
|
70
|
+
|
71
|
+
def deliver
|
72
|
+
if delivery_disabled?
|
73
|
+
Courrier.configuration&.logger&.info "[Courrier] Email delivery skipped: delivery is disabled via environment variable"
|
74
|
+
|
75
|
+
return nil
|
76
|
+
end
|
77
|
+
|
78
|
+
Provider.new(
|
79
|
+
provider: @provider,
|
80
|
+
api_key: @api_key,
|
81
|
+
options: @options,
|
82
|
+
provider_options: Courrier.configuration&.providers&.[](@provider.to_s.downcase.to_sym)&.to_h
|
83
|
+
).deliver
|
84
|
+
end
|
85
|
+
alias_method :deliver_now, :deliver
|
86
|
+
|
87
|
+
private
|
88
|
+
|
89
|
+
def delivery_disabled?
|
90
|
+
ENV["COURRIER_EMAIL_DISABLED"] == "true" || ENV["COURRIER_EMAIL_ENABLED"] == "false"
|
91
|
+
end
|
92
|
+
|
93
|
+
def method_missing(name, *) = @context_options[name]
|
94
|
+
|
95
|
+
def respond_to_missing?(name, include_private = false) = true
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module Courrier
|
2
|
+
class Railtie < Rails::Railtie
|
3
|
+
config.after_initialize do
|
4
|
+
Courrier::Email.default_url_options = Courrier.configuration.default_url_options
|
5
|
+
end
|
6
|
+
|
7
|
+
ActiveSupport.on_load(:action_view) do
|
8
|
+
include Courrier::Email::Address
|
9
|
+
end
|
10
|
+
|
11
|
+
ActiveSupport.on_load(:action_controller) do
|
12
|
+
include Courrier::Email::Address
|
13
|
+
end
|
14
|
+
|
15
|
+
ActiveSupport.on_load(:active_job) do
|
16
|
+
include Courrier::Email::Address
|
17
|
+
end
|
18
|
+
|
19
|
+
rake_tasks do
|
20
|
+
load File.expand_path("../tasks/courrier.rake", __FILE__)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
namespace :tmp do
|
2
|
+
task :courrier do
|
3
|
+
rm_rf Dir["#{Courrier.configuration.preview.destination}/[^.]*"], verbose: false
|
4
|
+
end
|
5
|
+
|
6
|
+
task clear: :courrier
|
7
|
+
end
|
8
|
+
|
9
|
+
namespace :courrier do
|
10
|
+
desc "Clear preview email files from `#{Courrier.configuration.preview.destination}`"
|
11
|
+
|
12
|
+
task clear: "tmp:courrier"
|
13
|
+
end
|
data/lib/courrier.rb
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
module Courrier
|
2
|
+
class EmailGenerator < Rails::Generators::NamedBase
|
3
|
+
desc "Create a new Courrier Email class"
|
4
|
+
|
5
|
+
source_root File.expand_path("templates", __dir__)
|
6
|
+
|
7
|
+
check_class_collision suffix: "Email"
|
8
|
+
|
9
|
+
class_option :skip_suffix, type: :boolean, default: false
|
10
|
+
|
11
|
+
def copy_mailer_file
|
12
|
+
template "email.rb", File.join(Courrier.configuration.email_path, class_path, "#{file_name}#{options[:skip_suffix] ? "" : "_email"}.rb")
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def parent_class = defined?(ApplicationEmail) ? ApplicationEmail : Courrier::Email
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
module Courrier
|
2
|
+
class InstallGenerator < Rails::Generators::Base
|
3
|
+
desc "Creates the initializer for Courrier"
|
4
|
+
|
5
|
+
source_root File.expand_path("templates", __dir__)
|
6
|
+
|
7
|
+
def copy_initializer_file
|
8
|
+
template "initializer.rb", "config/initializers/courrier.rb"
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
class <%= class_name %><%= options[:skip_suffix] ? "" : "Email" %> < <%= parent_class %>
|
2
|
+
def subject = ""
|
3
|
+
|
4
|
+
# Create HTML and text emails using:
|
5
|
+
# https://railsdesigner.com/minimal-email-editor/
|
6
|
+
def text
|
7
|
+
<<~TEXT
|
8
|
+
TEXT
|
9
|
+
end
|
10
|
+
|
11
|
+
def html
|
12
|
+
<<~HTML
|
13
|
+
HTML
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
Courrier.configure do |config|
|
2
|
+
include Courrier::Email::Address
|
3
|
+
|
4
|
+
# Choose your email delivery provider
|
5
|
+
# Default: `logger`
|
6
|
+
# config.provider = ""
|
7
|
+
|
8
|
+
# Add your email provider's API key
|
9
|
+
# config.api_key = ""
|
10
|
+
|
11
|
+
# Configure provider-specific settings
|
12
|
+
# config.providers.loops.transactional_id = ""
|
13
|
+
# config.providers.mailgun.domain = ""
|
14
|
+
|
15
|
+
|
16
|
+
# Set default sender details
|
17
|
+
config.from = email_with_name("support@example.com", "Example Support") # => `Example Support <support@example.com>`
|
18
|
+
# config.reply_to = ""
|
19
|
+
# config.cc = ""
|
20
|
+
# config.bcc = ""
|
21
|
+
|
22
|
+
|
23
|
+
# Set host for Rails URL helpers, e.g. `{host: "https://railsdesigner.com/"}`
|
24
|
+
# config.default_url_options = {}
|
25
|
+
|
26
|
+
# Generate text version from HTML content
|
27
|
+
# Default: `false`
|
28
|
+
# config.auto_generate_text = false
|
29
|
+
|
30
|
+
# Location for generated Courrier Emails
|
31
|
+
# Default: `app/emails`
|
32
|
+
# config.email_path = ""
|
33
|
+
|
34
|
+
|
35
|
+
# Select logger for the `logger` provider
|
36
|
+
# Default: `::Logger.new($stdout)` - Ruby's built-in Logger
|
37
|
+
# config.logger = ""
|
38
|
+
end
|
metadata
ADDED
@@ -0,0 +1,125 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: courrier
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.5.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Rails Designer
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2025-05-09 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: launchy
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '3.1'
|
20
|
+
- - "<"
|
21
|
+
- !ruby/object:Gem::Version
|
22
|
+
version: '4'
|
23
|
+
type: :runtime
|
24
|
+
prerelease: false
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
26
|
+
requirements:
|
27
|
+
- - ">="
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: '3.1'
|
30
|
+
- - "<"
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '4'
|
33
|
+
- !ruby/object:Gem::Dependency
|
34
|
+
name: nokogiri
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - ">="
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: '1.18'
|
40
|
+
- - "<"
|
41
|
+
- !ruby/object:Gem::Version
|
42
|
+
version: '2'
|
43
|
+
type: :runtime
|
44
|
+
prerelease: false
|
45
|
+
version_requirements: !ruby/object:Gem::Requirement
|
46
|
+
requirements:
|
47
|
+
- - ">="
|
48
|
+
- !ruby/object:Gem::Version
|
49
|
+
version: '1.18'
|
50
|
+
- - "<"
|
51
|
+
- !ruby/object:Gem::Version
|
52
|
+
version: '2'
|
53
|
+
description: Modern, API-powered email delivery for Ruby apps with support for Postmark,
|
54
|
+
SendGrid, Mailgun and more.
|
55
|
+
email:
|
56
|
+
- devs@railsdesigner.com
|
57
|
+
executables: []
|
58
|
+
extensions: []
|
59
|
+
extra_rdoc_files: []
|
60
|
+
files:
|
61
|
+
- Gemfile
|
62
|
+
- Gemfile.lock
|
63
|
+
- README.md
|
64
|
+
- Rakefile
|
65
|
+
- bin/console
|
66
|
+
- bin/release
|
67
|
+
- bin/setup
|
68
|
+
- courrier.gemspec
|
69
|
+
- lib/courrier.rb
|
70
|
+
- lib/courrier/configuration.rb
|
71
|
+
- lib/courrier/configuration/preview.rb
|
72
|
+
- lib/courrier/configuration/providers.rb
|
73
|
+
- lib/courrier/email.rb
|
74
|
+
- lib/courrier/email/address.rb
|
75
|
+
- lib/courrier/email/layouts.rb
|
76
|
+
- lib/courrier/email/options.rb
|
77
|
+
- lib/courrier/email/provider.rb
|
78
|
+
- lib/courrier/email/providers/base.rb
|
79
|
+
- lib/courrier/email/providers/logger.rb
|
80
|
+
- lib/courrier/email/providers/loops.rb
|
81
|
+
- lib/courrier/email/providers/mailgun.rb
|
82
|
+
- lib/courrier/email/providers/mailjet.rb
|
83
|
+
- lib/courrier/email/providers/mailpace.rb
|
84
|
+
- lib/courrier/email/providers/postmark.rb
|
85
|
+
- lib/courrier/email/providers/preview.rb
|
86
|
+
- lib/courrier/email/providers/preview/default.html.erb
|
87
|
+
- lib/courrier/email/providers/sendgrid.rb
|
88
|
+
- lib/courrier/email/providers/sparkpost.rb
|
89
|
+
- lib/courrier/email/request.rb
|
90
|
+
- lib/courrier/email/result.rb
|
91
|
+
- lib/courrier/email/transformer.rb
|
92
|
+
- lib/courrier/errors.rb
|
93
|
+
- lib/courrier/railtie.rb
|
94
|
+
- lib/courrier/tasks/courrier.rake
|
95
|
+
- lib/courrier/version.rb
|
96
|
+
- lib/generators/courrier/email_generator.rb
|
97
|
+
- lib/generators/courrier/install_generator.rb
|
98
|
+
- lib/generators/courrier/templates/email.rb.tt
|
99
|
+
- lib/generators/courrier/templates/initializer.rb.tt
|
100
|
+
homepage: https://railsdesigner.com/courrier/
|
101
|
+
licenses:
|
102
|
+
- MIT
|
103
|
+
metadata:
|
104
|
+
homepage_uri: https://railsdesigner.com/courrier/
|
105
|
+
source_code_uri: https://github.com/Rails-Designer/courrier/
|
106
|
+
post_install_message:
|
107
|
+
rdoc_options: []
|
108
|
+
require_paths:
|
109
|
+
- lib
|
110
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
111
|
+
requirements:
|
112
|
+
- - ">="
|
113
|
+
- !ruby/object:Gem::Version
|
114
|
+
version: 3.2.0
|
115
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
116
|
+
requirements:
|
117
|
+
- - ">="
|
118
|
+
- !ruby/object:Gem::Version
|
119
|
+
version: '0'
|
120
|
+
requirements: []
|
121
|
+
rubygems_version: 3.4.1
|
122
|
+
signing_key:
|
123
|
+
specification_version: 4
|
124
|
+
summary: Modern, API-powered email delivery for Rails apps
|
125
|
+
test_files: []
|