mailkube 1.0.0 → 1.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/NOTICE +1 -4
- data/README.md +23 -1
- data/lib/mailkube/client.rb +8 -2
- data/lib/mailkube/config.rb +19 -2
- data/lib/mailkube/events/contexts.rb +11 -2
- data/lib/mailkube/version.rb +1 -1
- data/lib/mailkube/webhooks.rb +35 -2
- data/sig/mailkube/client.rbs +2 -1
- data/sig/mailkube/config.rbs +2 -1
- data/sig/mailkube/events/contexts.rbs +3 -2
- data/sig/mailkube/webhooks.rbs +2 -0
- data/sig/mailkube.rbs +2 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 11e623e291d499bfa611c9eb8e4e768b8a93dbffafe84422dfc0692f0e4c719a
|
|
4
|
+
data.tar.gz: 2f945465f2bb22cd7633aeb05901f53a48311d41c748c216264c8862720f50bc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4d02cc6a359397afb79581541156aa4f6774b7f187c798d63d4e3e2923024e095d40c5d7676c5024b6b319bffc28bec7ed9c89be98d668c8300ae6c5b5155ac2
|
|
7
|
+
data.tar.gz: 4ee50cef68dfaaae7a8dacc2c76ab6b6bb02e5fbaa4f642014c6224ce45ba90d8e72558a98a1ee009b480cc6b0711b185d6a4fb58f4bcb60f48e454bc365c32e
|
data/LICENSE
CHANGED
|
@@ -186,7 +186,7 @@
|
|
|
186
186
|
same "printed page" as the copyright notice for easier
|
|
187
187
|
identification within third-party archives.
|
|
188
188
|
|
|
189
|
-
Copyright 2026
|
|
189
|
+
Copyright 2026 Mail Tactic Corporation
|
|
190
190
|
|
|
191
191
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
192
|
you may not use this file except in compliance with the License.
|
data/NOTICE
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
mailkube-ruby
|
|
2
|
-
Copyright 2026
|
|
2
|
+
Copyright 2026 Mail Tactic Corporation
|
|
3
3
|
|
|
4
4
|
This product is licensed under the Apache License, Version 2.0 (the "License");
|
|
5
5
|
you may not use this product except in compliance with the License. You may
|
|
6
6
|
obtain a copy of the License at:
|
|
7
7
|
|
|
8
8
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
-
|
|
10
|
-
"mailkube" is a trademark of Mailtactic, Corp. Use of the name is governed by
|
|
11
|
-
Section 6 (Trademarks) of the License.
|
data/README.md
CHANGED
|
@@ -46,6 +46,13 @@ Create one client and reuse it.
|
|
|
46
46
|
| Base URL | `base_url:` | `MAILKUBE_BASE_URL` | `https://api.mailkube.com/mta/v1/` |
|
|
47
47
|
| Timeout | `timeout:` | | 30s |
|
|
48
48
|
| HTTP adapter | `http:` | | `Mailkube::NetHttpAdapter` |
|
|
49
|
+
| User-Agent suffix | `user_agent_suffix:` | | none |
|
|
50
|
+
|
|
51
|
+
If you are building something on top of this gem — a CLI, an internal service, a framework
|
|
52
|
+
integration — set `user_agent_suffix:` to your own `name/version`. It is appended after this SDK's
|
|
53
|
+
own token, so both are visible: `mailkube-ruby/1.1.0 my-cli/1.0.0`. Surrounding whitespace is
|
|
54
|
+
trimmed, and a value containing CR or LF is **ignored rather than sanitized** — a header value that
|
|
55
|
+
could split the request is not one this gem will send, and quietly repairing it would hide the bug.
|
|
49
56
|
|
|
50
57
|
Pass your own `http:` adapter — anything responding to
|
|
51
58
|
`#call(method:, url:, headers:, body:)` and returning a `Mailkube::HttpResponse` — to route
|
|
@@ -198,6 +205,15 @@ header mapping, including Rack's CGI-env spelling.
|
|
|
198
205
|
`Mailkube::Webhooks.verify_signature` is the signature check alone, if you want to parse yourself.
|
|
199
206
|
`X-Webhook-Id` is stable across retries; deduplicate on it.
|
|
200
207
|
|
|
208
|
+
`Mailkube::Webhooks.sign` is the mirror, so your own tests can build a valid request without
|
|
209
|
+
reimplementing the HMAC from this page:
|
|
210
|
+
|
|
211
|
+
```ruby
|
|
212
|
+
signature = Mailkube::Webhooks.sign(
|
|
213
|
+
id: "wh_1", timestamp: Time.now.utc.iso8601, payload: body, secret: secret
|
|
214
|
+
)
|
|
215
|
+
```
|
|
216
|
+
|
|
201
217
|
### Endpoint registration
|
|
202
218
|
|
|
203
219
|
When you create an endpoint, or re-point an existing one, mailkube probes it with
|
|
@@ -224,6 +240,12 @@ implement it.
|
|
|
224
240
|
Every `email.*` payload also carries the message context: `email_id`, `created_at`, `domain`,
|
|
225
241
|
`subject`, `to`, `from` and `tags`.
|
|
226
242
|
|
|
243
|
+
On the `data.open` and `data.click` blocks, `ip_address`, `country` and `user_agent` are recorded
|
|
244
|
+
only where the sending domain has elected them, and both settings are off by default. The server
|
|
245
|
+
omits the key rather than sending an empty value, so the accessor returns `nil` when it was not
|
|
246
|
+
recorded. `country` can be `nil` even where the address was recorded, because it is resolved at the
|
|
247
|
+
edge and is not available on every path.
|
|
248
|
+
|
|
227
249
|
Two guarantees hold for every released version, so a platform change never breaks a running
|
|
228
250
|
receiver:
|
|
229
251
|
|
|
@@ -320,4 +342,4 @@ must pass. Security issues: see [SECURITY.md](SECURITY.md).
|
|
|
320
342
|
|
|
321
343
|
## License
|
|
322
344
|
|
|
323
|
-
[Apache-2.0](LICENSE) © 2026
|
|
345
|
+
[Apache-2.0](LICENSE) © 2026 Mail Tactic Corporation
|
data/lib/mailkube/client.rb
CHANGED
|
@@ -35,9 +35,15 @@ module Mailkube
|
|
|
35
35
|
# @param http [#call, nil] an HTTP adapter to use instead of the built-in {NetHttpAdapter}.
|
|
36
36
|
# This is the dependency-inversion seam the test suite injects through; when supplied,
|
|
37
37
|
# `timeout` is the adapter's business rather than this client's.
|
|
38
|
+
# @param user_agent_suffix [String, nil] a `name/version` token identifying software that
|
|
39
|
+
# wraps this SDK — a CLI, an internal service, a framework integration — appended after
|
|
40
|
+
# this SDK's own token so both are visible. A value containing CR or LF is ignored.
|
|
38
41
|
# @raise [ConfigurationError] when no API key is available.
|
|
39
|
-
def initialize(api_key: nil, base_url: nil, timeout: Config::DEFAULT_TIMEOUT, http: nil
|
|
40
|
-
|
|
42
|
+
def initialize(api_key: nil, base_url: nil, timeout: Config::DEFAULT_TIMEOUT, http: nil,
|
|
43
|
+
user_agent_suffix: nil)
|
|
44
|
+
config = Config.new(
|
|
45
|
+
api_key: api_key, base_url: base_url, timeout: timeout, user_agent_suffix: user_agent_suffix
|
|
46
|
+
)
|
|
41
47
|
transport = Transport.new(config, http || NetHttpAdapter.new(timeout: timeout))
|
|
42
48
|
|
|
43
49
|
@config = config
|
data/lib/mailkube/config.rb
CHANGED
|
@@ -30,14 +30,20 @@ module Mailkube
|
|
|
30
30
|
# @param api_key [String, nil] the API key; falls back to `MAILKUBE_API_KEY`.
|
|
31
31
|
# @param base_url [String, nil] the API base URL; falls back to `MAILKUBE_BASE_URL`.
|
|
32
32
|
# @param timeout [Integer, Float] the per-request timeout in seconds.
|
|
33
|
+
# @param user_agent_suffix [String, nil] a `name/version` token identifying software that
|
|
34
|
+
# wraps this SDK, appended after this SDK's own token.
|
|
33
35
|
# @raise [ConfigurationError] when no API key is available.
|
|
34
|
-
def initialize(api_key: nil, base_url: nil, timeout: DEFAULT_TIMEOUT)
|
|
36
|
+
def initialize(api_key: nil, base_url: nil, timeout: DEFAULT_TIMEOUT, user_agent_suffix: nil)
|
|
35
37
|
key = api_key || ENV.fetch(ENV_API_KEY, nil)
|
|
36
38
|
raise ConfigurationError, "no API key provided: pass api_key: or set #{ENV_API_KEY}" if key.nil? || key.empty?
|
|
37
39
|
|
|
38
40
|
@api_key = key
|
|
39
41
|
@base_url = base_url || ENV.fetch(ENV_BASE_URL, nil) || Mailkube::DEFAULT_BASE_URL
|
|
40
42
|
@timeout = timeout
|
|
43
|
+
suffix = user_agent_suffix.to_s.strip
|
|
44
|
+
# Dropped rather than sanitized: a header value that could split the request is not one this
|
|
45
|
+
# gem will send, and silently repairing it hides the caller's bug.
|
|
46
|
+
@user_agent_suffix = suffix.match?(/[\r\n]/) ? "" : suffix
|
|
41
47
|
freeze
|
|
42
48
|
end
|
|
43
49
|
|
|
@@ -51,12 +57,23 @@ module Mailkube
|
|
|
51
57
|
def default_headers
|
|
52
58
|
{
|
|
53
59
|
"Authorization" => "Bearer #{@api_key}",
|
|
54
|
-
"User-Agent" =>
|
|
60
|
+
"User-Agent" => user_agent,
|
|
55
61
|
"Content-Type" => "application/json",
|
|
56
62
|
"Accept" => "application/json"
|
|
57
63
|
}
|
|
58
64
|
end
|
|
59
65
|
|
|
66
|
+
# This gem's token, plus any suffix a wrapping tool supplied.
|
|
67
|
+
#
|
|
68
|
+
# The SDK token always leads, so attribution of the SDK itself never depends on what the
|
|
69
|
+
# wrapper chose to call itself.
|
|
70
|
+
#
|
|
71
|
+
# @return [String] the User-Agent value.
|
|
72
|
+
def user_agent
|
|
73
|
+
agent = "mailkube-ruby/#{VERSION}"
|
|
74
|
+
@user_agent_suffix.empty? ? agent : "#{agent} #{@user_agent_suffix}"
|
|
75
|
+
end
|
|
76
|
+
|
|
60
77
|
# Join a relative path onto the base URL, attach the query, and refuse any absolute URL off
|
|
61
78
|
# the base URL's origin.
|
|
62
79
|
#
|
|
@@ -63,10 +63,19 @@ module Mailkube
|
|
|
63
63
|
# These nested keys are camelCase on the wire, unlike every other block here. The SDK mirrors
|
|
64
64
|
# the server rather than normalizing it.
|
|
65
65
|
class EngagementContext < Node
|
|
66
|
-
#
|
|
66
|
+
# The three connection accessors return nil unless the sending domain elected to record the
|
|
67
|
+
# field, which is off by default. The server omits the key rather than sending a blank, so
|
|
68
|
+
# nil here means "the sender did not record this" and never "the sender recorded an empty
|
|
69
|
+
# value".
|
|
70
|
+
#
|
|
71
|
+
# @return [String, nil] the opening client's IP address (wire key `ipAddress`).
|
|
67
72
|
def ip_address = self["ipAddress"]
|
|
68
|
-
# @return [String] the opening client's user agent (wire key `userAgent`).
|
|
73
|
+
# @return [String, nil] the opening client's user agent (wire key `userAgent`).
|
|
69
74
|
def user_agent = self["userAgent"]
|
|
75
|
+
# Elected together with {#ip_address}, but can still be nil when the address was recorded:
|
|
76
|
+
# it is resolved at the edge and is not available on every path.
|
|
77
|
+
# @return [String, nil] the two-letter country the address resolves to.
|
|
78
|
+
def country = self["country"]
|
|
70
79
|
# @return [String] when the interaction was recorded.
|
|
71
80
|
def timestamp = self["timestamp"]
|
|
72
81
|
end
|
data/lib/mailkube/version.rb
CHANGED
|
@@ -11,5 +11,5 @@ module Mailkube
|
|
|
11
11
|
# semantic-release rewrites this line **in the release runner** just before the gem is built, and
|
|
12
12
|
# commits nothing back to `main` (see `.rules/RELEASE.md`). So a checkout reports `0.0.0` and an
|
|
13
13
|
# installed gem reports the real version: that is intended, not a bug to fix by hardcoding one.
|
|
14
|
-
VERSION = "1.
|
|
14
|
+
VERSION = "1.2.0"
|
|
15
15
|
end
|
data/lib/mailkube/webhooks.rb
CHANGED
|
@@ -5,7 +5,7 @@ require "time"
|
|
|
5
5
|
require "json"
|
|
6
6
|
|
|
7
7
|
module Mailkube
|
|
8
|
-
# Webhook signature verification.
|
|
8
|
+
# Webhook signature verification and event parsing.
|
|
9
9
|
#
|
|
10
10
|
# Verification is pure and dependency-free: no client instance, no configuration, so you call
|
|
11
11
|
# it directly inside your webhook handler.
|
|
@@ -89,6 +89,39 @@ module Mailkube
|
|
|
89
89
|
parse_event(verify_signature(payload: payload, headers: headers, secret: secret, tolerance: tolerance))
|
|
90
90
|
end
|
|
91
91
|
|
|
92
|
+
# Produce the `X-Webhook-Sig` value for a payload, the mirror of {verify_signature}.
|
|
93
|
+
#
|
|
94
|
+
# This exists so anything that produces a delivery — a fixture, a local replay tool, a fake
|
|
95
|
+
# endpoint in your own suite — computes the signature the way this module verifies it. A
|
|
96
|
+
# reimplementation from the docs above agrees with its author's reading of the prose rather
|
|
97
|
+
# than with this SDK, and the two drift silently. Production code verifies; it does not sign.
|
|
98
|
+
#
|
|
99
|
+
# Freshness is not this method's concern: it signs the timestamp it is given, so replaying an
|
|
100
|
+
# old capture reproduces the original signature exactly.
|
|
101
|
+
#
|
|
102
|
+
# @param id [String] the `X-Webhook-Id` value.
|
|
103
|
+
# @param timestamp [String] the `X-Webhook-Ts` value, ISO-8601.
|
|
104
|
+
# @param payload [String] the raw body that will be sent.
|
|
105
|
+
# @param secret [String] the endpoint's signing secret.
|
|
106
|
+
# @return [String] the header value, including the `sha256=` prefix.
|
|
107
|
+
def self.sign(id:, timestamp:, payload:, secret:)
|
|
108
|
+
SIGNATURE_PREFIX + signature_hex(id, timestamp, payload, secret)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Return the hex HMAC-SHA256 over the contract's signed input.
|
|
112
|
+
#
|
|
113
|
+
# One implementation, so signing and verifying cannot disagree.
|
|
114
|
+
#
|
|
115
|
+
# @param id [String] the `X-Webhook-Id` value.
|
|
116
|
+
# @param timestamp [String] the `X-Webhook-Ts` value.
|
|
117
|
+
# @param payload [String] the raw body.
|
|
118
|
+
# @param secret [String] the signing secret.
|
|
119
|
+
# @return [String] the hex digest, without the prefix.
|
|
120
|
+
def self.signature_hex(id, timestamp, payload, secret)
|
|
121
|
+
OpenSSL::HMAC.hexdigest("SHA256", secret, "#{id}.#{timestamp}.#{payload}")
|
|
122
|
+
end
|
|
123
|
+
private_class_method :signature_hex
|
|
124
|
+
|
|
92
125
|
# Normalize a header mapping to lowercase, dashed names.
|
|
93
126
|
#
|
|
94
127
|
# This accepts more than a Hash on purpose. `ActionDispatch::Http::Headers` is `Enumerable`
|
|
@@ -139,7 +172,7 @@ module Mailkube
|
|
|
139
172
|
# @param secret [String] the signing secret.
|
|
140
173
|
# @raise [SignatureVerificationError] when the digests differ.
|
|
141
174
|
def self.check_signature(payload, id, timestamp, signature, secret)
|
|
142
|
-
expected =
|
|
175
|
+
expected = signature_hex(id, timestamp, payload, secret)
|
|
143
176
|
provided = signature.delete_prefix(SIGNATURE_PREFIX)
|
|
144
177
|
# Length is compared first because `fixed_length_secure_compare` raises on a mismatch, and
|
|
145
178
|
# the length of a hex digest is not a secret.
|
data/sig/mailkube/client.rbs
CHANGED
|
@@ -3,7 +3,8 @@ module Mailkube
|
|
|
3
3
|
attr_reader emails: Resources::Emails
|
|
4
4
|
attr_reader scheduled_emails: Resources::ScheduledEmails
|
|
5
5
|
|
|
6
|
-
def initialize: (?api_key: String?, ?base_url: String?, ?timeout: Numeric, ?http: _HttpAdapter
|
|
6
|
+
def initialize: (?api_key: String?, ?base_url: String?, ?timeout: Numeric, ?http: _HttpAdapter?,
|
|
7
|
+
?user_agent_suffix: String?) -> void
|
|
7
8
|
def base_url: () -> String
|
|
8
9
|
end
|
|
9
10
|
end
|
data/sig/mailkube/config.rbs
CHANGED
|
@@ -7,8 +7,9 @@ module Mailkube
|
|
|
7
7
|
attr_reader base_url: String
|
|
8
8
|
attr_reader timeout: Numeric
|
|
9
9
|
|
|
10
|
-
def initialize: (?api_key: String?, ?base_url: String?, ?timeout: Numeric) -> void
|
|
10
|
+
def initialize: (?api_key: String?, ?base_url: String?, ?timeout: Numeric, ?user_agent_suffix: String?) -> void
|
|
11
11
|
def default_headers: () -> Hash[String, String]
|
|
12
|
+
def user_agent: () -> String
|
|
12
13
|
def build_url: (String, ?Hash[String, String]) -> String
|
|
13
14
|
end
|
|
14
15
|
end
|
|
@@ -22,8 +22,9 @@ module Mailkube
|
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
class EngagementContext < Node
|
|
25
|
-
def ip_address: () -> String
|
|
26
|
-
def user_agent: () -> String
|
|
25
|
+
def ip_address: () -> String?
|
|
26
|
+
def user_agent: () -> String?
|
|
27
|
+
def country: () -> String?
|
|
27
28
|
def timestamp: () -> String
|
|
28
29
|
end
|
|
29
30
|
|
data/sig/mailkube/webhooks.rbs
CHANGED
|
@@ -12,6 +12,8 @@ module Mailkube
|
|
|
12
12
|
def self.parse_event: (String) -> Events::Event
|
|
13
13
|
def self.verify: (payload: String, headers: _HeaderMap, secret: String,
|
|
14
14
|
?tolerance: Integer) -> Events::Event
|
|
15
|
+
def self.sign: (id: String, timestamp: String, payload: String, secret: String) -> String
|
|
16
|
+
def self.signature_hex: (String, String, String, String) -> String
|
|
15
17
|
def self.check_freshness: (String, Integer) -> void
|
|
16
18
|
def self.check_signature: (String, String, String, String, String) -> void
|
|
17
19
|
def self.normalize_headers: (_HeaderMap) -> Hash[String, String]
|
data/sig/mailkube.rbs
CHANGED
|
@@ -29,7 +29,8 @@ module Mailkube
|
|
|
29
29
|
def request_json: (RequestSpec) -> Hash[String, untyped]
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
def self.new: (?api_key: String?, ?base_url: String?, ?timeout: Numeric, ?http: _HttpAdapter
|
|
32
|
+
def self.new: (?api_key: String?, ?base_url: String?, ?timeout: Numeric, ?http: _HttpAdapter?,
|
|
33
|
+
?user_agent_suffix: String?) -> Client
|
|
33
34
|
def self.error_class_for: (Integer) -> Class
|
|
34
35
|
|
|
35
36
|
STATUS_ERRORS: Hash[Integer, Class]
|
metadata
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mailkube
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
|
-
-
|
|
7
|
+
- Mail Tactic Corporation
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|