thousandmails 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.
@@ -0,0 +1,287 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module ThousandMails
7
+ # The transport every resource goes through: URL building, auth header,
8
+ # timeout, retry policy, and turning a non-2xx response into a typed error.
9
+ class Http
10
+ RETRY_BASE_MS = 500
11
+ RETRY_MAX_MS = 8000
12
+
13
+ # Returned by retry_after_ms when the server's hint is longer than
14
+ # RETRY_MAX_MS. Distinct from nil, which means the response carried no hint
15
+ # at all. A real delay is never negative, so -1 is unambiguous.
16
+ TOO_LONG_TO_WAIT = -1
17
+
18
+ # Statuses worth trying again. 429 and 503 are both refusals issued before
19
+ # the handler ran (the rate limiter, and the attachment admission gate), so
20
+ # they are safe to retry whatever the method. A 5xx is ambiguous for a send —
21
+ # the mail may already be on its way — so those only retry when an
22
+ # idempotency key makes a repeat harmless.
23
+ ALWAYS_RETRY = [429, 503].freeze
24
+
25
+ attr_reader :api_key, :base_url, :timeout, :max_retries, :default_headers,
26
+ :user_agent, :transport
27
+ # Rate-limit headers from the most recent response, for callers that want to
28
+ # pace themselves rather than wait for a 429.
29
+ attr_reader :last_rate_limit
30
+
31
+ def initialize(api_key: nil, base_url: nil, timeout: nil, max_retries: nil,
32
+ headers: nil, user_agent: nil, transport: nil)
33
+ key = api_key || ENV["THOUSANDMAILS_API_KEY"]
34
+
35
+ if !key.is_a?(String) || key.strip.empty?
36
+ raise ConfigError, "An API key is required: ThousandMails::Client.new(api_key:) " \
37
+ "or the THOUSANDMAILS_API_KEY environment variable"
38
+ end
39
+
40
+ @api_key = key.strip
41
+ @base_url = self.class.normalise_base_url(
42
+ base_url.nil? || base_url.to_s.empty? ? Constants.default_base_url : base_url
43
+ )
44
+ # Per-attempt timeout in milliseconds; 0 disables. A retried request gets a
45
+ # fresh window rather than sharing one budget across the whole call.
46
+ @timeout = timeout.nil? ? Constants::DEFAULT_TIMEOUT_MS : timeout.to_i
47
+ @max_retries = max_retries.nil? ? Constants::DEFAULT_MAX_RETRIES : max_retries.to_i
48
+ @default_headers = headers || {}
49
+ @user_agent = user_agent || "thousandmails-ruby/#{ThousandMails::VERSION} ruby/#{RUBY_VERSION}"
50
+
51
+ if transport && !transport.is_a?(Transports::Transport)
52
+ raise ConfigError, "transport must be a ThousandMails::Transports::Transport"
53
+ end
54
+
55
+ @transport = transport || Transports::NetHttpTransport.new
56
+ @last_rate_limit = nil
57
+ end
58
+
59
+ # Absolute URL for a client-API path, with empty query params dropped.
60
+ def url(path, query = nil)
61
+ target = "#{base_url}#{Constants::CLIENT_PREFIX}#{path}"
62
+
63
+ pairs = (query || {}).reject { |_, value| value.nil? || value == "" }
64
+ return target if pairs.empty?
65
+
66
+ encoded = pairs.map do |name, value|
67
+ rendered = value == true ? "true" : (value == false ? "false" : value.to_s)
68
+ "#{URI.encode_www_form_component(name.to_s)}=#{URI.encode_www_form_component(rendered)}"
69
+ end
70
+
71
+ "#{target}?#{encoded.join('&')}"
72
+ end
73
+
74
+ # Perform one API call, retrying per the policy above.
75
+ #
76
+ # @param form [Array(String, String)] `[body, content_type]` for multipart
77
+ # @param parse [Symbol] :json decodes the body, :text returns it as a string
78
+ # @param retry_unsafe [Boolean] allow 5xx retries without an idempotency key
79
+ # @param timeout [Integer] overrides the client timeout; 0 disables it.
80
+ # Applies per attempt, so a retried request gets a fresh window.
81
+ # @return [Hash] `{ data:, status:, headers: }`
82
+ def request(http_method, path, query: nil, json: :none, form: nil,
83
+ idempotency_key: nil, parse: :json, retry_unsafe: false,
84
+ timeout: nil, headers: nil)
85
+ target = url(path, query)
86
+ effective_timeout = timeout.nil? ? @timeout : timeout.to_i
87
+ retryable = http_method == "GET" || !idempotency_key.nil? || retry_unsafe
88
+
89
+ attempt = 0
90
+
91
+ loop do
92
+ request_headers = {
93
+ "Authorization" => "Bearer #{api_key}",
94
+ "Accept" => parse == :text ? "text/csv, text/plain, */*" : "application/json",
95
+ "User-Agent" => user_agent
96
+ }
97
+ request_headers.merge!(default_headers)
98
+ request_headers.merge!(headers) if headers
99
+
100
+ body = nil
101
+ if form
102
+ body, request_headers["Content-Type"] = form
103
+ elsif json != :none
104
+ request_headers["Content-Type"] = "application/json"
105
+ body = JSON.generate(json)
106
+ end
107
+ request_headers["Idempotency-Key"] = idempotency_key if idempotency_key
108
+
109
+ begin
110
+ response = transport.send_request(
111
+ Transports::Request.new(http_method, target, request_headers, body, effective_timeout)
112
+ )
113
+ rescue ConnectionError
114
+ # A connection that never produced a response may still have delivered
115
+ # the request, so a send is only replayed when a key makes that safe.
116
+ # TimeoutError subclasses ConnectionError, so a request that timed out
117
+ # is covered by the same rule.
118
+ raise unless retryable && attempt < max_retries
119
+
120
+ sleep(self.class.backoff(attempt) / 1000.0)
121
+ attempt += 1
122
+ next
123
+ end
124
+
125
+ @last_rate_limit = self.class.read_rate_limit(response.headers)
126
+
127
+ if response.ok?
128
+ return {
129
+ data: self.class.read_body(response.body, parse),
130
+ status: response.status,
131
+ headers: response.headers
132
+ }
133
+ end
134
+
135
+ should_retry = attempt < max_retries &&
136
+ (ALWAYS_RETRY.include?(response.status) ||
137
+ (response.status >= 500 && retryable))
138
+
139
+ # A Retry-After longer than we are willing to wait means the window will
140
+ # still be shut when the retry lands, so the attempts would be spent for
141
+ # nothing. Give up now instead and let the caller pace itself off
142
+ # `error.retry_after`, which carries the server's real figure.
143
+ hinted = should_retry ? self.class.retry_after_ms(response.headers) : nil
144
+
145
+ if should_retry && hinted != TOO_LONG_TO_WAIT
146
+ sleep((hinted || self.class.backoff(attempt)) / 1000.0)
147
+ attempt += 1
148
+ next
149
+ end
150
+
151
+ raise Errors.from_response(
152
+ response.status,
153
+ self.class.read_body(response.body, :json),
154
+ response.headers,
155
+ http_method,
156
+ target
157
+ )
158
+ end
159
+ end
160
+
161
+ # `request`, for the endpoints that always answer with a JSON object.
162
+ #
163
+ # A 2xx whose body is empty or isn't JSON means something between here and
164
+ # the API rewrote the response — a proxy error page, a truncated reply. The
165
+ # resource methods are documented to return hashes, so without this the
166
+ # caller would get a NoMethodError from deep inside the SDK instead of
167
+ # something that names the problem.
168
+ def request_object(http_method, path, **options)
169
+ response = request(http_method, path, **options)
170
+ data = response[:data]
171
+ return data if data.is_a?(Hash)
172
+
173
+ shown = data.nil? ? "an empty body" : "a non-JSON body"
174
+ raise ServerError.new(
175
+ "The API returned #{shown} with status #{response[:status]}, " \
176
+ "where a JSON object was expected",
177
+ status: response[:status],
178
+ body: data,
179
+ headers: response[:headers],
180
+ http_method: http_method,
181
+ url: url(path, options[:query])
182
+ )
183
+ end
184
+
185
+ # Streaming GET — used by the SSE endpoint, which must not time out.
186
+ # Each chunk is handed to the block; returning false from it closes the
187
+ # connection.
188
+ def open(path, query: nil, headers: nil, &on_chunk)
189
+ target = url(path, query)
190
+
191
+ request_headers = {
192
+ "Authorization" => "Bearer #{api_key}",
193
+ "Accept" => "text/event-stream",
194
+ "Cache-Control" => "no-cache",
195
+ "User-Agent" => user_agent
196
+ }
197
+ request_headers.merge!(default_headers)
198
+ request_headers.merge!(headers) if headers
199
+
200
+ response = transport.stream(
201
+ Transports::Request.new("GET", target, request_headers, nil, 0), &on_chunk
202
+ )
203
+
204
+ return if response.nil? || response.ok?
205
+
206
+ raise Errors.from_response(
207
+ response.status,
208
+ self.class.read_body(response.body, :json),
209
+ response.headers,
210
+ "GET",
211
+ target
212
+ )
213
+ end
214
+
215
+ class << self
216
+ # Percent-encode one path segment, RFC 3986 style.
217
+ #
218
+ # Not URI.encode_www_form_component, which spells a space "+" — correct in
219
+ # a query string, wrong in a path, where the server would read it back as a
220
+ # literal plus. An SMTP message id (`<uuid@domain>`) goes through here.
221
+ def encode_path_segment(value)
222
+ value.to_s.b.gsub(/[^a-zA-Z0-9\-._~]/) { |char| format("%%%02X", char.ord) }
223
+ end
224
+
225
+ # Exponential with full jitter, so a fleet retrying together spreads out.
226
+ # @return [Integer] milliseconds to wait
227
+ def backoff(attempt)
228
+ ceiling = [RETRY_BASE_MS * (2**attempt), RETRY_MAX_MS].min
229
+ (ceiling * (0.5 + (rand * 0.5))).round
230
+ end
231
+
232
+ # A base_url that isn't an absolute URL is a configuration mistake, and it
233
+ # has to be reported as one here. Left to the first request it would
234
+ # surface as a URI error from inside the SDK, on a call that looks like it
235
+ # failed for some other reason entirely.
236
+ def normalise_base_url(base_url)
237
+ trimmed = base_url.to_s.strip.sub(%r{/+\z}, "")
238
+
239
+ parsed = begin
240
+ URI.parse(trimmed)
241
+ rescue URI::InvalidURIError
242
+ nil
243
+ end
244
+
245
+ unless parsed && %w[http https].include?(parsed.scheme) && parsed.host
246
+ raise ConfigError, "base_url is not a usable absolute URL: \"#{base_url}\""
247
+ end
248
+
249
+ trimmed
250
+ end
251
+
252
+ def read_body(raw, parse)
253
+ text = raw.to_s
254
+ return text if parse == :text
255
+ return nil if text.empty?
256
+
257
+ begin
258
+ JSON.parse(text)
259
+ rescue JSON::ParserError
260
+ text
261
+ end
262
+ end
263
+
264
+ def read_rate_limit(headers)
265
+ limit = headers["ratelimit-limit"]
266
+ return nil if limit.nil?
267
+
268
+ {
269
+ "limit" => limit.to_i,
270
+ "remaining" => headers["ratelimit-remaining"].to_i,
271
+ "reset" => headers["ratelimit-reset"].to_i
272
+ }
273
+ end
274
+
275
+ # How long the server asked us to wait, in ms — or TOO_LONG_TO_WAIT when
276
+ # that exceeds the retry ceiling, or nil when it said nothing.
277
+ def retry_after_ms(headers)
278
+ seconds = Errors.parse_retry_after(headers)
279
+ return nil if seconds.nil?
280
+
281
+ # A date already in the past, or a negative delay, means "now".
282
+ ms = [0.0, seconds].max * 1000
283
+ ms > RETRY_MAX_MS ? TOO_LONG_TO_WAIT : ms.to_i
284
+ end
285
+ end
286
+ end
287
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThousandMails
4
+ # Reconciles the two ways a Ruby caller naturally passes a payload.
5
+ #
6
+ # Ruby folds bare keyword arguments into a positional Hash only for methods
7
+ # that take no keywords of their own. Every method here takes options
8
+ # (`timeout:`, `idempotency_key:`), so the natural call
9
+ #
10
+ # client.send_mail(senderemail: "...", to: "...", subject: "...")
11
+ #
12
+ # would otherwise drop the whole message into the options bucket and leave the
13
+ # payload empty. Folding the leftover keywords back into the payload puts them
14
+ # where the caller plainly meant them, while an explicit hash still works when
15
+ # options travel alongside:
16
+ #
17
+ # client.send_mail({ senderemail: "...", to: "..." }, timeout: 5_000)
18
+ module Options
19
+ module_function
20
+
21
+ def merge(payload, extra)
22
+ base = payload.nil? ? {} : payload
23
+ return base if extra.nil? || extra.empty?
24
+ return extra unless base.is_a?(Hash)
25
+
26
+ base.merge(extra)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ThousandMails
6
+ # Server-Sent Events client for GET /client/realtime/stream.
7
+ #
8
+ # The endpoint emits a `ready` frame on connect, an `email` frame per recorded
9
+ # event, and a `: ping` comment every 25s to keep proxies from closing an idle
10
+ # connection. Anything that isn't one of those is surfaced as a raw frame
11
+ # rather than dropped, so a new server-side event type doesn't need an SDK
12
+ # release.
13
+ #
14
+ # Usage — either style:
15
+ #
16
+ # stream = thousandmails.realtime.stream
17
+ # stream.on("email") { |event| puts event["type"] }
18
+ # stream.listen
19
+ #
20
+ # thousandmails.realtime.stream.events.each { |event| ... }
21
+ #
22
+ # The Node SDK exposes these as an EventEmitter and an async iterable. Ruby has
23
+ # no event loop in play here, so `listen` blocks and dispatches to handlers
24
+ # while `events` returns an Enumerator that yields one event at a time. Neither
25
+ # queues anything: an event is handed to exactly one consumer and then dropped,
26
+ # so a stream held open for days does not grow.
27
+ class EventStream
28
+ # Set when the loop stopped because of a failure it could not recover from.
29
+ attr_reader :error
30
+
31
+ def initialize(http, reconnect: true, max_reconnects: nil,
32
+ on_event: nil, on_ready: nil, on_error: nil)
33
+ @http = http
34
+ @reconnect = reconnect != false
35
+ @max_reconnects = max_reconnects || Float::INFINITY
36
+
37
+ @listeners = Hash.new { |hash, key| hash[key] = [] }
38
+ @closed = false
39
+ @connected = false
40
+ @ended = false
41
+ @error = nil
42
+ # The last `id:` the server labelled a frame with, replayed as
43
+ # Last-Event-ID on reconnect. See connect_and_read.
44
+ @last_event_id = nil
45
+
46
+ on("email", &on_event) if on_event
47
+ on("ready", &on_ready) if on_ready
48
+ on("error", &on_error) if on_error
49
+ end
50
+
51
+ # Register a handler. Event names: ready, email, heartbeat, frame, open,
52
+ # close, error, end.
53
+ #
54
+ # Unlike the Node SDK, a stream with no `error` handler is not itself an
55
+ # error — an unhandled failure is reported through `end` and, for `events`,
56
+ # raised at the consumer.
57
+ def on(event, &listener)
58
+ @listeners[event.to_s] << listener
59
+ self
60
+ end
61
+
62
+ def connected?
63
+ @connected
64
+ end
65
+
66
+ def closed?
67
+ @closed
68
+ end
69
+
70
+ # Stop listening and close the underlying connection. Safe to call from a
71
+ # handler, from another thread, or twice.
72
+ #
73
+ # From a handler, or by breaking out of `events`, this takes effect at once.
74
+ # From another thread it takes effect when the next frame arrives, because
75
+ # the reader is parked on a blocking socket read until then — at worst one
76
+ # heartbeat interval, which the server sends every 25 seconds. `closed?` is
77
+ # true immediately either way; it is only the socket that lingers.
78
+ def close
79
+ return if @closed
80
+
81
+ @closed = true
82
+ finish
83
+ nil
84
+ end
85
+
86
+ # Run the stream, dispatching to the handlers registered with `on`.
87
+ #
88
+ # Blocks until the connection ends and reconnection is exhausted or
89
+ # disabled, or until a handler calls `close`.
90
+ def listen
91
+ frames { |name, payload| emit(name, payload) }
92
+ nil
93
+ ensure
94
+ finish
95
+ end
96
+
97
+ # The same loop, as an Enumerator of `email` events.
98
+ #
99
+ # Handlers registered with `on` still fire, so a caller can iterate the email
100
+ # events while watching `error` or `heartbeat` on the side. Breaking out of
101
+ # the loop closes the connection.
102
+ def events
103
+ Enumerator.new do |yielder|
104
+ begin
105
+ frames do |name, payload|
106
+ emit(name, payload)
107
+ yielder << payload if name == "email" && !@closed
108
+ end
109
+ ensure
110
+ # A `break` in the caller's loop lands here, which would otherwise
111
+ # leave the socket open with nobody reading it.
112
+ close
113
+ end
114
+
115
+ raise @error if @error
116
+ end
117
+ end
118
+
119
+ private
120
+
121
+ # The reconnect loop, yielding `[name, payload]` for every frame. Both entry
122
+ # points drive this; they differ only in what they do with a frame once it
123
+ # arrives.
124
+ def frames(&dispatch)
125
+ failures = 0
126
+
127
+ until @closed
128
+ dropped = false
129
+
130
+ begin
131
+ connect_and_read(&dispatch)
132
+ rescue ThousandMails::Error => e
133
+ break if @closed
134
+
135
+ dropped = true
136
+ failures += 1
137
+ dispatch.call("error", e)
138
+
139
+ # An auth failure will never resolve itself, so retrying it only burns
140
+ # the rate-limit budget.
141
+ status = e.respond_to?(:status) ? e.status : nil
142
+ fatal = status == 401 || status == 403 || !@reconnect
143
+
144
+ if fatal || failures >= @max_reconnects
145
+ @error = e
146
+ return
147
+ end
148
+ end
149
+
150
+ dispatch.call("close", nil) unless dropped
151
+ break if @closed || !@reconnect
152
+
153
+ sleep(Http.backoff([failures, 4].min) / 1000.0)
154
+ end
155
+ end
156
+
157
+ # Open the connection and dispatch complete frames. SSE frames are separated
158
+ # by a blank line; a chunk boundary can land anywhere, so partial frames stay
159
+ # in the buffer until their terminator arrives.
160
+ def connect_and_read(&dispatch)
161
+ # Where to resume from, per the SSE spec. The server does not label frames
162
+ # with `id:` today, so this stays unset and a reconnect picks up from the
163
+ # live edge — events that occurred during the gap are missed. Reconcile
164
+ # with client.logs when that matters. The moment the server starts
165
+ # labelling frames, this resumes without an SDK release.
166
+ headers = @last_event_id ? { "Last-Event-ID" => @last_event_id } : nil
167
+
168
+ pending = +""
169
+ opened = false
170
+
171
+ @http.open("/realtime/stream", headers: headers) do |chunk|
172
+ unless opened
173
+ opened = true
174
+ @connected = true
175
+ dispatch.call("open", nil)
176
+ end
177
+
178
+ # Normalised on the whole buffer, not per chunk, so a CRLF split across a
179
+ # chunk boundary still reads as one line ending.
180
+ pending << chunk.to_s.dup.force_encoding(Encoding::UTF_8)
181
+ pending = pending.gsub("\r\n", "\n")
182
+
183
+ while (split = pending.index("\n\n"))
184
+ frame = pending[0, split]
185
+ pending = pending[(split + 2)..]
186
+
187
+ parsed, event_id = self.class.parse_frame(frame)
188
+ @last_event_id = event_id if event_id
189
+ parsed.each { |name, payload| dispatch.call(name, payload) }
190
+
191
+ break if @closed
192
+ end
193
+
194
+ !@closed
195
+ end
196
+ ensure
197
+ @connected = false
198
+ end
199
+
200
+ def emit(event, payload)
201
+ @listeners[event].each { |listener| listener.call(payload) }
202
+ end
203
+
204
+ # Called from both close and the end of a run, so it has to be idempotent —
205
+ # a consumer must not see two `end` events for one stream.
206
+ def finish
207
+ @closed = true
208
+ return if @ended
209
+
210
+ @ended = true
211
+ emit("end", @error)
212
+ end
213
+
214
+ # One SSE frame, as the events it should be dispatched under.
215
+ #
216
+ # A named frame produces two: a generic `frame` carrying the envelope, then
217
+ # the frame's own name. A comment frame produces only `heartbeat`.
218
+ #
219
+ # @return [Array] `[frames, event_id]`
220
+ def self.parse_frame(frame)
221
+ # A comment frame (": ping") is the heartbeat — proof of life, nothing more.
222
+ return [[["heartbeat", nil]], nil] if frame.strip.empty? || frame.start_with?(":")
223
+
224
+ name = "message"
225
+ data = []
226
+ event_id = nil
227
+
228
+ frame.split("\n").each do |line|
229
+ if line.start_with?("event:")
230
+ name = line[6..].strip
231
+ elsif line.start_with?("data:")
232
+ data << line[5..].sub(/\A /, "")
233
+ elsif line.start_with?("id:")
234
+ # The spec says a field value containing NUL is ignored rather than
235
+ # stored, and that the id survives the frame it arrived on.
236
+ candidate = line[3..].strip
237
+ event_id = candidate unless candidate.empty? || candidate.include?("\0")
238
+ end
239
+ end
240
+
241
+ raw = data.join("\n")
242
+ payload = begin
243
+ JSON.parse(raw)
244
+ rescue JSON::ParserError
245
+ # Leave it as text — a frame the SDK doesn't model is still delivered.
246
+ raw
247
+ end
248
+
249
+ [[["frame", { "event" => name, "data" => payload }], [name, payload]], event_id]
250
+ end
251
+ end
252
+ end