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 +7 -0
- data/LICENSE +21 -0
- data/README.md +82 -0
- data/lib/wakio/delivery_method.rb +136 -0
- data/lib/wakio/rails.rb +11 -0
- data/lib/wakio.rb +1 -0
- metadata +57 -0
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
|
data/lib/wakio/rails.rb
ADDED
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: []
|