wakio 0.1.4 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 38874499f377ba462c3e6b6309132fc3e755f83d53ba173c3f45c6f3c7a71b22
4
- data.tar.gz: dd55b75c7b3d5ba2dd490c6d24c18bf284b4c389385e14627e840b5d73e56ea7
3
+ metadata.gz: 9795c3b6e4664844b3592b1e52fda609fb7ce969c6c330510e9c9f6f6b8a4c30
4
+ data.tar.gz: 95a22426d5dc1becadc7579b2785f27f75bc092dedce71735df1226952546a93
5
5
  SHA512:
6
- metadata.gz: 96d287db49a082bdd79f41500482a7141e0aedae7ed1f7508ae070321a4ab10ac133496f6ba3214b328b780b13042a61e7d2f600e86a397337dfe0773047f6fe
7
- data.tar.gz: 92402e1b1906380ede53db33c50f1ba806d746d8d63f9b3b8760105427e7a3349093dbfc46b1172ca1bdca07a73b4c671e6f7f48feea85930ea8f57053b97d5a
6
+ metadata.gz: 4958fb49ab19fbad6ba9e17a3fb763de4afe4fa0f01709077690b4e34ed400673c22218b14c6ebaab75d19a6067c17f5438a5c2452047b8a6d633c06c04a0bec
7
+ data.tar.gz: a927058f1018d75ec47ef541e29664c44879988b6802ec31deb7b3614f3bfa3d0917aa7a3e9591f7ae1b770e5effa90e875e12b35d6e3f1dac63c2113cb87dfc
data/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 WorkersLab LLC
3
+ Copyright (c) 2026 ClayClock LLC
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
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"
@@ -20,7 +22,65 @@ config.action_mailer.wakio_settings = {
20
22
 
21
23
  Every existing mailer keeps working unchanged. Its rendered subject, its HTML and text parts, and its `from` go to Wakio as they are, with no server-side template involved. A text-only mailer sends a text-only email. Recipients keep their display names (`to`, `cc`, `bcc`); `reply_to` is sent as bare addresses.
22
24
 
23
- 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. `GET /v1/sending-identities` reports which domains are currently sendable.
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.
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.
24
84
 
25
85
  ## Limitations worth knowing before you switch a mailer over
26
86
 
@@ -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 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:
@@ -68,10 +128,9 @@ ever approaches it. It reads "per request" and not "per message" for that reason
68
128
  the limit bounds what one `deliver!` call may name, not what any single message
69
129
  contains.
70
130
 
71
- **This gem does not send the mailer's `attachments`.** No files reach the recipient. Call the HTTP API directly for a send that needs them.
131
+ **Attachments are sent; inline attachments are refused.** Every file in the mailer's `attachments` reaches each recipient. Wakio cannot reference a part by `cid:`, so a mailer using `attachments.inline` raises `Wakio::Rejected` before anything is sent, rather than delivering a broken image.
72
132
 
73
- **Ten megabytes of attachments per request** — a limit of the HTTP API you would call
74
- directly for the send above, since this gem posts no attachments of its own. Attachment
133
+ **Ten megabytes of attachments per request**, checked by the API, which refuses a request over any limit below with `Wakio::Rejected`. Attachment
75
134
  content is read once from the request, not once per recipient — but Wakio still writes an
76
135
  independent copy of it to every recipient's own delivery record, so the same bytes are
77
136
  materialised once per recipient server-side. A second ceiling catches that: attachment
@@ -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.
@@ -80,10 +65,24 @@ module Wakio
80
65
  from: mail[:from]&.value,
81
66
  subject: mail.subject.to_s.gsub(/[[:space:]]+/, " ").strip,
82
67
  html: html,
83
- text: text
68
+ text: text,
69
+ attachments: attachments_for(mail)
84
70
  }.compact
85
71
  end
86
72
 
73
+ # Wakio has no content_id, so an inline part would arrive as a separate file and its cid: image
74
+ # would break. Refused here, before the request, rather than delivered broken.
75
+ def attachments_for(mail)
76
+ return if mail.attachments.empty?
77
+
78
+ inline = mail.attachments.select(&:inline?)
79
+ raise Rejected, "Wakio does not send inline attachments: #{inline.map(&:filename).join(', ')}" if inline.any?
80
+
81
+ mail.attachments.map do |file|
82
+ { filename: file.filename, content_type: file.mime_type, content: [ file.decoded ].pack("m0") }
83
+ end
84
+ end
85
+
87
86
  # `formatted` keeps each recipient's display name, which Wakio shows in the To line and ignores
88
87
  # for matching. reply_to stays bare above: Wakio takes no name there.
89
88
  # A header Mail cannot parse is an UnstructuredField with no #formatted; sent as written, Wakio
@@ -145,7 +144,7 @@ module Wakio
145
144
  end
146
145
 
147
146
  def refusal(status, raw)
148
- body = parsed(raw)
147
+ body = Http.parse_json(raw)
149
148
  code = body["error"] if body.is_a?(Hash)
150
149
  kind =
151
150
  if status.between?(300, 399) then Misconfigured
@@ -153,13 +152,7 @@ module Wakio
153
152
  elsif REJECTED_STATUSES.include?(status) || REJECTED_CODES.include?(code) then Rejected
154
153
  else Misconfigured
155
154
  end
156
- kind.new("Wakio returned #{status}: #{raw.to_s[0, MESSAGE_BODY_LIMIT]}", status: status, body: body)
157
- end
158
-
159
- def parsed(body)
160
- JSON.parse(body.to_s)
161
- rescue JSON::ParserError
162
- nil
155
+ kind.new(Http.refusal_message(status, raw), status: status, body: body)
163
156
  end
164
157
 
165
158
  def unreadable!(response)
@@ -168,9 +161,7 @@ module Wakio
168
161
 
169
162
  def post(payload, idempotency_key:)
170
163
  uri = URI.join(settings.fetch(:host), "/v1/emails")
171
- unless uri.scheme == "https" || LOCAL_HOSTS.include?(uri.hostname)
172
- raise Misconfigured, "Wakio host must be https, or the API key travels in cleartext: #{settings.fetch(:host)}"
173
- end
164
+ Http.require_https!(uri, "host", settings.fetch(:host))
174
165
  request = Net::HTTP::Post.new(uri)
175
166
  request["Authorization"] = "Bearer #{settings.fetch(:api_key)}"
176
167
  request["Content-Type"] = "application/json"
@@ -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
@@ -1 +1,3 @@
1
1
  require "wakio/rails"
2
+ require "wakio/client"
3
+ require "wakio/contacts"
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.1.4
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
- - WorkersLab LLC
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: []