naijacloud-email 0.3.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,361 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "openssl"
7
+ require "time"
8
+ require "timeout"
9
+
10
+ module NaijaCloud
11
+ module Email
12
+ # The only place in the gem that touches the network.
13
+ #
14
+ # net/http from the standard library, no gem: this package holds a live
15
+ # sending credential, so every third-party runtime dependency is another
16
+ # maintainer who could ship a post-install script into a process that can
17
+ # mail as a customer's verified domain. A faster HTTP client is not worth
18
+ # that trade.
19
+ class Transport
20
+ # Full-jitter backoff, per section 4 of the SDK contract.
21
+ RETRY_BASE_SECONDS = 0.5
22
+ RETRY_CAP_SECONDS = 8.0
23
+
24
+ # A server that asks for an hour is almost always a misconfiguration, and a
25
+ # caller blocked for an hour inside a web request is an outage. Honour the
26
+ # header, but not past a minute.
27
+ RETRY_AFTER_MAX_SECONDS = 60.0
28
+
29
+ RETRYABLE_STATUSES = [408, 429].freeze
30
+
31
+ # Raised by the per-attempt deadline. Its own class so it cannot be
32
+ # confused with a Timeout::Error raised by anything else, and so it is
33
+ # rescued exactly where the deadline is set.
34
+ class AttemptDeadline < StandardError; end
35
+
36
+ # Test seams. Overridden by the suite so retry timing is asserted rather
37
+ # than waited out; there is no public constructor option for them because a
38
+ # caller who can replace `sleep` can turn the backoff into a hot loop
39
+ # against production.
40
+ attr_writer :sleeper, :jitter
41
+
42
+ def initialize(api_key:, base_url:, timeout:, max_retries:, user_agent:)
43
+ @api_key = api_key
44
+ @base_uri = base_url
45
+ @timeout = timeout
46
+ @max_retries = max_retries
47
+ @user_agent = user_agent
48
+ @sleeper = ->(seconds) { sleep(seconds) }
49
+ @jitter = ->(ceiling) { Kernel.rand * ceiling }
50
+ end
51
+
52
+ # `body` arrives already serialized: the caller has to measure the encoded
53
+ # payload against the 10 MiB limit anyway, and serializing a 10 MiB body
54
+ # twice to avoid passing a String is a poor trade.
55
+ #
56
+ # `require_string`: a key the 2xx body must carry as a non-empty String
57
+ # (the send response's `id`). A success without it is a malformed
58
+ # response, raised as ServerError and not retried.
59
+ def post(path, body, extra_headers = {}, require_string: nil)
60
+ execute(:post, path, body, extra_headers, require_string)
61
+ end
62
+
63
+ def get(path, extra_headers = {})
64
+ execute(:get, path, nil, extra_headers, nil)
65
+ end
66
+
67
+ # The key lives in this object, so both of these are overridden. Ruby prints
68
+ # `inspect` for an object in an unhandled-exception trace and in irb, which
69
+ # is how a bearer token ends up pasted into a GitHub issue.
70
+ #
71
+ # Fully qualified on purpose: inside this namespace the bare constant
72
+ # `Email` resolves to the Email *response class*, not to this module.
73
+ def inspect
74
+ "#<NaijaCloud::Email::Transport base_url=#{@base_uri} " \
75
+ "api_key=#{NaijaCloud::Email.redact_key(@api_key)}>"
76
+ end
77
+ alias to_s inspect
78
+
79
+ # The same for dumps. YAML.dump(client.emails) reaches this object, and
80
+ # Psych writes every instance variable unless encode_with says otherwise.
81
+ def marshal_dump
82
+ raise Error.new("a NaijaCloud::Email::Transport holds an API key and must not be serialized")
83
+ end
84
+
85
+ def encode_with(_coder)
86
+ raise Error.new("a NaijaCloud::Email::Transport holds an API key and must not be serialized")
87
+ end
88
+
89
+ private
90
+
91
+ def execute(method, path, body, extra_headers, require_string)
92
+ attempt = 0
93
+
94
+ loop do
95
+ error = nil
96
+ retryable = false
97
+ retry_after = nil
98
+
99
+ begin
100
+ response = perform(method, path, body, extra_headers)
101
+ status = response.code.to_i
102
+
103
+ return decode_success(response, status, require_string) if status >= 200 && status < 300
104
+
105
+ retry_after = parse_retry_after(response["retry-after"])
106
+ error = build_error(response, status, retry_after)
107
+ retryable = retryable_status?(status)
108
+ rescue AttemptDeadline, Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Timeout::Error => e
109
+ # A client-side deadline. The request may well have reached the
110
+ # server, which is why every send carries an idempotency key before
111
+ # the first attempt rather than after the first failure.
112
+ error = TimeoutError.new("request timed out after #{@timeout}s (#{e.class})")
113
+ retryable = true
114
+ rescue SocketError, SystemCallError, OpenSSL::SSL::SSLError, IOError,
115
+ Net::HTTPBadResponse, Net::ProtocolError => e
116
+ # Net::HTTPBadResponse and Net::ProtocolError are in this list because
117
+ # a proxy answering with garbage instead of a status line would
118
+ # otherwise escape as a raw net/http exception, and the promise this
119
+ # SDK makes is that one `rescue NaijaCloud::Email::Error` covers
120
+ # every failure mode.
121
+ error = ConnectionError.new("could not reach #{@base_uri.host}: #{e.class}: #{e.message}")
122
+ retryable = true
123
+ end
124
+
125
+ raise error unless retryable && attempt < @max_retries
126
+
127
+ @sleeper.call(delay_for(attempt, retry_after))
128
+ attempt += 1
129
+ end
130
+ end
131
+
132
+ # One attempt, bounded by one deadline covering connect, write and the
133
+ # whole response read. net/http's own timeouts are per socket operation, so
134
+ # a server trickling a byte every few seconds would never trip
135
+ # read_timeout and could hold a caller for ever; the outer deadline is what
136
+ # makes `timeout` mean "this attempt takes at most N seconds".
137
+ def perform(method, path, body, extra_headers)
138
+ Timeout.timeout(@timeout, AttemptDeadline) do
139
+ perform_attempt(method, path, body, extra_headers)
140
+ end
141
+ end
142
+
143
+ def perform_attempt(method, path, body, extra_headers)
144
+ uri = request_uri(path)
145
+
146
+ # #hostname, not #host: #host keeps the brackets on an IPv6 literal, and
147
+ # Net::HTTP would then try to resolve "[::1]" and never connect.
148
+ http = Net::HTTP.new(uri.hostname, uri.port)
149
+ http.use_ssl = uri.scheme == "https"
150
+ # Set explicitly rather than relying on the default. OpenSSL's ambient
151
+ # configuration can be changed by anything else loaded in the process, and
152
+ # a client that silently stops verifying certificates is the worst kind of
153
+ # regression: it keeps working.
154
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
155
+ http.open_timeout = @timeout
156
+ http.read_timeout = @timeout
157
+ http.write_timeout = @timeout if http.respond_to?(:write_timeout=)
158
+
159
+ # net/http retries idempotent requests once on its own by default. Left
160
+ # alone it would silently double the attempt count and re-send a POST
161
+ # that may already have been accepted, outside the retry accounting in
162
+ # this file. Our policy is the only one.
163
+ http.max_retries = 0
164
+
165
+ # Never call http.set_debug_output: it writes every header, including
166
+ # Authorization, to whatever IO it is given. There is deliberately no
167
+ # verbose mode in this gem for that reason.
168
+
169
+ request = build_request(method, uri, body, extra_headers)
170
+ http.start { |conn| conn.request(request) }
171
+ end
172
+
173
+ def build_request(method, uri, body, extra_headers)
174
+ klass = method == :post ? Net::HTTP::Post : Net::HTTP::Get
175
+ request = klass.new(uri.request_uri)
176
+
177
+ request["Authorization"] = "Bearer #{@api_key}"
178
+ request["Accept"] = "application/json"
179
+ request["User-Agent"] = @user_agent
180
+
181
+ extra_headers.each { |name, value| request[name] = value }
182
+
183
+ if body
184
+ request["Content-Type"] = "application/json"
185
+ request.body = body
186
+ end
187
+
188
+ request
189
+ end
190
+
191
+ def request_uri(path)
192
+ uri = @base_uri.dup
193
+ base_path = uri.path.to_s.sub(%r{/+\z}, "")
194
+ uri.path = "#{base_path}#{path}"
195
+ uri
196
+ end
197
+
198
+ def decode_success(response, status, require_string)
199
+ parsed = parse_json(response.body)
200
+
201
+ unless parsed.is_a?(Hash)
202
+ raise ServerError.new(
203
+ "malformed response: expected a JSON object from the API, got #{status_line(response, status)}",
204
+ status_code: status,
205
+ request_id: response["x-request-id"],
206
+ body: response.body,
207
+ parsed_body: parsed,
208
+ retryable: false,
209
+ )
210
+ end
211
+
212
+ if require_string && !(parsed[require_string].is_a?(String) && !parsed[require_string].empty?)
213
+ raise ServerError.new(
214
+ "malformed response: \"#{require_string}\" is missing",
215
+ status_code: status,
216
+ request_id: response["x-request-id"],
217
+ body: response.body,
218
+ parsed_body: parsed,
219
+ retryable: false,
220
+ )
221
+ end
222
+
223
+ parsed
224
+ end
225
+
226
+ def build_error(response, status, retry_after)
227
+ # net/http does not follow redirects unless you write the loop yourself,
228
+ # so nothing here has to be switched off -- but a 3xx is still turned into
229
+ # a hard error rather than left to fall through as "some other status",
230
+ # because the reason it must never be followed is worth stating in the
231
+ # error a caller actually sees: following it would re-send this
232
+ # Authorization header to whatever host the response named.
233
+ if status >= 300 && status < 400
234
+ return ServerError.new(
235
+ "unexpected redirect (#{status}) from #{@base_uri.host}; the API does not redirect and " \
236
+ "this client will not resend credentials to another host",
237
+ status_code: status,
238
+ request_id: response["x-request-id"],
239
+ body: response.body,
240
+ parsed_body: parse_json(response.body),
241
+ retryable: false,
242
+ )
243
+ end
244
+
245
+ parsed = parse_json(response.body)
246
+ payload = parsed.is_a?(Hash) ? parsed : {}
247
+
248
+ message = error_message(payload, response, status)
249
+ common = {
250
+ status_code: status,
251
+ error_label: payload["error"],
252
+ request_id: response["x-request-id"],
253
+ body: response.body,
254
+ parsed_body: parsed,
255
+ }
256
+
257
+ case status
258
+ when 400
259
+ # A known control-plane quirk: an unknown message id answers 400 with
260
+ # this exact string instead of 404. Matched on the message because it is
261
+ # the only thing that distinguishes it, and mapped here so callers write
262
+ # one rescue that keeps working when the server is fixed.
263
+ if message.to_s.downcase.include?("message not found")
264
+ NotFoundError.new(message, **common)
265
+ else
266
+ ValidationError.new(message, **common)
267
+ end
268
+ when 401 then AuthenticationError.new(message, **common)
269
+ when 403 then PermissionError.new(message, **common)
270
+ when 404 then NotFoundError.new(message, **common)
271
+ when 408 then TimeoutError.new(message, **common)
272
+ when 409 then ConflictError.new(message, **common)
273
+ # 413 is the server's body parser refusing an oversized request: the
274
+ # caller's input, and no retry will shrink it.
275
+ when 413, 422 then ValidationError.new(message, **common)
276
+ when 429 then RateLimitError.new(message, retry_after: retry_after, **common)
277
+ else
278
+ if status >= 500
279
+ ServerError.new(message, **common)
280
+ else
281
+ # Any other 4xx (405, 415, 451...): the request as sent will never
282
+ # succeed, which is what ValidationError means to a caller. Same in
283
+ # all five SDKs (contract section 3).
284
+ ValidationError.new(message, **common)
285
+ end
286
+ end
287
+ end
288
+
289
+ # NestJS sends `message` as either a string or an array of strings (one per
290
+ # failed validation rule). A body that is not JSON at all -- a proxy's HTML
291
+ # error page, or nothing -- is the case that matters most: it happens when
292
+ # something between the caller and us is broken, and an SDK that raises
293
+ # JSON::ParserError there hides the status code that would have explained it.
294
+ def error_message(payload, response, status)
295
+ message = payload["message"]
296
+
297
+ case message
298
+ when Array
299
+ strings = message.map(&:to_s).reject(&:empty?)
300
+ strings.empty? ? status_line(response, status) : strings.join("; ")
301
+ when String
302
+ message.empty? ? status_line(response, status) : message
303
+ else
304
+ status_line(response, status)
305
+ end
306
+ end
307
+
308
+ def status_line(response, status)
309
+ reason = response.message.to_s.strip
310
+ reason.empty? ? "HTTP #{status}" : "HTTP #{status} #{reason}"
311
+ end
312
+
313
+ def parse_json(body)
314
+ return nil if body.nil? || body.empty?
315
+
316
+ JSON.parse(body)
317
+ rescue JSON::ParserError
318
+ nil
319
+ end
320
+
321
+ def retryable_status?(status)
322
+ RETRYABLE_STATUSES.include?(status) || status >= 500
323
+ end
324
+
325
+ # Retry-After is defined as either a delta in seconds or an HTTP date, and
326
+ # real proxies send both. Anything unparseable is ignored rather than
327
+ # treated as zero, so a malformed header falls back to normal backoff
328
+ # instead of turning a rate limit into a tight retry loop.
329
+ def parse_retry_after(value)
330
+ return nil if value.nil?
331
+
332
+ raw = value.to_s.strip
333
+ return nil if raw.empty?
334
+
335
+ seconds =
336
+ if raw =~ /\A\d+\z/
337
+ raw.to_i.to_f
338
+ else
339
+ begin
340
+ Time.httpdate(raw) - Time.now
341
+ rescue ArgumentError
342
+ return nil
343
+ end
344
+ end
345
+
346
+ seconds = 0.0 if seconds.negative?
347
+ [seconds, RETRY_AFTER_MAX_SECONDS].min
348
+ end
349
+
350
+ def delay_for(attempt, retry_after)
351
+ return retry_after if retry_after
352
+
353
+ # Full jitter. The alternative -- every client sleeping the same
354
+ # exponential interval -- rebuilds the thundering herd that caused the
355
+ # 429 in the first place.
356
+ ceiling = [RETRY_CAP_SECONDS, RETRY_BASE_SECONDS * (2**attempt)].min
357
+ @jitter.call(ceiling)
358
+ end
359
+ end
360
+ end
361
+ end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NaijaCloud
4
+ module Email
5
+ # The statuses the API uses today, lowercase exactly as they appear on the
6
+ # wire. Frozen constants rather than symbols so a comparison against a value
7
+ # decoded from JSON needs no conversion at the call site.
8
+ #
9
+ # Deliberately NOT an exhaustive enum in the type sense: `Email#status` hands
10
+ # back whatever the server sent. A status added server-side must reach a
11
+ # caller running last year's gem as a plain string, not as a crash -- the
12
+ # alternative is that shipping a new event type breaks every old SDK at once.
13
+ module MessageStatus
14
+ QUEUED = "queued"
15
+ SENT = "sent"
16
+ DELIVERED = "delivered"
17
+ BOUNCED = "bounced"
18
+ DEFERRED = "deferred"
19
+ COMPLAINED = "complained"
20
+ REJECTED = "rejected"
21
+ FAILED = "failed"
22
+
23
+ ALL = [QUEUED, SENT, DELIVERED, BOUNCED, DEFERRED, COMPLAINED, REJECTED, FAILED].freeze
24
+
25
+ # For callers that want to branch on "something I have never seen" rather
26
+ # than assume the list above is closed.
27
+ def self.known?(status)
28
+ ALL.include?(status)
29
+ end
30
+ end
31
+
32
+ # Base for the response objects. Named BaseObject rather than Object because
33
+ # a class called Object inside this namespace would shadow ::Object for every
34
+ # other file in the gem -- a subtle way to break `is_a?(Object)` checks and
35
+ # anything that rescues on a bare constant.
36
+ #
37
+ # `from_hash` ignores keys it does not know on purpose. The control plane
38
+ # ships ahead of the SDKs, so a field added there must be a no-op for a
39
+ # customer pinned to an old gem version, not a NoMethodError in their
40
+ # webhook handler at 3am. `#raw` keeps the full body so that customer can
41
+ # still read a new field before we cut a release.
42
+ class BaseObject
43
+ attr_reader :raw
44
+
45
+ def initialize(raw = {})
46
+ @raw = raw.freeze
47
+ end
48
+
49
+ def to_h
50
+ @raw
51
+ end
52
+
53
+ # Hash access to the decoded body, for fields this version predates.
54
+ def [](key)
55
+ @raw[key.to_s]
56
+ end
57
+ end
58
+
59
+ # A recipient the server refused before sending -- today only because the
60
+ # address is on the team's suppression list.
61
+ class RejectedRecipient < BaseObject
62
+ attr_reader :address, :reason
63
+
64
+ def self.from_hash(hash)
65
+ hash = {} unless hash.is_a?(::Hash)
66
+ new(hash)
67
+ end
68
+
69
+ def initialize(raw = {})
70
+ super
71
+ @address = raw["address"]
72
+ @reason = raw["reason"]
73
+ end
74
+
75
+ def inspect
76
+ "#<NaijaCloud::Email::RejectedRecipient address=#{address.inspect} reason=#{reason.inspect}>"
77
+ end
78
+ end
79
+
80
+ # The 202 body from POST /v1/emails.
81
+ class SendEmailResponse < BaseObject
82
+ attr_reader :id, :status, :rejected
83
+
84
+ def self.from_hash(hash)
85
+ hash = {} unless hash.is_a?(::Hash)
86
+ new(hash)
87
+ end
88
+
89
+ def initialize(raw = {})
90
+ super
91
+ @id = raw["id"]
92
+ @status = raw["status"]
93
+ # The server omits `rejected` entirely when nobody was refused. Normalise
94
+ # to an empty array so callers write `if resp.rejected.any?` and never
95
+ # `resp.rejected&.any?` -- and so nobody reads the absent key as an error.
96
+ rejected = raw["rejected"]
97
+ @rejected = (rejected.is_a?(::Array) ? rejected : []).map { |r| RejectedRecipient.from_hash(r) }.freeze
98
+ end
99
+
100
+ def inspect
101
+ "#<NaijaCloud::Email::SendEmailResponse id=#{id.inspect} status=#{status.inspect} " \
102
+ "rejected=#{rejected.length}>"
103
+ end
104
+ end
105
+
106
+ # The body from GET /v1/emails/{id}.
107
+ #
108
+ # `to` is a single address, not a list: the server writes one row per primary
109
+ # recipient, so a three-recipient send produces three of these and the id you
110
+ # got back from the send is the first of them.
111
+ class Email < BaseObject
112
+ attr_reader :id, :to, :from, :subject, :status, :created_at, :delivered_at,
113
+ :opened, :clicked, :failure_reason, :sandbox
114
+
115
+ def self.from_hash(hash)
116
+ hash = {} unless hash.is_a?(::Hash)
117
+ new(hash)
118
+ end
119
+
120
+ def initialize(raw = {})
121
+ super
122
+ @id = raw["id"]
123
+ @to = raw["to"]
124
+ @from = raw["from"]
125
+ @subject = raw["subject"]
126
+ @status = raw["status"]
127
+ @created_at = raw["created_at"]
128
+ @delivered_at = raw["delivered_at"]
129
+ @opened = raw["opened"]
130
+ @clicked = raw["clicked"]
131
+ # Present only on a failure, so its absence is the normal case.
132
+ @failure_reason = raw["failure_reason"]
133
+ # True for a message sent with a test key (nmail_test_...): recorded,
134
+ # never handed to a mail server, so a "bounced" one is simulated.
135
+ @sandbox = raw["sandbox"] == true
136
+ end
137
+
138
+ def sandbox?
139
+ @sandbox
140
+ end
141
+
142
+ def opened?
143
+ !!@opened
144
+ end
145
+
146
+ def clicked?
147
+ !!@clicked
148
+ end
149
+
150
+ def delivered?
151
+ @status == MessageStatus::DELIVERED
152
+ end
153
+
154
+ # Timestamps are left as the ISO-8601 strings the server sent. Parsing them
155
+ # into Time here would force a timezone interpretation on a caller who may
156
+ # only want to pass the value through, and Time.iso8601 raises on anything
157
+ # unexpected -- a parse failure is not worth turning a readable field into
158
+ # an exception.
159
+ def created_at_time
160
+ @created_at && ::Time.iso8601(@created_at)
161
+ end
162
+
163
+ def delivered_at_time
164
+ @delivered_at && ::Time.iso8601(@delivered_at)
165
+ end
166
+
167
+ def inspect
168
+ "#<NaijaCloud::Email::Email id=#{id.inspect} status=#{status.inspect} to=#{to.inspect}>"
169
+ end
170
+ end
171
+
172
+ # A verified webhook delivery.
173
+ #
174
+ # The envelope (id / type / created_at / data) is fixed by the SDK contract
175
+ # section 6; the control plane does not emit these yet, so treat the fields
176
+ # inside `data` as provisional and read anything else through `#raw`.
177
+ class WebhookEvent < BaseObject
178
+ attr_reader :id, :type, :created_at, :data
179
+
180
+ def self.from_hash(hash)
181
+ hash = {} unless hash.is_a?(::Hash)
182
+ new(hash)
183
+ end
184
+
185
+ def initialize(raw = {})
186
+ super
187
+ @id = raw["id"]
188
+ @type = raw["type"]
189
+ @created_at = raw["created_at"]
190
+ @data = raw["data"].is_a?(::Hash) ? raw["data"].freeze : {}.freeze
191
+ end
192
+
193
+ # The message id the event is about, when the event carries one.
194
+ def email_id
195
+ @data["email_id"] || @data["id"]
196
+ end
197
+
198
+ def inspect
199
+ "#<NaijaCloud::Email::WebhookEvent id=#{id.inspect} type=#{type.inspect}>"
200
+ end
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NaijaCloud
4
+ module Email
5
+ VERSION = "0.3.0"
6
+ end
7
+ end