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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +116 -0
- data/CONTRIBUTING.md +90 -0
- data/LICENSE +21 -0
- data/README.md +357 -0
- data/SECURITY.md +92 -0
- data/lib/naijacloud/email/client.rb +224 -0
- data/lib/naijacloud/email/emails.rb +443 -0
- data/lib/naijacloud/email/errors.rb +116 -0
- data/lib/naijacloud/email/http.rb +361 -0
- data/lib/naijacloud/email/objects.rb +203 -0
- data/lib/naijacloud/email/version.rb +7 -0
- data/lib/naijacloud/email/webhooks.rb +153 -0
- data/lib/naijacloud/email.rb +30 -0
- metadata +91 -0
|
@@ -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
|