mailtruss 0.1.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/LICENSE +9 -0
- data/README.md +23 -0
- data/lib/mailtruss/delivery_method.rb +60 -0
- data/lib/mailtruss/rails.rb +12 -0
- data/lib/mailtruss/version.rb +3 -0
- data/lib/mailtruss.rb +170 -0
- metadata +46 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0aefd75093db988be98ed2ee2a5649567f768ea1a5506306fec9cf13dce5e161
|
|
4
|
+
data.tar.gz: 552f058e0408b3d83845ddd2ed98fb363f8bb7a0c734e1a526e0dee4f4eb89ba
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 1ff4df3ef60ab13e0778b5c469459d126f33219431b0ec81f828191b368b3add7a8a657ab107ac35fdc7be81776924d5ccae693c2db44d7940081f235d0c02af
|
|
7
|
+
data.tar.gz: 890aeea078fbf44a6e0d4b875a10ee0618526656d622a9f554d884fb9c2c76a5e60b20918aadc2beb82e50c3ebe9f294cd08a0f9ac02646b9600a7dc19064c0f
|
data/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mailtruss
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# mailtruss (Ruby)
|
|
2
|
+
|
|
3
|
+
Ruby 3.0+ client for the [Mailtruss](https://mailtruss.com) email API, with a Rails ActionMailer delivery method. Standard library only.
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
client = Mailtruss::Client.new(ENV.fetch("MAILTRUSS_API_KEY"))
|
|
7
|
+
client.emails.send({ from: "hello@yourdomain.com", to: "ana@example.com", subject: "Welcome", html: "<p>Hi Ana</p>" }, idempotency_key: "welcome-ana")
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Resources: `emails`, `domains`, `api_keys`, `contacts`, `segments`, `topics`, `suppressions`, `webhooks`, `templates`, `broadcasts` (`deliver` sends one), `logs`, `inbound`. Errors are `Mailtruss::Error` (`name`, `status`, `request_id`).
|
|
11
|
+
|
|
12
|
+
## Rails
|
|
13
|
+
|
|
14
|
+
With the gem in your Gemfile, `:mailtruss` is registered as a delivery method:
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
config.action_mailer.delivery_method = :mailtruss
|
|
18
|
+
config.action_mailer.mailtruss_settings = { api_key: ENV["MAILTRUSS_API_KEY"] }
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Webhooks: `event = Mailtruss.verify_webhook(request.raw_post, request.headers, ENV.fetch("MAILTRUSS_WEBHOOK_SECRET"))`.
|
|
22
|
+
|
|
23
|
+
Tests: `ruby -Ilib test/test_mailtruss.rb` (needs the `mail` and `minitest` gems).
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
|
|
3
|
+
module Mailtruss
|
|
4
|
+
# ActionMailer / Mail delivery method that sends through the Mailtruss API.
|
|
5
|
+
# config.action_mailer.delivery_method = :mailtruss
|
|
6
|
+
# config.action_mailer.mailtruss_settings = { api_key: ENV["MAILTRUSS_API_KEY"] }
|
|
7
|
+
# Envelope recipients not named in To or Cc are sent as Bcc. The Message-ID becomes the idempotency key, so a retried job sends once.
|
|
8
|
+
class DeliveryMethod
|
|
9
|
+
attr_accessor :settings
|
|
10
|
+
|
|
11
|
+
def initialize(settings = {})
|
|
12
|
+
@settings = settings
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def deliver!(mail)
|
|
16
|
+
client = settings[:client] || Client.new(settings.fetch(:api_key), base_url: settings[:base_url])
|
|
17
|
+
res = client.emails.send(self.class.payload(mail), idempotency_key: "rails-#{Digest::SHA256.hexdigest(mail.message_id.to_s.empty? ? mail.encoded : mail.message_id)}")
|
|
18
|
+
mail.header["X-Mailtruss-Id"] = res["id"]
|
|
19
|
+
res
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
PASS = %w[in-reply-to references list-unsubscribe list-unsubscribe-post list-id].freeze
|
|
23
|
+
|
|
24
|
+
# The API request for one message.
|
|
25
|
+
def self.payload(mail)
|
|
26
|
+
down = ->(list) { Array(list).map { |a| a.to_s.downcase } }
|
|
27
|
+
rcpt = down.call(mail.smtp_envelope_to)
|
|
28
|
+
to = down.call(mail.to).select { |a| rcpt.include?(a) }
|
|
29
|
+
cc = down.call(mail.cc).select { |a| rcpt.include?(a) }
|
|
30
|
+
bcc = rcpt - down.call(mail.to) - down.call(mail.cc)
|
|
31
|
+
to << bcc.shift if to.empty? && cc.empty? && bcc.any?
|
|
32
|
+
from = mail[:from].formatted.first || mail.smtp_envelope_from
|
|
33
|
+
p = { from: from, to: to, subject: mail.subject.to_s }
|
|
34
|
+
p[:cc] = cc if cc.any?
|
|
35
|
+
p[:bcc] = bcc if bcc.any?
|
|
36
|
+
p[:reply_to] = Array(mail.reply_to) if mail.reply_to
|
|
37
|
+
html = mail.html_part ? mail.html_part.decoded : (mail.mime_type == "text/html" ? mail.decoded : nil)
|
|
38
|
+
text = mail.text_part ? mail.text_part.decoded : (mail.mime_type.nil? || mail.mime_type == "text/plain" ? (mail.multipart? ? nil : mail.decoded) : nil)
|
|
39
|
+
p[:html] = html if html
|
|
40
|
+
p[:text] = text if text
|
|
41
|
+
headers = {}
|
|
42
|
+
mail.header.fields.each do |f|
|
|
43
|
+
n = f.name.downcase
|
|
44
|
+
headers[f.name] = f.value.to_s if PASS.include?(n) || (n.start_with?("x-") && n != "x-mailtruss-tags")
|
|
45
|
+
end
|
|
46
|
+
p[:headers] = headers if headers.any?
|
|
47
|
+
if (tags = mail.header["X-Mailtruss-Tags"])
|
|
48
|
+
p[:tags] = tags.value.to_s.split(",").map { |pair| k, v = pair.strip.split("=", 2); { name: k, value: v } if k && v }.compact
|
|
49
|
+
end
|
|
50
|
+
if mail.attachments.any?
|
|
51
|
+
p[:attachments] = mail.attachments.map do |a|
|
|
52
|
+
att = { filename: a.filename, content: Base64.strict_encode64(a.decoded), content_type: a.mime_type }
|
|
53
|
+
att[:content_id] = a.content_id.to_s.delete("<>") if a.inline?
|
|
54
|
+
att
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
p
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
require_relative "delivery_method"
|
|
2
|
+
|
|
3
|
+
module Mailtruss
|
|
4
|
+
# Registers :mailtruss as an ActionMailer delivery method when Rails is loaded.
|
|
5
|
+
class Railtie < ::Rails::Railtie
|
|
6
|
+
initializer "mailtruss.add_delivery_method" do
|
|
7
|
+
ActiveSupport.on_load(:action_mailer) do
|
|
8
|
+
ActionMailer::Base.add_delivery_method :mailtruss, Mailtruss::DeliveryMethod, api_key: ENV["MAILTRUSS_API_KEY"]
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
data/lib/mailtruss.rb
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "base64"
|
|
5
|
+
require "uri"
|
|
6
|
+
require_relative "mailtruss/version"
|
|
7
|
+
|
|
8
|
+
# Ruby client for the Mailtruss email API.
|
|
9
|
+
#
|
|
10
|
+
# client = Mailtruss::Client.new(ENV.fetch("MAILTRUSS_API_KEY"))
|
|
11
|
+
# client.emails.send({ from: "hello@yourdomain.com", to: "ana@example.com", subject: "Hi", text: "Hello" }, idempotency_key: "welcome-ana")
|
|
12
|
+
module Mailtruss
|
|
13
|
+
# Any non-2xx answer. `name` is the stable code (validation_error, rate_limit_exceeded, ...).
|
|
14
|
+
class Error < StandardError
|
|
15
|
+
attr_reader :status, :name, :request_id, :retry_after
|
|
16
|
+
def initialize(status, name, message, request_id = nil, retry_after = nil)
|
|
17
|
+
super(message)
|
|
18
|
+
@status, @name, @request_id, @retry_after = status, name, request_id, retry_after
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class WebhookError < StandardError; end
|
|
23
|
+
|
|
24
|
+
class Client
|
|
25
|
+
attr_reader :emails, :domains, :api_keys, :contacts, :segments, :topics, :suppressions, :webhooks, :templates, :broadcasts, :logs, :inbound
|
|
26
|
+
|
|
27
|
+
# transport: optional callable(method, url, headers, body) -> [status, body, headers] (for tests)
|
|
28
|
+
def initialize(api_key, base_url: nil, timeout: 30, max_retries: 2, transport: nil)
|
|
29
|
+
@api_key, @timeout, @max_retries, @transport = api_key, timeout, max_retries, transport
|
|
30
|
+
@base_url = (base_url || ENV["MAILTRUSS_API_URL"] || "https://mailtruss.com").sub(%r{/+\z}, "")
|
|
31
|
+
@emails = Emails.new(self)
|
|
32
|
+
@domains = Domains.new(self)
|
|
33
|
+
@api_keys = ApiKeys.new(self)
|
|
34
|
+
@contacts = Resource.new(self, "contacts")
|
|
35
|
+
@segments = Resource.new(self, "segments")
|
|
36
|
+
@topics = Resource.new(self, "topics")
|
|
37
|
+
@suppressions = Suppressions.new(self)
|
|
38
|
+
@webhooks = Webhooks.new(self)
|
|
39
|
+
@templates = Templates.new(self)
|
|
40
|
+
@broadcasts = Broadcasts.new(self)
|
|
41
|
+
@logs = Resource.new(self, "logs")
|
|
42
|
+
@inbound = Inbound.new(self)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Any API call. Retries only what is safe to repeat (reads, deletes, writes with an idempotency key) on 429 and 5xx.
|
|
46
|
+
def request(method, path, body = nil, idempotency_key: nil)
|
|
47
|
+
headers = { "Authorization" => "Bearer #{@api_key}", "Content-Type" => "application/json", "User-Agent" => "mailtruss-ruby/#{VERSION}" }
|
|
48
|
+
headers["Idempotency-Key"] = idempotency_key if idempotency_key
|
|
49
|
+
payload = body.nil? ? nil : JSON.generate(body)
|
|
50
|
+
retryable = !idempotency_key.nil? || %w[GET DELETE].include?(method)
|
|
51
|
+
attempt = 0
|
|
52
|
+
loop do
|
|
53
|
+
begin
|
|
54
|
+
status, raw, res_headers = (@transport || method(:http)).call(method, @base_url + path, headers, payload)
|
|
55
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, SystemCallError, IOError, OpenSSL::SSL::SSLError
|
|
56
|
+
raise unless retryable && attempt < @max_retries
|
|
57
|
+
sleep(2**attempt * 0.5); attempt += 1; next
|
|
58
|
+
end
|
|
59
|
+
json = raw.to_s.empty? ? {} : (JSON.parse(raw) rescue {})
|
|
60
|
+
return json if status.between?(200, 299)
|
|
61
|
+
retry_after = res_headers["retry-after"]&.to_i
|
|
62
|
+
if retryable && attempt < @max_retries && (status == 429 || status >= 500)
|
|
63
|
+
sleep(retry_after || 2**attempt); attempt += 1; next
|
|
64
|
+
end
|
|
65
|
+
raise Error.new(status, json["name"] || "application_error", json["message"] || "Request failed", res_headers["x-request-id"], retry_after)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def http(method, url, headers, body)
|
|
72
|
+
uri = URI(url)
|
|
73
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: @timeout, read_timeout: @timeout) do |h|
|
|
74
|
+
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri, headers)
|
|
75
|
+
req.body = body if body
|
|
76
|
+
res = h.request(req)
|
|
77
|
+
[res.code.to_i, res.body, res.each_header.to_h]
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Generic resource: create, list, get, update, remove.
|
|
83
|
+
class Resource
|
|
84
|
+
def initialize(client, name)
|
|
85
|
+
@c, @name = client, name
|
|
86
|
+
end
|
|
87
|
+
def create(params, idempotency_key: nil) = @c.request("POST", base, params, idempotency_key: idempotency_key)
|
|
88
|
+
def list(query = {}) = @c.request("GET", base + q(query))
|
|
89
|
+
def get(id) = @c.request("GET", "#{base}/#{e(id)}")
|
|
90
|
+
def update(id, params) = @c.request("PATCH", "#{base}/#{e(id)}", params)
|
|
91
|
+
def remove(id) = @c.request("DELETE", "#{base}/#{e(id)}")
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def base = "/api/v1/#{@name}"
|
|
96
|
+
def e(v) = URI.encode_www_form_component(v.to_s).gsub("+", "%20")
|
|
97
|
+
def q(h)
|
|
98
|
+
h = h.reject { |_, v| v.nil? || v == "" }
|
|
99
|
+
h.empty? ? "" : "?#{URI.encode_www_form(h)}"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
class Emails < Resource
|
|
104
|
+
def initialize(c) = super(c, "emails")
|
|
105
|
+
def send(email, idempotency_key: nil) = @c.request("POST", base, email, idempotency_key: idempotency_key)
|
|
106
|
+
def batch(emails, idempotency_key: nil) = @c.request("POST", "#{base}/batch", emails, idempotency_key: idempotency_key)
|
|
107
|
+
def cancel(id) = @c.request("POST", "#{base}/#{e(id)}/cancel")
|
|
108
|
+
def reschedule(id, scheduled_at) = @c.request("PATCH", "#{base}/#{e(id)}", { scheduled_at: scheduled_at })
|
|
109
|
+
# The delivery timeline, what each event proves, and a diagnosis when something went wrong.
|
|
110
|
+
def events(id) = @c.request("GET", "#{base}/#{e(id)}/events")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
class Domains < Resource
|
|
114
|
+
def initialize(c) = super(c, "domains")
|
|
115
|
+
def verify(id) = @c.request("POST", "#{base}/#{e(id)}/verify")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
class ApiKeys < Resource
|
|
119
|
+
def initialize(c) = super(c, "api-keys")
|
|
120
|
+
# Checks this client's own key and what it may do (works with sending-only keys).
|
|
121
|
+
def verify = @c.request("POST", "#{base}/verify", {})
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
class Suppressions < Resource
|
|
125
|
+
def initialize(c) = super(c, "suppressions")
|
|
126
|
+
def add(email) = @c.request("POST", base, { email: email })
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
class Webhooks < Resource
|
|
130
|
+
def initialize(c) = super(c, "webhooks")
|
|
131
|
+
def rotate_secret(id) = @c.request("POST", "#{base}/#{e(id)}/rotate-secret")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
class Templates < Resource
|
|
135
|
+
def initialize(c) = super(c, "templates")
|
|
136
|
+
def publish(id, version: nil) = @c.request("POST", "#{base}/#{e(id)}/publish", version ? { version: version } : {})
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
class Broadcasts < Resource
|
|
140
|
+
def initialize(c) = super(c, "broadcasts")
|
|
141
|
+
def deliver(id, scheduled_at: nil) = @c.request("POST", "#{base}/#{e(id)}/send", scheduled_at ? { scheduled_at: scheduled_at } : {})
|
|
142
|
+
def cancel(id) = @c.request("POST", "#{base}/#{e(id)}/cancel")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Mail received at your receiving domains.
|
|
146
|
+
class Inbound < Resource
|
|
147
|
+
def initialize(c) = super(c, "inbound")
|
|
148
|
+
# One attachment; its bytes are base64 in "content".
|
|
149
|
+
def attachment(id, attachment_id) = @c.request("GET", "#{base}/#{e(id)}/attachments/#{e(attachment_id)}")
|
|
150
|
+
def raw(id) = @c.request("GET", "#{base}/#{e(id)}/raw")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Verifies a Mailtruss webhook (Standard Webhooks format) and returns the event. Pass the raw body exactly as received.
|
|
154
|
+
#
|
|
155
|
+
# event = Mailtruss.verify_webhook(request.raw_post, request.headers, ENV.fetch("MAILTRUSS_WEBHOOK_SECRET"))
|
|
156
|
+
def self.verify_webhook(body, headers, secret, tolerance: 300, now: Time.now.to_i)
|
|
157
|
+
get = ->(*names) { names.each { |n| v = headers[n] || headers[n.split("-").map(&:capitalize).join("-")] || headers["HTTP_#{n.upcase.tr("-", "_")}"]; return v if v }; nil }
|
|
158
|
+
id, ts, sigs = get.call("webhook-id", "svix-id"), get.call("webhook-timestamp", "svix-timestamp"), get.call("webhook-signature", "svix-signature")
|
|
159
|
+
raise WebhookError, "Missing webhook-id, webhook-timestamp or webhook-signature header" unless id && ts && sigs
|
|
160
|
+
raise WebhookError, "Webhook timestamp is outside the allowed window" unless ts.to_s.match?(/\A\d+\z/) && (now - ts.to_i).abs <= tolerance
|
|
161
|
+
# Secrets issued before 2026-11-14 (whsec_ + 32 url-safe characters) are used as text; newer ones are base64 key bytes.
|
|
162
|
+
key = secret.match?(/\Awhsec_[A-Za-z0-9_-]{32}\z/) ? secret : Base64.strict_decode64(secret.delete_prefix("whsec_"))
|
|
163
|
+
expected = Base64.strict_encode64(OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{ts}.#{body}"))
|
|
164
|
+
ok = sigs.to_s.split.any? { |part| v, sig = part.split(",", 2); v == "v1" && sig && sig.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(sig, expected) }
|
|
165
|
+
raise WebhookError, "Webhook signature does not match" unless ok
|
|
166
|
+
JSON.parse(body)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
require_relative "mailtruss/rails" if defined?(Rails::Railtie)
|
metadata
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: mailtruss
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Mailtruss
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
executables: []
|
|
13
|
+
extensions: []
|
|
14
|
+
extra_rdoc_files: []
|
|
15
|
+
files:
|
|
16
|
+
- LICENSE
|
|
17
|
+
- README.md
|
|
18
|
+
- lib/mailtruss.rb
|
|
19
|
+
- lib/mailtruss/delivery_method.rb
|
|
20
|
+
- lib/mailtruss/rails.rb
|
|
21
|
+
- lib/mailtruss/version.rb
|
|
22
|
+
homepage: https://mailtruss.com/docs
|
|
23
|
+
licenses:
|
|
24
|
+
- MIT
|
|
25
|
+
metadata:
|
|
26
|
+
documentation_uri: https://mailtruss.com/docs
|
|
27
|
+
rubygems_mfa_required: 'true'
|
|
28
|
+
rdoc_options: []
|
|
29
|
+
require_paths:
|
|
30
|
+
- lib
|
|
31
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
32
|
+
requirements:
|
|
33
|
+
- - ">="
|
|
34
|
+
- !ruby/object:Gem::Version
|
|
35
|
+
version: '3.0'
|
|
36
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '0'
|
|
41
|
+
requirements: []
|
|
42
|
+
rubygems_version: 4.0.23
|
|
43
|
+
specification_version: 4
|
|
44
|
+
summary: Ruby client for the Mailtruss email API, with a Rails (ActionMailer) delivery
|
|
45
|
+
method.
|
|
46
|
+
test_files: []
|