wakio 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2b058707e5d5a45ba09f1ab08a918b13cd24421ff6090c957ac42e8f7ef2ddb3
4
+ data.tar.gz: 90c415eb9823dfca80567242d5402d3d9a61cc3636d6b8e12ab768463c1851c0
5
+ SHA512:
6
+ metadata.gz: c011065c01a89cdffab9684e0aa88a175f657e601246c0780aa193f85b2cf900b541b1709d20e454f011b7f5876243e11cb55c29354c961e3ca628abca5baa22
7
+ data.tar.gz: f53a850d624b30ccfb86a0525b88de4946d1534bd7cb2d053f983d1faf66cd70256045d4d42d4f7944272cce1479606e57235120263b04d1ecdba273b0cf1bc8
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 WorkersLab LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # wakio-rails
2
+
3
+ ```ruby
4
+ # Gemfile
5
+ gem "wakio"
6
+ ```
7
+
8
+ ```ruby
9
+ # config/environments/production.rb
10
+ config.action_mailer.delivery_method = :wakio
11
+ config.action_mailer.wakio_settings = {
12
+ api_key: Rails.application.credentials.wakio_api_key,
13
+ host: "https://api.wakio.net"
14
+ }
15
+ ```
16
+
17
+ 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.
18
+
19
+ 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.
20
+
21
+ ## Limitations worth knowing before you switch a mailer over
22
+
23
+ **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.
24
+
25
+ **Fifty recipients per request.** `to`, `cc` and `bcc` are counted together, once
26
+ case-insensitive duplicates collapse, against Wakio's own contract limit — and the
27
+ whole request is refused past it rather than truncated. The number mirrors SES's own
28
+ per-message recipient ceiling, but it is not that ceiling enforced on you: Wakio fans
29
+ one call out into one SES message per recipient, so no individual message SES sees
30
+ ever approaches it. It reads "per request" and not "per message" for that reason —
31
+ the limit bounds what one `deliver!` call may name, not what any single message
32
+ contains.
33
+
34
+ **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.
35
+
36
+ **Ten megabytes of attachments per request** — a limit of the HTTP API you would call
37
+ directly for the send above, since this gem posts no attachments of its own. Attachment
38
+ content is read once from the request, not once per recipient — but Wakio still writes an
39
+ independent copy of it to every recipient's own delivery record, so the same bytes are
40
+ materialised once per recipient server-side. A second ceiling catches that: attachment
41
+ bytes times recipient count may not exceed 50 MB. The full 10 MB is only available up to
42
+ five recipients on one request; at the 50-recipient limit, attachments may total at most
43
+ 1 MB. A third caps the count rather than the size: at most 20 attachments on one request,
44
+ however small they are.
45
+
46
+ **Every recipient gets their own delivery record.** A `cc`'d address receives a
47
+ separate message, so reply-all does not reach them, and each recipient is billed
48
+ and gated separately.
49
+
50
+ **`host` must not carry a path.** The endpoint is resolved with `URI.join`, so a
51
+ configured host of `https://api.wakio.net/api` yields
52
+ `https://api.wakio.net/v1/emails` — the `/api` is discarded. Give the bare
53
+ origin.
54
+
55
+ ## Making a send idempotent
56
+
57
+ Set a header on the message and it becomes the request's `Idempotency-Key`:
58
+
59
+ ```ruby
60
+ class OrderMailer < ApplicationMailer
61
+ def receipt(order)
62
+ headers["X-Wakio-Idempotency-Key"] = "order-#{order.id}-receipt"
63
+ mail(to: order.email)
64
+ end
65
+ end
66
+ ```
67
+
68
+ A retry carrying a key Wakio has already seen, with a byte-identical request body,
69
+ returns the original response instead of sending again, for 24 hours. The same key
70
+ with any other body is refused with `422 idempotency_key_reused`, and a retry that
71
+ arrives while the first request is still being processed gets `409 request_in_flight`.
72
+
73
+ **Supply one whenever a duplicate would be worse than a missing send** — receipts,
74
+ invoices, welcome emails. **Leave it off, or vary it, when a repeat is the point** — a
75
+ resent one-time code needs to arrive even if it is identical to the last one.
76
+
77
+ The gem never invents a key, and that is deliberate. Only you can tell a retry of one
78
+ send apart from a deliberate repeat of identical content: a receipt for order 1234 has
79
+ an obvious stable key, a resent OTP needs a fresh one, and both can produce byte-identical
80
+ requests. The difference is intent, not data, so nothing here could infer it. Earlier
81
+ versions sent the message's `Message-ID`, which ActionMailer regenerates on every
82
+ delivery attempt — so it changed on exactly the retry it was supposed to catch.
@@ -0,0 +1,136 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "uri"
4
+
5
+ module Wakio
6
+ DeliveryError = Class.new(StandardError)
7
+ Suppressed = Class.new(DeliveryError)
8
+
9
+ # An ActionMailer delivery method, so a host app keeps every existing mailer
10
+ # untouched and changes only configuration.
11
+ class DeliveryMethod
12
+ attr_reader :settings
13
+
14
+ def initialize(settings = {})
15
+ @settings = settings
16
+ end
17
+
18
+ # The mail header a caller sets to make a send idempotent. Not `Idempotency-Key`,
19
+ # so it cannot be confused with the outgoing HTTP header, and X- prefixed so it is
20
+ # obviously ours if it ever leaks into a message dump.
21
+ IDEMPOTENCY_HEADER = "X-Wakio-Idempotency-Key".freeze
22
+
23
+ def deliver!(mail)
24
+ # Caller-supplied, never derived — and no key at all when the caller gives none.
25
+ #
26
+ # This used to send the mail's own Message-ID. That is minted fresh every time
27
+ # ActionMailer builds the message, and MailDeliveryJob re-runs the mailer method
28
+ # on every delivery attempt, so a retried `deliver_later` carried a *different*
29
+ # key and the server deduplicated nothing. The header looked like a guarantee
30
+ # while providing none, which is worse than its absence.
31
+ #
32
+ # Deriving from the payload instead would break the case that matters most here:
33
+ # a resent OTP can be byte-identical to the original, so a content hash would
34
+ # silently swallow the resend and the recipient would never get their code. Only
35
+ # the caller can distinguish "this is a retry of one send" from "this is a
36
+ # deliberate repeat", because the difference is intent rather than data.
37
+ response = post(payload_for(mail), idempotency_key: mail[IDEMPOTENCY_HEADER]&.to_s)
38
+ unless response.code.to_i.between?(200, 299)
39
+ raise DeliveryError, "Wakio returned #{response.code}: #{response.body}"
40
+ end
41
+
42
+ # A separate check from the status above, deliberately: `accepted_any?` only ever
43
+ # returns true or raises today, but joining the two into one `&&` condition would
44
+ # leave a future `false` return falling through to the status-code raise above —
45
+ # misreporting a rejection this method caught as a problem with the response's
46
+ # 2xx status, which was never in question.
47
+ raise DeliveryError, "Wakio did not accept any recipient" unless accepted_any?(response)
48
+
49
+ response
50
+ end
51
+
52
+ private
53
+
54
+ def payload_for(mail)
55
+ html, text = bodies_for(mail)
56
+ {
57
+ to: Array(mail.to),
58
+ cc: Array(mail.cc),
59
+ bcc: Array(mail.bcc),
60
+ reply_to: Array(mail.reply_to),
61
+ from: mail[:from]&.value,
62
+ subject: mail.subject.to_s.gsub(/[[:space:]]+/, " ").strip,
63
+ html: html,
64
+ text: text
65
+ }.compact
66
+ end
67
+
68
+ # Parts, not `mail.body.decoded`: on a multipart message that is `""`, and multipart is
69
+ # what every Rails mailer with both an .html.erb and a .text.erb view produces.
70
+ def bodies_for(mail)
71
+ if mail.multipart?
72
+ [ mail.html_part&.decoded, mail.text_part&.decoded ]
73
+ elsif mail.mime_type == "text/html"
74
+ [ mail.decoded, nil ]
75
+ else
76
+ [ nil, mail.decoded ]
77
+ end
78
+ end
79
+
80
+ # A suppression is the gate working, not a delivery failure — but a request where
81
+ # every recipient was suppressed delivered nothing, so this raises rather than
82
+ # reporting success.
83
+ #
84
+ # Each shape is checked before it is used, rather than caught after the fact: a JSON
85
+ # scalar (`null`, `123`, `"ok"` - a proxy, a CDN error page that happens to be valid
86
+ # JSON) parses cleanly and only then answers #key? with NoMethodError, which is not in
87
+ # the rescue below and so escapes a host app rescuing DeliveryError. Adding NoMethodError
88
+ # to that rescue would swallow a real bug in this file just as readily; raising
89
+ # DeliveryError here does not, because the rescue names two other classes.
90
+ def accepted_any?(response)
91
+ body = JSON.parse(response.body)
92
+ unreadable!(response) unless body.is_a?(Hash)
93
+ if body.key?("id")
94
+ return true unless body["status"] == "suppressed"
95
+
96
+ raise Suppressed, "Wakio accepted no recipient: the address is suppressed"
97
+ end
98
+
99
+ entries = body.fetch("deliveries", [])
100
+ # `all?(Hash)` and not only `is_a?(Array)`: `{"deliveries": ["a"]}` clears the Array
101
+ # check, and `"a"["status"]` is String#[] returning nil rather than an error - so an
102
+ # unusable body would report every recipient accepted instead of being refused.
103
+ unreadable!(response) unless entries.is_a?(Array) && entries.all? { |entry| entry.is_a?(Hash) }
104
+ return true if entries.any? { |entry| entry["status"] != "suppressed" }
105
+
106
+ if entries.empty?
107
+ raise DeliveryError, "Wakio's response named no delivery at all: #{response.body}"
108
+ end
109
+
110
+ raise Suppressed,
111
+ "Wakio accepted no recipient: " +
112
+ entries.map { |e| "#{e['email']} #{e['status']}" }.join(", ")
113
+ rescue JSON::ParserError, TypeError
114
+ unreadable!(response)
115
+ end
116
+
117
+ def unreadable!(response)
118
+ raise DeliveryError, "Wakio returned an unreadable body: #{response.body}"
119
+ end
120
+
121
+ def post(payload, idempotency_key:)
122
+ uri = URI.join(settings.fetch(:host), "/v1/emails")
123
+ request = Net::HTTP::Post.new(uri)
124
+ request["Authorization"] = "Bearer #{settings.fetch(:api_key)}"
125
+ request["Content-Type"] = "application/json"
126
+ # NOT `idempotency_key.present?` — that is ActiveSupport, and this gem
127
+ # deliberately depends on nothing but the stdlib plus `mail`.
128
+ request["Idempotency-Key"] = idempotency_key unless idempotency_key.nil? || idempotency_key.empty?
129
+ request.body = payload.to_json
130
+
131
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
132
+ http.request(request)
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,11 @@
1
+ require "wakio/delivery_method"
2
+
3
+ module Wakio
4
+ module Rails
5
+ def self.install!
6
+ ActionMailer::Base.add_delivery_method(:wakio, Wakio::DeliveryMethod)
7
+ end
8
+ end
9
+ end
10
+
11
+ ActiveSupport.on_load(:action_mailer) { Wakio::Rails.install! }
data/lib/wakio.rb ADDED
@@ -0,0 +1 @@
1
+ require "wakio/rails"
metadata ADDED
@@ -0,0 +1,57 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: wakio
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - WorkersLab LLC
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: actionmailer
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.0'
26
+ executables: []
27
+ extensions: []
28
+ extra_rdoc_files: []
29
+ files:
30
+ - LICENSE
31
+ - README.md
32
+ - lib/wakio.rb
33
+ - lib/wakio/delivery_method.rb
34
+ - lib/wakio/rails.rb
35
+ homepage: https://wakio.net/docs
36
+ licenses:
37
+ - MIT
38
+ metadata:
39
+ rubygems_mfa_required: 'true'
40
+ rdoc_options: []
41
+ require_paths:
42
+ - lib
43
+ required_ruby_version: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '3.2'
48
+ required_rubygems_version: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '0'
53
+ requirements: []
54
+ rubygems_version: 4.0.20
55
+ specification_version: 4
56
+ summary: ActionMailer delivery method for Wakio
57
+ test_files: []