mailkube 1.0.0 → 1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7641c72078cd5550384c2e708dc156aea1f487d2304d07689ed7fcebc0868bd4
4
- data.tar.gz: 4d0d9ea1e793297301d4cc7652a3ee0fa40b08dc7fcc2fa069b33cb20ddcfef2
3
+ metadata.gz: 62f56b63852a247583a1bbb34cbe7f816ecfdfcbbaf672281b1e4396a697c8e7
4
+ data.tar.gz: c12d752ba809c48b7d7cf6d1cade536b839be61279d2109d2320c96ee187c3bb
5
5
  SHA512:
6
- metadata.gz: 8abad977f64add055a7d30eb83c88529ba3602fdeeb37f7f543386f2b7683fbec70e00f28e7cd102fa1288fabf4f503dbf81cf4c616429f62539d672c3c2b499
7
- data.tar.gz: dd85a8fc81e7cc52a2446bd13e70a348866694b65c0034133b4dbfe7fe8ecd31c45f82a4479216e60d783785131a5d5a27fa90ce265d86c3949493f161191cc2
6
+ metadata.gz: 3dd9768193d7ca0a700cdb6d6af906ddb33533adc9182de122be1debb9617cf000899b0102e396549cf55c19e1c3df26ec9d6cf8f94d8b15f676fcede3841f9b
7
+ data.tar.gz: b5daed405e91f130357dd2a65e5e03284f5616d2676ce6b0d4eb53adff715a004f07594d6e70d051aa9681e1fe8aebbfff4da0f477222a99307cbd2fff900f3b
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
@@ -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
- config = Config.new(api_key: api_key, base_url: base_url, timeout: timeout)
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
@@ -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" => "mailkube-ruby/#{VERSION}",
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
  #
@@ -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.0.0"
14
+ VERSION = "1.1.0"
15
15
  end
@@ -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 = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{id}.#{timestamp}.#{payload}")
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.
@@ -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?) -> void
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
@@ -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
@@ -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?) -> Client
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,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mailkube
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mailtactic, Corp.