mailkube 1.0.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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +201 -0
  3. data/NOTICE +11 -0
  4. data/README.md +323 -0
  5. data/lib/mailkube/client.rb +52 -0
  6. data/lib/mailkube/config.rb +89 -0
  7. data/lib/mailkube/errors.rb +132 -0
  8. data/lib/mailkube/events/contexts.rb +129 -0
  9. data/lib/mailkube/events/envelopes.rb +96 -0
  10. data/lib/mailkube/events/node.rb +62 -0
  11. data/lib/mailkube/events/payloads.rb +88 -0
  12. data/lib/mailkube/events/registry.rb +26 -0
  13. data/lib/mailkube/logging.rb +113 -0
  14. data/lib/mailkube/net_http_adapter.rb +123 -0
  15. data/lib/mailkube/resources/emails.rb +81 -0
  16. data/lib/mailkube/resources/scheduled_email_requests.rb +78 -0
  17. data/lib/mailkube/resources/scheduled_emails.rb +142 -0
  18. data/lib/mailkube/serialization.rb +98 -0
  19. data/lib/mailkube/transport.rb +190 -0
  20. data/lib/mailkube/types/scheduled_acks.rb +57 -0
  21. data/lib/mailkube/types/scheduled_emails.rb +96 -0
  22. data/lib/mailkube/types.rb +54 -0
  23. data/lib/mailkube/version.rb +15 -0
  24. data/lib/mailkube/webhooks.rb +152 -0
  25. data/lib/mailkube.rb +68 -0
  26. data/sig/mailkube/client.rbs +9 -0
  27. data/sig/mailkube/config.rbs +14 -0
  28. data/sig/mailkube/errors.rbs +78 -0
  29. data/sig/mailkube/events/contexts.rbs +60 -0
  30. data/sig/mailkube/events/envelopes.rbs +60 -0
  31. data/sig/mailkube/events/node.rbs +32 -0
  32. data/sig/mailkube/events/payloads.rbs +54 -0
  33. data/sig/mailkube/events/registry.rbs +6 -0
  34. data/sig/mailkube/logging.rbs +26 -0
  35. data/sig/mailkube/net_http_adapter.rbs +15 -0
  36. data/sig/mailkube/resources/emails.rbs +18 -0
  37. data/sig/mailkube/resources/scheduled_email_requests.rbs +19 -0
  38. data/sig/mailkube/resources/scheduled_emails.rbs +32 -0
  39. data/sig/mailkube/serialization.rbs +11 -0
  40. data/sig/mailkube/transport.rbs +34 -0
  41. data/sig/mailkube/types/scheduled_acks.rbs +30 -0
  42. data/sig/mailkube/types/scheduled_emails.rbs +53 -0
  43. data/sig/mailkube/types.rbs +44 -0
  44. data/sig/mailkube/webhooks.rbs +30 -0
  45. data/sig/mailkube.rbs +36 -0
  46. metadata +88 -0
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Mailkube
6
+ # A fully-built request, ready to send.
7
+ #
8
+ # @api private This is internal plumbing, not part of the supported surface. Build requests by
9
+ # calling a resource verb; the shape here may change in a minor release.
10
+ #
11
+ # `path` is relative to the base URL, or an absolute URL the API itself issued (a pagination
12
+ # link). {Config#build_url} refuses an absolute URL off the configured origin.
13
+ class RequestSpec < Data.define(:path, :method, :body, :params, :headers)
14
+ # @param path [String] the path or absolute URL to request.
15
+ # @param method [String] the HTTP method.
16
+ # @param body [Hash, nil] the JSON request body, or nil for a body-less request.
17
+ # @param params [Hash{String => String}] query parameters, already rendered to strings by
18
+ # {Serialization.query}. Empty means no query string at all, not an empty one.
19
+ # @param headers [Hash{String => String}] per-request headers, merged over the defaults.
20
+ def initialize(path:, method: "POST", body: nil, params: {}, headers: {}) = super
21
+ end
22
+
23
+ # One HTTP response, in the shape every adapter returns.
24
+ #
25
+ # `headers` are stored exactly as the adapter supplied them, and read through {#header}, which
26
+ # matches case-insensitively as HTTP requires.
27
+ #
28
+ # Unlike the rest of this file, this class **is** public: it is the return type of the `http:`
29
+ # adapter contract, so any adapter you write has to construct one.
30
+ class HttpResponse < Data.define(:status, :headers, :body)
31
+ # @param status [Integer] the HTTP status code.
32
+ # @param headers [Hash{String => String}] response headers, in any casing.
33
+ # @param body [String] the raw response body.
34
+ def initialize(status:, headers: {}, body: "") = super
35
+
36
+ # Look a header up, case-insensitively.
37
+ #
38
+ # Both sides are downcased, rather than downcasing the argument and indexing a map assumed to
39
+ # be lowercase already. That assumption used to be this class's documented contract, and since
40
+ # the class is **public** — the `http:` seam means third-party adapters construct it — it was
41
+ # an invariant the SDK asked for and could not enforce. An adapter that passed `X-Request-Id`
42
+ # through in the server's own casing produced a silent nil for every header the SDK reads,
43
+ # which is exactly how the request id could have stayed empty even after the gateway started
44
+ # sending it. A scan over a handful of pairs costs nothing and cannot be got wrong from
45
+ # outside.
46
+ #
47
+ # @param name [String] a header name in any casing.
48
+ # @return [String, nil] the header value, or nil when absent.
49
+ def header(name)
50
+ wanted = name.downcase
51
+ headers.each { |key, value| return value if key.downcase == wanted }
52
+ nil
53
+ end
54
+ end
55
+
56
+ # Performs one HTTP round trip and turns the result into a model or a mapped error.
57
+ #
58
+ # @api private This is internal plumbing, not part of the supported surface. To swap out the HTTP
59
+ # layer, pass an adapter to `Client.new(http:)` — that seam **is** public, and it is documented
60
+ # in the README. Depending on this class directly is not supported.
61
+ #
62
+ # This is the layer that knows the API's error envelope and nothing about `Net::HTTP`: the
63
+ # actual socket work lives behind the injected adapter (see {NetHttpAdapter}), which is what
64
+ # lets the whole suite run without network access.
65
+ #
66
+ # A resource depends on the narrowest thing it needs, which in Ruby means an object responding
67
+ # to the one verb it calls. `Resources::Emails` needs only `#send_email`. A new capability adds
68
+ # a method here; it never widens an existing one.
69
+ class Transport
70
+ # @param config [Config] the resolved configuration.
71
+ # @param adapter [#call] the HTTP adapter performing the round trip.
72
+ def initialize(config, adapter)
73
+ @config = config
74
+ @adapter = adapter
75
+ freeze
76
+ end
77
+
78
+ # Perform a send request and build the accepted-send result.
79
+ #
80
+ # @param spec [RequestSpec] the request to perform.
81
+ # @return [Email] the accepted-send result.
82
+ # @raise [APIError] on any non-2xx response.
83
+ # @raise [ConnectionError] on a transport failure or timeout.
84
+ def send_email(spec)
85
+ response = perform(spec)
86
+ payload = decode(response.body)
87
+ id = payload["id"]
88
+ raise APIError.new("expected a JSON body with an 'id'", status_code: response.status) unless id.is_a?(String)
89
+
90
+ Email.new(
91
+ id: id,
92
+ message_id: payload["message_id"],
93
+ idempotent_replayed: response.header("Idempotent-Replayed")&.downcase == "true",
94
+ status: payload["status"],
95
+ scheduled_at: payload["scheduled_at"],
96
+ batch_id: payload["batch_id"]
97
+ )
98
+ end
99
+
100
+ # Perform a request and return its decoded JSON object body.
101
+ #
102
+ # The second transport verb, and deliberately **not** a widening of the first: {#send_email}
103
+ # builds one specific model out of a body plus a response header, which is a send concern.
104
+ # This one hands back the decoded object and lets the resource name its model, which is what
105
+ # keeps {Resources::Emails} depending on `#send_email` alone.
106
+ #
107
+ # It takes no model argument on purpose. Ruby has no generics and RBS cannot tie a class
108
+ # argument to a return type, so a `request(spec, model)` form would make Steep prove generic
109
+ # structural conformance for something the resource already knows statically. Naming the model
110
+ # is a resource decision; decoding is this layer's.
111
+ #
112
+ # @param spec [RequestSpec] the request to perform.
113
+ # @return [Hash{String => Object}] the decoded 2xx body.
114
+ # @raise [APIError] on any non-2xx response, or a 2xx body that is not a JSON object.
115
+ # @raise [ConnectionError] on a transport failure or timeout.
116
+ def request_json(spec)
117
+ response = perform(spec)
118
+ payload = decode_object(response.body)
119
+ raise APIError.new("expected a JSON object body", status_code: response.status) if payload.nil?
120
+
121
+ payload
122
+ end
123
+
124
+ private
125
+
126
+ # Perform the round trip, raising the mapped error for any non-2xx status.
127
+ #
128
+ # This is the single place a status becomes an exception, so every verb, present and future,
129
+ # reports failures identically.
130
+ #
131
+ # @param spec [RequestSpec] the request to perform.
132
+ # @return [HttpResponse] the 2xx response.
133
+ def perform(spec)
134
+ url = @config.build_url(spec.path, spec.params)
135
+ headers = @config.default_headers.merge(spec.headers)
136
+ Logging.request(spec.method, url, headers)
137
+ response = @adapter.call(
138
+ method: spec.method,
139
+ url: url,
140
+ headers: headers,
141
+ body: spec.body.nil? ? nil : JSON.generate(spec.body)
142
+ )
143
+ Logging.response(response.status, url, response.header("X-Request-Id"))
144
+ return response if (200..299).cover?(response.status)
145
+
146
+ raise error_for(response)
147
+ end
148
+
149
+ # Build the exception for a non-2xx response.
150
+ #
151
+ # @param response [HttpResponse] the failed response.
152
+ # @return [APIError] the exception to raise.
153
+ def error_for(response)
154
+ payload = decode(response.body)
155
+ Mailkube.error_class_for(response.status).new(
156
+ payload["message"],
157
+ error_name: payload["name"].is_a?(String) ? payload["name"] : "",
158
+ status_code: response.status,
159
+ body: payload,
160
+ retry_after: response.header("Retry-After")&.to_i,
161
+ request_id: response.header("X-Request-Id")
162
+ )
163
+ end
164
+
165
+ # Decode a body strictly: nil when it is empty, undecodable, or not a JSON *object*.
166
+ #
167
+ # Split out of {#decode} rather than inlined, because the two callers need opposite things
168
+ # from the same parse: {#error_for} must stay lenient, so a malformed error body still maps by
169
+ # status, and {#request_json} must not, so a 2xx that is not an object is reported rather than
170
+ # silently becoming an empty model. One parse, two contracts.
171
+ #
172
+ # @param raw [String] the raw response body.
173
+ # @return [Hash{String => Object}, nil] the decoded object, or nil.
174
+ def decode_object(raw)
175
+ return nil if raw.empty?
176
+
177
+ decoded = JSON.parse(raw)
178
+ decoded.is_a?(Hash) ? decoded : nil
179
+ rescue JSON::ParserError
180
+ nil
181
+ end
182
+
183
+ # Best-effort JSON decode: an empty or undecodable body becomes an empty hash, so a
184
+ # malformed error response still maps by status.
185
+ #
186
+ # @param raw [String] the raw response body.
187
+ # @return [Hash] the decoded object, or an empty hash.
188
+ def decode(raw) = decode_object(raw) || {}
189
+ end
190
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailkube
4
+ # What a cancellation reports back.
5
+ #
6
+ # A separate model from {ScheduledEmail} rather than a widened one: the server answers a
7
+ # cancellation with a three-field acknowledgement, not with the record, and a model mirrors the
8
+ # wire and nothing else.
9
+ class CanceledScheduledEmail < Data.define(:id, :object, :status)
10
+ # @param id [String] the canceled scheduled email's UUID.
11
+ # @param object [String] the resource discriminator, always `scheduled_email`.
12
+ # @param status [String] the resulting status, always `canceled`.
13
+ def initialize(id:, object: "scheduled_email", status: "canceled") = super
14
+
15
+ # @param payload [Hash{String => Object}] the decoded acknowledgement.
16
+ # @return [CanceledScheduledEmail] the model.
17
+ def self.from(payload)
18
+ new(id: payload["id"], object: payload["object"] || "scheduled_email",
19
+ status: payload["status"] || "canceled")
20
+ end
21
+ end
22
+
23
+ # What cancelling a whole batch reports back.
24
+ class ScheduledEmailBatchCancel < Data.define(:object, :batch_id, :canceled_count)
25
+ # @param object [String] the resource discriminator, always `scheduled_email.batch`.
26
+ # @param batch_id [String] the batch that was targeted.
27
+ # @param canceled_count [Integer] how many pending emails the cancellation affected. An
28
+ # unknown batch is a no-op reporting 0, **not** an error, so do not treat 0 as a failure.
29
+ def initialize(object: "scheduled_email.batch", batch_id: "", canceled_count: 0) = super
30
+
31
+ # @param payload [Hash{String => Object}] the decoded acknowledgement.
32
+ # @return [ScheduledEmailBatchCancel] the model.
33
+ def self.from(payload)
34
+ new(object: payload["object"] || "scheduled_email.batch", batch_id: payload["batch_id"] || "",
35
+ canceled_count: payload["canceled_count"] || 0)
36
+ end
37
+ end
38
+
39
+ # What rescheduling a whole batch reports back.
40
+ class ScheduledEmailBatchUpdate < Data.define(:object, :batch_id, :rescheduled_count, :scheduled_at)
41
+ # @param object [String] the resource discriminator, always `scheduled_email.batch`.
42
+ # @param batch_id [String] the batch that was targeted.
43
+ # @param rescheduled_count [Integer] how many pending emails moved. An unknown batch is a
44
+ # no-op reporting 0, **not** an error.
45
+ # @param scheduled_at [String, nil] the new due time applied to every moved email.
46
+ def initialize(object: "scheduled_email.batch", batch_id: "", rescheduled_count: 0, scheduled_at: nil)
47
+ super
48
+ end
49
+
50
+ # @param payload [Hash{String => Object}] the decoded acknowledgement.
51
+ # @return [ScheduledEmailBatchUpdate] the model.
52
+ def self.from(payload)
53
+ new(object: payload["object"] || "scheduled_email.batch", batch_id: payload["batch_id"] || "",
54
+ rescheduled_count: payload["rescheduled_count"] || 0, scheduled_at: payload["scheduled_at"])
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailkube
4
+ # One scheduled email, as the `scheduled-emails` collection reports it.
5
+ #
6
+ # Richer than the {Email} acknowledgement a scheduled send returns: that ack is lean by design,
7
+ # and these are the fields you get by asking the collection for the email afterwards.
8
+ #
9
+ # Timestamps stay the verbatim ISO-8601 strings the server sent; call `Time.iso8601` yourself if
10
+ # you want objects. `tags` are plain hashes rather than {Tag} values — see {Events::Node} for the
11
+ # rule: {Tag} is what you construct, a hash is what you read.
12
+ class ScheduledEmail < Data.define(:id, :message_id, :object, :status, :scheduled_at, :created_at,
13
+ :batch_id, :subject, :recipients, :topic, :tags)
14
+ # @param id [String] the scheduled email's UUID.
15
+ # @param message_id [String, nil] the RFC Message-ID.
16
+ # @param object [String] the resource discriminator, always `scheduled_email`.
17
+ # @param status [String] one of `scheduled`, `canceled`, `sent`, `failed`. A server-controlled
18
+ # string, deliberately not an enum: a value added later must not break a released client.
19
+ # @param scheduled_at [String, nil] when the send is due.
20
+ # @param created_at [String, nil] when the send was accepted.
21
+ # @param batch_id [String, nil] the batch label this send was grouped under.
22
+ # @param subject [String, nil] the subject line.
23
+ # @param recipients [String, nil] a recipient **summary**, not a list: `"a@b.com +2"` when
24
+ # there are more, the bare address when there is one, `""` when there are none. The full
25
+ # list stays server-side.
26
+ # @param topic [String, nil] the mailing-list topic slug.
27
+ # @param tags [Array<Hash{String => Object}>] the tags attached at send time, verbatim.
28
+ def initialize(id:, message_id: nil, object: "scheduled_email", status: "", scheduled_at: nil,
29
+ created_at: nil, batch_id: nil, subject: nil, recipients: nil, topic: nil, tags: [])
30
+ super
31
+ end
32
+
33
+ # @param payload [Hash{String => Object}] one decoded `scheduled_email` object.
34
+ # @return [ScheduledEmail] the model.
35
+ def self.from(payload)
36
+ new(id: payload["id"], message_id: payload["message_id"],
37
+ object: payload["object"] || "scheduled_email", status: payload["status"] || "",
38
+ scheduled_at: payload["scheduled_at"], created_at: payload["created_at"],
39
+ batch_id: payload["batch_id"], subject: payload["subject"],
40
+ recipients: payload["recipients"], topic: payload["topic"], tags: payload["tags"] || [])
41
+ end
42
+ end
43
+
44
+ # Links to the pages adjacent to the one in hand.
45
+ #
46
+ # The server **omits** a step at either end of the range rather than sending null, so an absent
47
+ # link and a nil value mean the same thing: there is no such page.
48
+ class PageSteps < Data.define(:next, :previous)
49
+ # @param next [String, nil] absolute URL of the following page, nil on the last page.
50
+ # @param previous [String, nil] absolute URL of the preceding page, nil on the first page.
51
+ def initialize(next: nil, previous: nil) = super
52
+
53
+ # @param payload [Hash{String => Object}, nil] the decoded `steps` block, which may be absent.
54
+ # @return [PageSteps] the model.
55
+ def self.from(payload) = new(next: (payload || {})["next"], previous: (payload || {})["previous"])
56
+ end
57
+
58
+ # The pagination block that accompanies every listing.
59
+ #
60
+ # Every member has a default because the server adds `total_count` and `current_page`
61
+ # conditionally, exactly as it does the step links.
62
+ class Pagination < Data.define(:steps, :total_count, :current_page)
63
+ # @param steps [PageSteps] links to the adjacent pages.
64
+ # @param total_count [Integer] matching records across every page.
65
+ # @param current_page [Integer] the 1-based number of the page in hand.
66
+ def initialize(steps: PageSteps.new, total_count: 0, current_page: 1) = super
67
+
68
+ # @param payload [Hash{String => Object}, nil] the decoded `pagination` block.
69
+ # @return [Pagination] the model.
70
+ def self.from(payload)
71
+ block = payload || {}
72
+ new(steps: PageSteps.from(block["steps"]), total_count: block["total_count"] || 0,
73
+ current_page: block["current_page"] || 1)
74
+ end
75
+ end
76
+
77
+ # One page of scheduled emails: the records, plus how to reach the neighbouring pages.
78
+ class ScheduledEmailPage < Data.define(:pagination, :data)
79
+ # @param pagination [Pagination] page metadata, including the adjacent-page links.
80
+ # @param data [Array<ScheduledEmail>] the scheduled emails on this page.
81
+ def initialize(pagination: Pagination.new, data: []) = super
82
+
83
+ # @param payload [Hash{String => Object}] the decoded listing body.
84
+ # @return [ScheduledEmailPage] the model.
85
+ def self.from(payload)
86
+ new(pagination: Pagination.from(payload["pagination"]),
87
+ data: (payload["data"] || []).map { |item| ScheduledEmail.from(item) })
88
+ end
89
+
90
+ # Spelled `more?` rather than `has_more`: Ruby's predicate convention, and the same call this
91
+ # gem already makes for `Email#scheduled?` where the other SDKs say `is_scheduled`.
92
+ #
93
+ # @return [Boolean] true when the server issued a link to a following page.
94
+ def more? = !pagination.steps.next.nil?
95
+ end
96
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailkube
4
+ # A file attached to an email.
5
+ #
6
+ # `content` is the raw bytes; the SDK base64-encodes them for the wire. `content_type` is
7
+ # inferred from the filename when omitted.
8
+ class Attachment < Data.define(:filename, :content, :content_type)
9
+ # @param filename [String] the name of the attached file.
10
+ # @param content [String] the raw file content.
11
+ # @param content_type [String, nil] the MIME type, inferred from the filename when nil.
12
+ def initialize(filename:, content:, content_type: nil) = super
13
+ end
14
+
15
+ # A free-form name/value tag attached to an outgoing email.
16
+ #
17
+ # Tags are forwarded to the server, which denormalizes them onto the sending log so you can
18
+ # filter, export and dashboard sends by tag, and so they ride along on delivery webhooks. Tag
19
+ # values are not encrypted, so do not put personal data in them.
20
+ class Tag < Data.define(:name, :value)
21
+ # @param name [String] the tag name.
22
+ # @param value [String] the tag value; it may be empty.
23
+ def initialize(name:, value: "") = super
24
+ end
25
+
26
+ # The result of a successful send.
27
+ #
28
+ # A **scheduled** send (one carrying `scheduled_at`) is acknowledged with 202 and a richer
29
+ # body; `status`, `scheduled_at` and `batch_id` are populated only then, and `#scheduled?` is
30
+ # the discriminator. An immediate send leaves all three nil.
31
+ #
32
+ # This is the worked example of the contract's **widen, never union** rule: one call can return
33
+ # two shapes, and adding optional fields plus a predicate keeps every existing caller valid,
34
+ # where returning one of two classes would not.
35
+ #
36
+ # Timestamps stay the verbatim ISO-8601 strings the server sent. The SDK does not validate or
37
+ # reinterpret server data; call `Time.iso8601` yourself if you want an object. Transport
38
+ # metadata (response headers, the request id) belongs on the exception, not here.
39
+ class Email < Data.define(:id, :message_id, :idempotent_replayed, :status, :scheduled_at, :batch_id)
40
+ # @param id [String] the accepted message's UUID.
41
+ # @param message_id [String, nil] the RFC Message-ID, when the deployment returns one.
42
+ # @param idempotent_replayed [Boolean] true when this replays an earlier identical request.
43
+ # @param status [String, nil] the scheduled email's status, on a scheduled ack only.
44
+ # @param scheduled_at [String, nil] when the send is due, on a scheduled ack only.
45
+ # @param batch_id [String, nil] the batch label the send was grouped under.
46
+ def initialize(id:, message_id: nil, idempotent_replayed: false, status: nil, scheduled_at: nil,
47
+ batch_id: nil)
48
+ super
49
+ end
50
+
51
+ # @return [Boolean] true when the send was accepted for later delivery rather than sent now.
52
+ def scheduled? = !scheduled_at.nil?
53
+ end
54
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailkube
4
+ # The released version of this gem.
5
+ #
6
+ # This constant is the **single source of truth** the contract requires: the gemspec reads it
7
+ # (`spec.version = Mailkube::VERSION`) and the User-Agent reads it, so the version on the wire
8
+ # and the version on rubygems.org cannot disagree.
9
+ #
10
+ # The value committed here is a permanent `0.0.0` placeholder and is never updated. On release,
11
+ # semantic-release rewrites this line **in the release runner** just before the gem is built, and
12
+ # commits nothing back to `main` (see `.rules/RELEASE.md`). So a checkout reports `0.0.0` and an
13
+ # installed gem reports the real version: that is intended, not a bug to fix by hardcoding one.
14
+ VERSION = "1.0.0"
15
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "time"
5
+ require "json"
6
+
7
+ module Mailkube
8
+ # Webhook signature verification.
9
+ #
10
+ # Verification is pure and dependency-free: no client instance, no configuration, so you call
11
+ # it directly inside your webhook handler.
12
+ module Webhooks
13
+ # How stale a webhook timestamp may be, in seconds, before it is rejected.
14
+ DEFAULT_TOLERANCE = 300
15
+ # The prefix the server puts before the hex digest in `X-Webhook-Sig`.
16
+ SIGNATURE_PREFIX = "sha256="
17
+ # Decode webhook payloads deep-frozen, so a receiver cannot mutate an event it is about to
18
+ # forward or log. Passed **positionally** because that is the shape `JSON.parse` declares
19
+ # (`(source, opts)`); as keywords Steep reports an unexpected keyword.
20
+ PARSE_OPTIONS = { freeze: true }.freeze
21
+
22
+ # Verify a webhook's signature and timestamp freshness over the raw body.
23
+ #
24
+ # The signed input is `"{id}.{timestamp}."` followed by the **raw** body, HMAC-SHA256 keyed
25
+ # by the endpoint's signing secret, hex-encoded, and sent as `X-Webhook-Sig: sha256=<hex>`.
26
+ # `X-Webhook-Ts` is an ISO-8601 timestamp checked for freshness; `X-Webhook-Id` is stable
27
+ # across retries, so use it to deduplicate.
28
+ #
29
+ # Verify against the bytes you received. Never parse the body and re-encode it first: JSON
30
+ # round-tripping reorders keys and normalizes whitespace, and the signature will not match.
31
+ # In Rails that means `request.raw_post`, not `params`.
32
+ #
33
+ # @param payload [String] the raw request body.
34
+ # @param headers [Hash{String => String}] the request headers, in any casing.
35
+ # @param secret [String] the endpoint's signing secret.
36
+ # @param tolerance [Integer] the freshness window in seconds.
37
+ # @return [String] the verified payload, so a caller can parse it in one expression.
38
+ # @raise [SignatureVerificationError] when a header is missing, the timestamp is stale, or
39
+ # the signature does not match.
40
+ def self.verify_signature(payload:, headers:, secret:, tolerance: DEFAULT_TOLERANCE)
41
+ lookup = normalize_headers(headers)
42
+ id = lookup["x-webhook-id"]
43
+ timestamp = lookup["x-webhook-ts"]
44
+ signature = lookup["x-webhook-sig"]
45
+ if id.nil? || timestamp.nil? || signature.nil?
46
+ raise SignatureVerificationError, "missing required webhook signature headers"
47
+ end
48
+
49
+ check_freshness(timestamp, tolerance)
50
+ check_signature(payload, id, timestamp, signature, secret)
51
+ payload
52
+ end
53
+
54
+ # Parse a raw webhook body into a typed event.
55
+ #
56
+ # An event type this release has never heard of comes back as {Events::UnknownEvent} rather
57
+ # than raising: a receiver keeps working when the platform adds a type, with no SDK upgrade.
58
+ # That is the contract's deliberate inversion of the response-model rules, and it is why the
59
+ # dispatch is a `fetch` with a default rather than a conditional — an unknown type is the last
60
+ # row of the table, not an error path.
61
+ #
62
+ # Unknown *fields* survive too, at every depth: see {Events::Node}.
63
+ #
64
+ # @param payload [String] the raw request body.
65
+ # @return [Events::Event] the parsed event; narrow it with `case` or `is_a?`.
66
+ # @raise [Error] when the body is not a JSON object.
67
+ def self.parse_event(payload)
68
+ body = JSON.parse(payload, PARSE_OPTIONS)
69
+ raise Error, "webhook payload is not a JSON object" unless body.is_a?(Hash)
70
+
71
+ Events::REGISTRY.fetch(body["type"], Events::UnknownEvent).new(body)
72
+ rescue JSON::ParserError => e
73
+ raise Error, "webhook payload is not valid JSON: #{e.message}"
74
+ end
75
+
76
+ # Verify a webhook's signature and return the parsed event.
77
+ #
78
+ # The combinator most handlers actually want. It composes cleanly only because
79
+ # {verify_signature} returns the verified payload rather than true.
80
+ #
81
+ # @param payload [String] the raw request body.
82
+ # @param headers [Hash{String => String}, Enumerable] the request headers, in any casing.
83
+ # @param secret [String] the endpoint's signing secret.
84
+ # @param tolerance [Integer] the freshness window in seconds.
85
+ # @return [Events::Event] the verified, parsed event.
86
+ # @raise [SignatureVerificationError] when verification fails.
87
+ # @raise [Error] when the verified body is not valid JSON.
88
+ def self.verify(payload:, headers:, secret:, tolerance: DEFAULT_TOLERANCE)
89
+ parse_event(verify_signature(payload: payload, headers: headers, secret: secret, tolerance: tolerance))
90
+ end
91
+
92
+ # Normalize a header mapping to lowercase, dashed names.
93
+ #
94
+ # This accepts more than a Hash on purpose. `ActionDispatch::Http::Headers` is `Enumerable`
95
+ # but **not** a Hash, so the obvious `headers.transform_keys` raises `NoMethodError` on
96
+ # `request.headers` — which is exactly what a Rails receiver passes. Nor is `to_h` a fix:
97
+ # Rails' `#each` yields raw CGI env names, so a `to_h`-based lookup sees `HTTP_X_WEBHOOK_SIG`
98
+ # and never matches `x-webhook-sig`.
99
+ #
100
+ # Stripping the `http_` prefix and swapping underscores for dashes maps both spellings onto
101
+ # one, so a plain Hash, a Rack env and `request.headers` all work.
102
+ #
103
+ # @param headers [Hash{String => String}, Enumerable] the request headers, in any casing.
104
+ # @return [Hash{String => String}] the headers keyed by lowercase dashed name.
105
+ def self.normalize_headers(headers)
106
+ # Iterated with a block rather than collected through `each.to_h`, because a mapping is only
107
+ # required to yield — it is not required to hand back an Enumerator when called bare.
108
+ normalized = {} #: Hash[String, String]
109
+ headers.each { |name, value| normalized[canonical_header(name)] = value.to_s }
110
+ normalized
111
+ end
112
+ private_class_method :normalize_headers
113
+
114
+ # @param name [Object] a header name in any casing, dashed or in CGI env form.
115
+ # @return [String] the lowercase, dashed form.
116
+ def self.canonical_header(name) = name.to_s.downcase.delete_prefix("http_").tr("_", "-")
117
+ private_class_method :canonical_header
118
+
119
+ # @param timestamp [String] the ISO-8601 `X-Webhook-Ts` value.
120
+ # @param tolerance [Integer] the freshness window in seconds.
121
+ # @raise [SignatureVerificationError] when malformed or outside the window.
122
+ def self.check_freshness(timestamp, tolerance)
123
+ begin
124
+ parsed = Time.iso8601(timestamp)
125
+ rescue ArgumentError
126
+ raise SignatureVerificationError, "malformed X-Webhook-Ts timestamp"
127
+ end
128
+
129
+ return if (Time.now - parsed).abs <= tolerance
130
+
131
+ raise SignatureVerificationError, "timestamp is outside the freshness window"
132
+ end
133
+ private_class_method :check_freshness
134
+
135
+ # @param payload [String] the raw body.
136
+ # @param id [String] the `X-Webhook-Id` value.
137
+ # @param timestamp [String] the `X-Webhook-Ts` value.
138
+ # @param signature [String] the `X-Webhook-Sig` value.
139
+ # @param secret [String] the signing secret.
140
+ # @raise [SignatureVerificationError] when the digests differ.
141
+ def self.check_signature(payload, id, timestamp, signature, secret)
142
+ expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{id}.#{timestamp}.#{payload}")
143
+ provided = signature.delete_prefix(SIGNATURE_PREFIX)
144
+ # Length is compared first because `fixed_length_secure_compare` raises on a mismatch, and
145
+ # the length of a hex digest is not a secret.
146
+ return if provided.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(provided, expected)
147
+
148
+ raise SignatureVerificationError, "signature mismatch"
149
+ end
150
+ private_class_method :check_signature
151
+ end
152
+ end
data/lib/mailkube.rb ADDED
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mailkube/version"
4
+ require_relative "mailkube/errors"
5
+ require_relative "mailkube/logging"
6
+ require_relative "mailkube/types"
7
+ require_relative "mailkube/types/scheduled_emails"
8
+ require_relative "mailkube/types/scheduled_acks"
9
+ require_relative "mailkube/serialization"
10
+ require_relative "mailkube/config"
11
+ require_relative "mailkube/transport"
12
+ require_relative "mailkube/net_http_adapter"
13
+ require_relative "mailkube/resources/emails"
14
+ require_relative "mailkube/resources/scheduled_email_requests"
15
+ require_relative "mailkube/resources/scheduled_emails"
16
+ require_relative "mailkube/client"
17
+ require_relative "mailkube/events/node"
18
+ require_relative "mailkube/events/contexts"
19
+ require_relative "mailkube/events/payloads"
20
+ require_relative "mailkube/events/envelopes"
21
+ require_relative "mailkube/events/registry"
22
+ require_relative "mailkube/webhooks"
23
+
24
+ # mailkube-ruby: The official Ruby SDK for mailkube
25
+ #
26
+ # client = Mailkube.new # reads MAILKUBE_API_KEY
27
+ # email = client.emails.send(
28
+ # from: "Acme <hello@yourdomain.com>",
29
+ # to: "customer@example.com",
30
+ # subject: "Hello world",
31
+ # html: "<p>It works!</p>"
32
+ # )
33
+ # email.id
34
+ #
35
+ # Errors form a hierarchy under {Error}, so rescue the category you care about:
36
+ #
37
+ # begin
38
+ # client.emails.send(...)
39
+ # rescue Mailkube::RateLimitError => e
40
+ # sleep(e.retry_after || 1)
41
+ # end
42
+ #
43
+ # The conventions every mailkube SDK shares are in `.rules/SDK_CONTRACT.md`; how they are
44
+ # realized in Ruby is in `.rules/SDK_DESIGN.md`.
45
+ module Mailkube
46
+ # The API base URL used when nothing else is configured.
47
+ DEFAULT_BASE_URL = "https://api.mailkube.com/mta/v1/"
48
+
49
+ # Create a {Client}. Sugar for `Mailkube::Client.new`, which is what most callers want.
50
+ #
51
+ # @param options [Hash] forwarded verbatim to {Client#initialize}.
52
+ # @return [Client] the new client.
53
+ def self.new(**options) = Client.new(**options)
54
+
55
+ # Turn SDK request logging on. Sugar for {Logging.enable!}.
56
+ #
57
+ # The device is anything responding to `#write(String)`, so an application passes its own
58
+ # logger rather than being handed one:
59
+ #
60
+ # Mailkube.enable_logging(device: Rails.logger)
61
+ #
62
+ # @param device [#write] where to write; defaults to `$stderr`.
63
+ # @return [#write] the device now in use.
64
+ def self.enable_logging(device: $stderr) = Logging.enable!(device: device)
65
+ end
66
+
67
+ # Honour `MAILKUBE_LOG` at load, so a deployment can turn logging on without a code change.
68
+ Mailkube::Logging.enable_from_env
@@ -0,0 +1,9 @@
1
+ module Mailkube
2
+ class Client
3
+ attr_reader emails: Resources::Emails
4
+ attr_reader scheduled_emails: Resources::ScheduledEmails
5
+
6
+ def initialize: (?api_key: String?, ?base_url: String?, ?timeout: Numeric, ?http: _HttpAdapter?) -> void
7
+ def base_url: () -> String
8
+ end
9
+ end
@@ -0,0 +1,14 @@
1
+ module Mailkube
2
+ class Config
3
+ ENV_API_KEY: String
4
+ ENV_BASE_URL: String
5
+ DEFAULT_TIMEOUT: Integer
6
+
7
+ attr_reader base_url: String
8
+ attr_reader timeout: Numeric
9
+
10
+ def initialize: (?api_key: String?, ?base_url: String?, ?timeout: Numeric) -> void
11
+ def default_headers: () -> Hash[String, String]
12
+ def build_url: (String, ?Hash[String, String]) -> String
13
+ end
14
+ end