wakio 0.1.5 → 0.2.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/LICENSE +1 -1
- data/README.md +61 -1
- data/lib/wakio/client.rb +84 -0
- data/lib/wakio/contacts.rb +44 -0
- data/lib/wakio/delivery_method.rb +5 -28
- data/lib/wakio/error.rb +19 -0
- data/lib/wakio/http.rb +28 -0
- data/lib/wakio.rb +2 -0
- metadata +9 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9795c3b6e4664844b3592b1e52fda609fb7ce969c6c330510e9c9f6f6b8a4c30
|
|
4
|
+
data.tar.gz: 95a22426d5dc1becadc7579b2785f27f75bc092dedce71735df1226952546a93
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4958fb49ab19fbad6ba9e17a3fb763de4afe4fa0f01709077690b4e34ed400673c22218b14c6ebaab75d19a6067c17f5438a5c2452047b8a6d633c06c04a0bec
|
|
7
|
+
data.tar.gz: a927058f1018d75ec47ef541e29664c44879988b6802ec31deb7b3614f3bfa3d0917aa7a3e9591f7ae1b770e5effa90e875e12b35d6e3f1dac63c2113cb87dfc
|
data/LICENSE
CHANGED
data/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# wakio-rails
|
|
2
2
|
|
|
3
|
+
The Ruby client for Wakio. It has two parts: an Action Mailer delivery method that sends your app's email through Wakio, and `Wakio::Contacts`, which keeps your Wakio contacts in step with your own user table.
|
|
4
|
+
|
|
3
5
|
```ruby
|
|
4
6
|
# Gemfile
|
|
5
7
|
gem "wakio"
|
|
@@ -22,6 +24,64 @@ Every existing mailer keeps working unchanged. Its rendered subject, its HTML an
|
|
|
22
24
|
|
|
23
25
|
The `from` domain must be one of your account's sendable sending domains, matched exactly: `hello@acme.com` needs `acme.com` itself verified, and a verified `mail.acme.com` does not cover it. A mismatch is refused with `422 no_verified_sending_domain`. A mailer with no `from` goes out as `no-reply@` your first sendable domain, as long as your account has one brand. Once it has more than one, every mailer needs a `from`, and one without it raises `Wakio::Misconfigured` with `422 from_required`. `GET /v1/sending-identities` reports which domains are currently sendable.
|
|
24
26
|
|
|
27
|
+
## Syncing contacts
|
|
28
|
+
|
|
29
|
+
Add a customer's users to Wakio from your own app. The key needs the `contacts` scope.
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
contacts = Wakio::Contacts.new(Wakio::Client.new(api_key: Rails.application.credentials.wakio_api_key))
|
|
33
|
+
|
|
34
|
+
contact = contacts.create(email: "anna@example.com", first_name: "Anna", external_id: "user-42")
|
|
35
|
+
contacts.update(contact["id"], fields: { plan: "pro" })
|
|
36
|
+
contacts.unsubscribe(contact["id"], lists: [ "lst_abc" ])
|
|
37
|
+
contacts.resubscribe(contact["id"], list: "lst_abc", consent_ip: request.remote_ip)
|
|
38
|
+
contacts.unsubscribe_all(contact["id"])
|
|
39
|
+
contacts.erase(contact["id"])
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
For a person who agreed to marketing email, say so and add them to lists. Send `subscribed: true` only for people who actually agreed. Pass `enroll: false` when backfilling, so existing users do not start any welcome flow.
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
contacts.create(email: "anna@example.com", subscribed: true, consented_at: Time.current.iso8601, lists: [ "lst_abc" ], enroll: false)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`create`, `get`, `update`, `unsubscribe`, `unsubscribe_all`, `resubscribe` and `erase` return the parsed JSON response, or `nil` for a `204`. `resubscribe` sends the person a confirmation email and changes nothing until they click it.
|
|
49
|
+
|
|
50
|
+
`unsubscribe_all` stops all marketing email to the person and keeps the contact, its history and its consent record. It is not erasure: for a person who asks to be forgotten, call `erase`. When someone closes their account, unsubscribe them from everything if you keep their data, and erase them if you delete it: `unsubscribe_all` and `erase`.
|
|
51
|
+
|
|
52
|
+
Pass `lists:` when backfilling. A `subscribed: true` add without it joins the brand's default list.
|
|
53
|
+
|
|
54
|
+
### Errors and retries
|
|
55
|
+
|
|
56
|
+
The client's `endpoint` defaults to `https://wakio.net` and must be `https`. Any non-2xx response raises a subclass of `Wakio::Error` carrying `status`, `code` (the response's `error`, such as `"contact_not_found"`) and the parsed `body`:
|
|
57
|
+
|
|
58
|
+
- `Wakio::NotFound`: a `404`. For an unsubscribe, a delete or an erase it means the person is already gone, so treat it as done. To sync a user nightly, `update` by `external_id` and `create` when it raises `NotFound`.
|
|
59
|
+
- `Wakio::Unavailable`: a `429`, a `5xx`, a timeout (`Net::OpenTimeout`, `Net::ReadTimeout`) or a connection failure. Retry it.
|
|
60
|
+
- `Wakio::Error`: any other refusal, such as `409 email_taken` or `422 invalid_contact`. Retrying the same call cannot help.
|
|
61
|
+
|
|
62
|
+
A call that raised `Wakio::Unavailable` may already have taken effect, so a retried POST can find its work done. That is safe for every contacts call: a repeated `resubscribe` answers `already_sent` and sends nothing.
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
def with_retries(attempts: 5)
|
|
66
|
+
tries = 0
|
|
67
|
+
begin
|
|
68
|
+
yield
|
|
69
|
+
rescue Wakio::Unavailable
|
|
70
|
+
tries += 1
|
|
71
|
+
raise if tries >= attempts
|
|
72
|
+
|
|
73
|
+
sleep(2**tries)
|
|
74
|
+
retry
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
with_retries { contacts.update("user-42", first_name: "Anna") }
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
A `Wakio::Client` keeps one connection open and reuses it, so build one and share it instead of building one per call. Calls on a shared client run one at a time, so it is safe across threads but does not run them in parallel: give each worker thread its own client for that. `client.close` drops the connection, and the next call opens a new one.
|
|
82
|
+
|
|
83
|
+
Build the client after the process forks, lazily in each process, never before a Puma or Sidekiq fork: a child forked with it would share the parent's open connection.
|
|
84
|
+
|
|
25
85
|
## Limitations worth knowing before you switch a mailer over
|
|
26
86
|
|
|
27
87
|
**A suppressed address raises.** Wakio suppresses addresses that have hard-bounced or complained, and accepts a send to one rather than refusing it. The response names each recipient's status, and `deliver!` raises `Wakio::Suppressed` (a `Wakio::DeliveryError`) when every recipient was suppressed, so a one-recipient mailer to a suppressed address fails loudly instead of reporting success. Retrying cannot help, so handle it rather than letting a job backend retry it: `rescue_from Wakio::Suppressed` in your mailer also catches it under `deliver_later`. A send where only some recipients were suppressed returns normally; check the returned response's `deliveries` for which ones.
|
|
@@ -29,7 +89,7 @@ The `from` domain must be one of your account's sendable sending domains, matche
|
|
|
29
89
|
**Refusals split by whether a retry can ever send the message.** A refusal from Wakio is a `Wakio::DeliveryError` carrying `status`, `code` (the response's `error`, such as `"invalid_request"`) and the parsed `body`. `code` is nil when the response is not JSON or names no `error`, so fall back to `status` when you log it.
|
|
30
90
|
|
|
31
91
|
- `Wakio::Rejected`: retrying this message cannot help. `400`, `413`, `422 invalid_request` (usually a mailer bug; `detail` names the field) and `422 idempotency_key_reused` (an earlier request with that key already went out). Report it and drop it.
|
|
32
|
-
- `Wakio::Misconfigured`: the account or its configuration is wrong, and the same message sends once that is fixed. `401 invalid_api_key`, `402 not_entitled`, `403 account_suspended`, `404`, `422 no_verified_sending_domain`, `422 from_required`, `422 unknown_template`, a redirect, an `http` host, and any 4xx this gem does not know. Retry it.
|
|
92
|
+
- `Wakio::Misconfigured`: the account or its configuration is wrong, and the same message sends once that is fixed. `401 invalid_api_key`, `402 not_entitled`, `402 limit_reached`, `403 account_suspended`, `403 transactional_paused`, `404`, `422 no_verified_sending_domain`, `422 from_required`, `422 unknown_template`, a redirect, an `http` host, and any 4xx this gem does not know. Retry it once that is fixed. `402 limit_reached` means a Free plan has used up this month's emails, or the message has more recipients than are left. Retrying cannot send it until the monthly allowance resets or the account upgrades, and that can be longer than your job's retry window.
|
|
33
93
|
- A plain `Wakio::DeliveryError`: `409 request_in_flight`, every 5xx, and a success response the gem cannot read. Retry it; after an unreadable success only a keyed send is safe to repeat.
|
|
34
94
|
|
|
35
95
|
Action Mailer's delivery job retries nothing itself, and Solid Queue and GoodJob do not retry a failed job by default, so give it a policy:
|
data/lib/wakio/client.rb
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "wakio/http"
|
|
6
|
+
|
|
7
|
+
module Wakio
|
|
8
|
+
class Client
|
|
9
|
+
DEFAULT_ENDPOINT = "https://wakio.net".freeze
|
|
10
|
+
CONNECTION_ERRORS = [ Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, EOFError, IOError, SocketError, SystemCallError, OpenSSL::SSL::SSLError ].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(api_key:, endpoint: DEFAULT_ENDPOINT, open_timeout: 5, read_timeout: 10)
|
|
13
|
+
@api_key = api_key
|
|
14
|
+
@endpoint = endpoint
|
|
15
|
+
@open_timeout = open_timeout
|
|
16
|
+
@read_timeout = read_timeout
|
|
17
|
+
@lock = Mutex.new
|
|
18
|
+
@connection = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def request(verb, path, payload = nil)
|
|
22
|
+
uri = URI.join(@endpoint, path)
|
|
23
|
+
Http.require_https!(uri, "endpoint", @endpoint)
|
|
24
|
+
|
|
25
|
+
parse(@lock.synchronize { transmit(uri, build(verb, uri, payload)) })
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def close
|
|
29
|
+
@lock.synchronize { disconnect }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def transmit(uri, request)
|
|
35
|
+
connection(uri).request(request)
|
|
36
|
+
rescue *CONNECTION_ERRORS => error
|
|
37
|
+
disconnect
|
|
38
|
+
raise Unavailable, "Wakio could not be reached: #{error.class}: #{error.message}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def connection(uri)
|
|
42
|
+
disconnect unless @connection && @connection.address == uri.hostname && @connection.port == uri.port
|
|
43
|
+
@connection ||= Net::HTTP.new(uri.hostname, uri.port).tap do |http|
|
|
44
|
+
http.use_ssl = uri.scheme == "https"
|
|
45
|
+
http.open_timeout = @open_timeout
|
|
46
|
+
http.read_timeout = @read_timeout
|
|
47
|
+
end
|
|
48
|
+
@connection.start unless @connection.started?
|
|
49
|
+
@connection
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def disconnect
|
|
53
|
+
@connection&.finish if @connection&.started?
|
|
54
|
+
rescue IOError
|
|
55
|
+
nil
|
|
56
|
+
ensure
|
|
57
|
+
@connection = nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def build(verb, uri, payload)
|
|
61
|
+
request = Net::HTTP.const_get(verb.to_s.capitalize).new(uri)
|
|
62
|
+
request["Authorization"] = "Bearer #{@api_key}"
|
|
63
|
+
request["Content-Type"] = "application/json"
|
|
64
|
+
request.body = payload.to_json unless payload.nil?
|
|
65
|
+
request
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def parse(response)
|
|
69
|
+
status = response.code.to_i
|
|
70
|
+
raw = response.body.to_s
|
|
71
|
+
body = Http.parse_json(raw)
|
|
72
|
+
raise failure(status).new(Http.refusal_message(status, raw), status: status, body: body) unless status.between?(200, 299)
|
|
73
|
+
|
|
74
|
+
body
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def failure(status)
|
|
78
|
+
if status == 404 then NotFound
|
|
79
|
+
elsif status == 429 || status >= 500 then Unavailable
|
|
80
|
+
else Error
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
require "erb"
|
|
2
|
+
require "wakio/client"
|
|
3
|
+
|
|
4
|
+
module Wakio
|
|
5
|
+
class Contacts
|
|
6
|
+
def initialize(client)
|
|
7
|
+
@client = client
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def create(**attrs)
|
|
11
|
+
@client.request(:post, "/v1/contacts", attrs)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def get(id)
|
|
15
|
+
@client.request(:get, path(id))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def update(id, **attrs)
|
|
19
|
+
@client.request(:patch, path(id), attrs)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def unsubscribe_all(id)
|
|
23
|
+
@client.request(:delete, path(id))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def unsubscribe(id, lists: nil)
|
|
27
|
+
@client.request(:post, path(id, "unsubscribe"), { lists: lists }.compact)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def resubscribe(id, list: nil, consent_ip: nil)
|
|
31
|
+
@client.request(:post, path(id, "resubscribe"), { list: list, consent_ip: consent_ip }.compact)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def erase(id)
|
|
35
|
+
@client.request(:post, path(id, "erase"))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def path(id, action = nil)
|
|
41
|
+
[ "/v1/contacts/#{ERB::Util.url_encode(id.to_s)}", action ].compact.join("/")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -1,23 +1,10 @@
|
|
|
1
1
|
require "json"
|
|
2
2
|
require "net/http"
|
|
3
3
|
require "uri"
|
|
4
|
+
require "wakio/error"
|
|
5
|
+
require "wakio/http"
|
|
4
6
|
|
|
5
7
|
module Wakio
|
|
6
|
-
class DeliveryError < StandardError
|
|
7
|
-
attr_reader :status, :code, :body
|
|
8
|
-
|
|
9
|
-
def initialize(message = nil, status: nil, body: nil)
|
|
10
|
-
super(message)
|
|
11
|
-
@status = status
|
|
12
|
-
@body = body
|
|
13
|
-
@code = body["error"] if body.is_a?(Hash)
|
|
14
|
-
end
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
Suppressed = Class.new(DeliveryError)
|
|
18
|
-
Rejected = Class.new(DeliveryError)
|
|
19
|
-
Misconfigured = Class.new(DeliveryError)
|
|
20
|
-
|
|
21
8
|
# An ActionMailer delivery method, so a host app keeps every existing mailer
|
|
22
9
|
# untouched and changes only configuration.
|
|
23
10
|
class DeliveryMethod
|
|
@@ -37,8 +24,6 @@ module Wakio
|
|
|
37
24
|
# towards retrying rather than towards dropping mail.
|
|
38
25
|
REJECTED_STATUSES = [ 400, 413 ].freeze
|
|
39
26
|
REJECTED_CODES = %w[invalid_request idempotency_key_reused].freeze
|
|
40
|
-
LOCAL_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
|
|
41
|
-
MESSAGE_BODY_LIMIT = 300
|
|
42
27
|
|
|
43
28
|
def deliver!(mail)
|
|
44
29
|
# Caller-supplied, never derived — and no key at all when the caller gives none.
|
|
@@ -159,7 +144,7 @@ module Wakio
|
|
|
159
144
|
end
|
|
160
145
|
|
|
161
146
|
def refusal(status, raw)
|
|
162
|
-
body =
|
|
147
|
+
body = Http.parse_json(raw)
|
|
163
148
|
code = body["error"] if body.is_a?(Hash)
|
|
164
149
|
kind =
|
|
165
150
|
if status.between?(300, 399) then Misconfigured
|
|
@@ -167,13 +152,7 @@ module Wakio
|
|
|
167
152
|
elsif REJECTED_STATUSES.include?(status) || REJECTED_CODES.include?(code) then Rejected
|
|
168
153
|
else Misconfigured
|
|
169
154
|
end
|
|
170
|
-
kind.new(
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
def parsed(body)
|
|
174
|
-
JSON.parse(body.to_s)
|
|
175
|
-
rescue JSON::ParserError
|
|
176
|
-
nil
|
|
155
|
+
kind.new(Http.refusal_message(status, raw), status: status, body: body)
|
|
177
156
|
end
|
|
178
157
|
|
|
179
158
|
def unreadable!(response)
|
|
@@ -182,9 +161,7 @@ module Wakio
|
|
|
182
161
|
|
|
183
162
|
def post(payload, idempotency_key:)
|
|
184
163
|
uri = URI.join(settings.fetch(:host), "/v1/emails")
|
|
185
|
-
|
|
186
|
-
raise Misconfigured, "Wakio host must be https, or the API key travels in cleartext: #{settings.fetch(:host)}"
|
|
187
|
-
end
|
|
164
|
+
Http.require_https!(uri, "host", settings.fetch(:host))
|
|
188
165
|
request = Net::HTTP::Post.new(uri)
|
|
189
166
|
request["Authorization"] = "Bearer #{settings.fetch(:api_key)}"
|
|
190
167
|
request["Content-Type"] = "application/json"
|
data/lib/wakio/error.rb
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Wakio
|
|
2
|
+
class Error < StandardError
|
|
3
|
+
attr_reader :status, :code, :body
|
|
4
|
+
|
|
5
|
+
def initialize(message = nil, status: nil, body: nil)
|
|
6
|
+
super(message)
|
|
7
|
+
@status = status
|
|
8
|
+
@body = body
|
|
9
|
+
@code = body["error"] if body.is_a?(Hash)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
NotFound = Class.new(Error)
|
|
14
|
+
Unavailable = Class.new(Error)
|
|
15
|
+
DeliveryError = Class.new(Error)
|
|
16
|
+
Suppressed = Class.new(DeliveryError)
|
|
17
|
+
Rejected = Class.new(DeliveryError)
|
|
18
|
+
Misconfigured = Class.new(DeliveryError)
|
|
19
|
+
end
|
data/lib/wakio/http.rb
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "uri"
|
|
3
|
+
require "wakio/error"
|
|
4
|
+
|
|
5
|
+
module Wakio
|
|
6
|
+
module Http
|
|
7
|
+
LOCAL_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
|
|
8
|
+
MESSAGE_BODY_LIMIT = 300
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def require_https!(uri, setting, configured)
|
|
13
|
+
return if uri.scheme == "https" || LOCAL_HOSTS.include?(uri.hostname)
|
|
14
|
+
|
|
15
|
+
raise Misconfigured, "Wakio #{setting} must be https, or the API key travels in cleartext: #{configured}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def refusal_message(status, raw)
|
|
19
|
+
"Wakio returned #{status}: #{raw.to_s[0, MESSAGE_BODY_LIMIT]}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def parse_json(raw)
|
|
23
|
+
JSON.parse(raw.to_s)
|
|
24
|
+
rescue JSON::ParserError
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
data/lib/wakio.rb
CHANGED
metadata
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: wakio
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
|
-
-
|
|
7
|
+
- ClayClock LLC
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
@@ -23,6 +23,8 @@ dependencies:
|
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '7.0'
|
|
26
|
+
description: 'Sends Action Mailer email through Wakio, and syncs your own users to
|
|
27
|
+
Wakio as contacts: add, update, unsubscribe, resubscribe and erase.'
|
|
26
28
|
executables: []
|
|
27
29
|
extensions: []
|
|
28
30
|
extra_rdoc_files: []
|
|
@@ -30,7 +32,11 @@ files:
|
|
|
30
32
|
- LICENSE
|
|
31
33
|
- README.md
|
|
32
34
|
- lib/wakio.rb
|
|
35
|
+
- lib/wakio/client.rb
|
|
36
|
+
- lib/wakio/contacts.rb
|
|
33
37
|
- lib/wakio/delivery_method.rb
|
|
38
|
+
- lib/wakio/error.rb
|
|
39
|
+
- lib/wakio/http.rb
|
|
34
40
|
- lib/wakio/rails.rb
|
|
35
41
|
homepage: https://wakio.net/docs
|
|
36
42
|
licenses:
|
|
@@ -53,5 +59,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
53
59
|
requirements: []
|
|
54
60
|
rubygems_version: 4.0.20
|
|
55
61
|
specification_version: 4
|
|
56
|
-
summary: ActionMailer delivery method for Wakio
|
|
62
|
+
summary: ActionMailer delivery method and contacts client for Wakio
|
|
57
63
|
test_files: []
|