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,443 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
begin
|
|
7
|
+
require "base64"
|
|
8
|
+
rescue LoadError
|
|
9
|
+
# base64 stops being a default gem in Ruby 3.4. Nothing to do here: the encoder
|
|
10
|
+
# below falls back to pack("m0"), which produces byte-identical output to
|
|
11
|
+
# Base64.strict_encode64. Declaring a runtime dependency to keep one method
|
|
12
|
+
# would put a third-party release into a package that holds a sending key.
|
|
13
|
+
nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
module NaijaCloud
|
|
17
|
+
module Email
|
|
18
|
+
# The `emails` resource: the two endpoints the control plane actually has.
|
|
19
|
+
#
|
|
20
|
+
# There is no domains, api-keys, batch or contacts resource here. Those exist
|
|
21
|
+
# in other vendors' SDKs and not in ours because they do not exist server-side
|
|
22
|
+
# -- an SDK method that returns 404 for everyone is worse than no method.
|
|
23
|
+
class Emails
|
|
24
|
+
# Mirrors SENDING_LIMITS in nc-control-plane/src/mail/mail.constants.ts.
|
|
25
|
+
# Checked locally so a caller with 200 recipients gets an immediate,
|
|
26
|
+
# readable error instead of paying a round trip to learn the same thing
|
|
27
|
+
# from a machine they cannot see.
|
|
28
|
+
MAX_RECIPIENTS = 50
|
|
29
|
+
MAX_HEADERS = 25
|
|
30
|
+
MAX_TAGS = 10
|
|
31
|
+
MAX_TAG_KEY_LEN = 64
|
|
32
|
+
MAX_TAG_VALUE_LEN = 256
|
|
33
|
+
# Measured the way the server measures it (SendService.assertShape): the
|
|
34
|
+
# UTF-8 bytes of html and text plus the *decoded* attachment bytes. Not
|
|
35
|
+
# the encoded JSON -- base64 inflates attachments by a third, and
|
|
36
|
+
# measuring the JSON refused 7.5-10 MiB attachments the server takes.
|
|
37
|
+
MAX_MESSAGE_BYTES = 10 * 1024 * 1024
|
|
38
|
+
# Kept for callers that referenced the old name.
|
|
39
|
+
MAX_PAYLOAD_BYTES = MAX_MESSAGE_BYTES
|
|
40
|
+
MAX_IDEMPOTENCY_KEY_BYTES = 255
|
|
41
|
+
|
|
42
|
+
# Overriding any of these would sidestep the domain authorisation that the
|
|
43
|
+
# From address is checked against, so they are refused before the request is
|
|
44
|
+
# built. The server refuses them too; this is the copy the caller can read.
|
|
45
|
+
FORBIDDEN_HEADERS = %w[from to cc bcc subject dkim-signature received].freeze
|
|
46
|
+
|
|
47
|
+
# CR and LF end a header; a NUL truncates it in some downstream parsers.
|
|
48
|
+
# Either one, anywhere a value reaches a MIME header, is an injected header
|
|
49
|
+
# -- a Bcc the sender never wrote, or a second message body.
|
|
50
|
+
UNSAFE_CHARS = /[\r\n\0]/.freeze
|
|
51
|
+
|
|
52
|
+
# RFC 7230 token characters. A header name with a colon or a space in it is
|
|
53
|
+
# not a name the caller meant; it is the start of an injection attempt or a
|
|
54
|
+
# typo that would produce an unparseable message.
|
|
55
|
+
HEADER_NAME = /\A[A-Za-z0-9!\#$%&'*+\-.^_`|~]+\z/.freeze
|
|
56
|
+
|
|
57
|
+
# Message ids are UUIDs. Rather than percent-encode an arbitrary string into
|
|
58
|
+
# the path -- and get it subtly wrong -- anything outside this set is
|
|
59
|
+
# rejected, so nothing a caller passes can add a path segment or a query.
|
|
60
|
+
ID_PATTERN = /\A[A-Za-z0-9._:-]{1,255}\z/.freeze
|
|
61
|
+
|
|
62
|
+
ALLOWED_KEYS = %w[
|
|
63
|
+
from to cc bcc reply_to replyTo subject html text
|
|
64
|
+
headers attachments tags idempotency_key
|
|
65
|
+
].freeze
|
|
66
|
+
|
|
67
|
+
ALLOWED_ATTACHMENT_KEYS = %w[filename content content_type content_id].freeze
|
|
68
|
+
|
|
69
|
+
def initialize(transport)
|
|
70
|
+
@transport = transport
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Send a message.
|
|
74
|
+
#
|
|
75
|
+
# Named `send_email`, not `send`. `send` is Object#send: defining it here
|
|
76
|
+
# would shadow the method every Ruby object uses to dispatch by name, so
|
|
77
|
+
# `emails.send(:get, id)` -- and anything in a caller's stack that
|
|
78
|
+
# metaprograms over this object, including some mocking libraries -- would
|
|
79
|
+
# silently try to mail someone. `create` is the alias for callers who prefer
|
|
80
|
+
# the REST-ish spelling.
|
|
81
|
+
#
|
|
82
|
+
# Takes keywords or a single Hash; on both Ruby 2.7 and 3.x a method with no
|
|
83
|
+
# keyword parameters receives keywords as one Hash, so the two call styles
|
|
84
|
+
# are the same call.
|
|
85
|
+
def send_email(params = {})
|
|
86
|
+
params = normalize_params(params)
|
|
87
|
+
|
|
88
|
+
from = required_address(params["from"], "from")
|
|
89
|
+
to = address_list(params["to"], "to")
|
|
90
|
+
raise ValidationError.new('"to" is required and must contain at least one address') if to.empty?
|
|
91
|
+
|
|
92
|
+
cc = address_list(params["cc"], "cc")
|
|
93
|
+
bcc = address_list(params["bcc"], "bcc")
|
|
94
|
+
reply_to = address_list(params["reply_to"] || params["replyTo"], "reply_to")
|
|
95
|
+
|
|
96
|
+
recipients = to.length + cc.length + bcc.length
|
|
97
|
+
if recipients > MAX_RECIPIENTS
|
|
98
|
+
raise ValidationError.new(
|
|
99
|
+
"too many recipients: #{recipients} across to, cc and bcc (limit #{MAX_RECIPIENTS})",
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
subject = optional_string(params["subject"], "subject") || ""
|
|
104
|
+
check_unsafe!(subject, "subject")
|
|
105
|
+
|
|
106
|
+
payload = {
|
|
107
|
+
"from" => from,
|
|
108
|
+
# Always an array, even for one recipient: the server accepts both, and
|
|
109
|
+
# an array removes any question of how a comma inside a display name
|
|
110
|
+
# would be split.
|
|
111
|
+
"to" => to,
|
|
112
|
+
"subject" => subject,
|
|
113
|
+
}
|
|
114
|
+
payload["cc"] = cc unless cc.empty?
|
|
115
|
+
payload["bcc"] = bcc unless bcc.empty?
|
|
116
|
+
payload["reply_to"] = reply_to unless reply_to.empty?
|
|
117
|
+
html = optional_string(params["html"], "html")
|
|
118
|
+
text = optional_string(params["text"], "text")
|
|
119
|
+
payload["html"] = html if html
|
|
120
|
+
payload["text"] = text if text
|
|
121
|
+
|
|
122
|
+
headers = build_headers(params["headers"])
|
|
123
|
+
payload["headers"] = headers unless headers.empty?
|
|
124
|
+
|
|
125
|
+
raw_attachments = collect_attachments(params["attachments"])
|
|
126
|
+
check_message_size!(html, text, raw_attachments)
|
|
127
|
+
attachments = raw_attachments.map { |entry| encode_attachment(entry) }
|
|
128
|
+
payload["attachments"] = attachments unless attachments.empty?
|
|
129
|
+
|
|
130
|
+
tags = build_tags(params["tags"])
|
|
131
|
+
payload["tags"] = tags unless tags.empty?
|
|
132
|
+
|
|
133
|
+
body = encode_json(payload)
|
|
134
|
+
|
|
135
|
+
# Generated once, here, and reused by every attempt the transport makes.
|
|
136
|
+
# This is the whole reason retrying a POST is safe: a timeout tells the
|
|
137
|
+
# caller nothing about whether the message was accepted, and without a
|
|
138
|
+
# stable dedup key the retry that follows mails the customer twice.
|
|
139
|
+
idempotency_key = idempotency_key_for(params["idempotency_key"])
|
|
140
|
+
|
|
141
|
+
# Header only, never also in the body: the server reads the header first,
|
|
142
|
+
# so a second copy is redundant and two copies could only disagree.
|
|
143
|
+
response = @transport.post("/v1/emails", body, { "Idempotency-Key" => idempotency_key },
|
|
144
|
+
require_string: "id")
|
|
145
|
+
SendEmailResponse.from_hash(response)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
alias create send_email
|
|
149
|
+
|
|
150
|
+
# Fetch one message's current state. Scoped to the key's team server-side.
|
|
151
|
+
def get(id)
|
|
152
|
+
unless id.is_a?(String) && id =~ ID_PATTERN
|
|
153
|
+
raise ValidationError.new("a message id is required and must be a plain id string")
|
|
154
|
+
end
|
|
155
|
+
# A dot segment would still be inside the character set above, and some
|
|
156
|
+
# proxies and routers collapse it -- "/v1/emails/.." becomes a request
|
|
157
|
+
# for something else entirely. Ids never contain one.
|
|
158
|
+
if id.include?("..")
|
|
159
|
+
raise ValidationError.new("a message id cannot contain a path segment")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
Email.from_hash(@transport.get("/v1/emails/#{id}"))
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
# A String that is not valid UTF-8 (binary read into an html field, say)
|
|
168
|
+
# makes JSON.generate raise JSON::GeneratorError -- a local input problem,
|
|
169
|
+
# so it surfaces as the ValidationError a caller already rescues.
|
|
170
|
+
def encode_json(payload)
|
|
171
|
+
JSON.generate(payload)
|
|
172
|
+
rescue JSON::GeneratorError, EncodingError => e
|
|
173
|
+
raise ValidationError.new("the message contains text that is not valid UTF-8 (#{e.class})")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def check_message_size!(html, text, attachments)
|
|
177
|
+
size = html.to_s.bytesize + text.to_s.bytesize +
|
|
178
|
+
attachments.sum { |entry| entry[:content].bytesize }
|
|
179
|
+
return if size <= MAX_MESSAGE_BYTES
|
|
180
|
+
|
|
181
|
+
raise ValidationError.new(
|
|
182
|
+
"message is #{size} bytes (html + text + attachments), over the #{MAX_MESSAGE_BYTES}-byte limit",
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def normalize_params(params)
|
|
187
|
+
params = params.to_h if params.respond_to?(:to_h) && !params.is_a?(Hash)
|
|
188
|
+
unless params.is_a?(Hash)
|
|
189
|
+
raise ValidationError.new("send_email expects keyword arguments or a Hash")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
normalized = {}
|
|
193
|
+
params.each { |key, value| normalized[key.to_s] = value }
|
|
194
|
+
|
|
195
|
+
# Unknown keys are refused rather than forwarded. A typo -- `htlm:` for
|
|
196
|
+
# `html:` -- would otherwise be dropped silently by the server and send a
|
|
197
|
+
# blank email to a real customer, which is the failure this SDK exists to
|
|
198
|
+
# make impossible.
|
|
199
|
+
unknown = normalized.keys - ALLOWED_KEYS
|
|
200
|
+
unless unknown.empty?
|
|
201
|
+
raise ValidationError.new(
|
|
202
|
+
"unknown parameter(s): #{unknown.sort.join(', ')}. Accepted: #{(ALLOWED_KEYS - ['replyTo']).join(', ')}",
|
|
203
|
+
)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
normalized
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def required_address(value, field)
|
|
210
|
+
unless value.is_a?(String) && !value.strip.empty?
|
|
211
|
+
raise ValidationError.new("\"#{field}\" is required and must be a string")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
check_unsafe!(value, field)
|
|
215
|
+
check_addressish!(value, field)
|
|
216
|
+
value
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def address_list(value, field)
|
|
220
|
+
return [] if value.nil?
|
|
221
|
+
|
|
222
|
+
list = value.is_a?(Array) ? value : [value]
|
|
223
|
+
list.each_with_index.map do |entry, index|
|
|
224
|
+
label = value.is_a?(Array) ? "#{field}[#{index}]" : field
|
|
225
|
+
unless entry.is_a?(String) && !entry.strip.empty?
|
|
226
|
+
raise ValidationError.new("#{label} must be a non-empty string")
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
check_unsafe!(entry, label)
|
|
230
|
+
check_addressish!(entry, label)
|
|
231
|
+
entry
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Deliberately not an RFC 5322 parser. Every regex that claims to validate
|
|
236
|
+
# an address rejects something legitimate, and the authoritative check runs
|
|
237
|
+
# server-side against the verified domain anyway. A missing "@" is the one
|
|
238
|
+
# mistake worth catching here because it is always a mistake.
|
|
239
|
+
def check_addressish!(value, field)
|
|
240
|
+
return if value.include?("@")
|
|
241
|
+
|
|
242
|
+
raise ValidationError.new("#{field} does not look like an email address: #{value.inspect}")
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def check_unsafe!(value, field)
|
|
246
|
+
# Matched on the bytes: a String that is not valid UTF-8 would make the
|
|
247
|
+
# regex raise ArgumentError instead of reaching the ValidationError that
|
|
248
|
+
# JSON encoding turns it into.
|
|
249
|
+
return unless value.is_a?(String) && value.b =~ UNSAFE_CHARS
|
|
250
|
+
|
|
251
|
+
raise ValidationError.new(
|
|
252
|
+
"#{field} contains a line break or NUL, which would inject a mail header",
|
|
253
|
+
)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def optional_string(value, field)
|
|
257
|
+
return nil if value.nil?
|
|
258
|
+
raise ValidationError.new("#{field} must be a string") unless value.is_a?(String)
|
|
259
|
+
|
|
260
|
+
value
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def build_headers(raw)
|
|
264
|
+
return {} if raw.nil?
|
|
265
|
+
raise ValidationError.new("headers must be a Hash of strings") unless raw.is_a?(Hash)
|
|
266
|
+
|
|
267
|
+
headers = {}
|
|
268
|
+
raw.each do |name, value|
|
|
269
|
+
name = name.to_s
|
|
270
|
+
raise ValidationError.new("a header name cannot be empty") if name.empty?
|
|
271
|
+
|
|
272
|
+
# Checked on the trimmed name, ahead of the token check: the MIME
|
|
273
|
+
# composer trims header names, so " From" or "Bcc\t" would land as the
|
|
274
|
+
# real header. Saying "cannot be overridden" names the actual problem.
|
|
275
|
+
if FORBIDDEN_HEADERS.include?(name.strip.downcase)
|
|
276
|
+
raise ValidationError.new(
|
|
277
|
+
"header #{name.inspect} cannot be overridden; it is set from the message itself",
|
|
278
|
+
)
|
|
279
|
+
end
|
|
280
|
+
unless name.b =~ HEADER_NAME
|
|
281
|
+
raise ValidationError.new("header name #{name.inspect} is not a valid header name")
|
|
282
|
+
end
|
|
283
|
+
unless value.is_a?(String)
|
|
284
|
+
raise ValidationError.new("header #{name.inspect} must have a string value")
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
check_unsafe!(value, "header #{name.inspect}")
|
|
288
|
+
headers[name] = value
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
if headers.length > MAX_HEADERS
|
|
292
|
+
raise ValidationError.new("too many custom headers: #{headers.length} (limit #{MAX_HEADERS})")
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
headers
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Validates every attachment and returns { filename:, content:,
|
|
299
|
+
# content_type:, content_id: } with the raw bytes, so the size check can
|
|
300
|
+
# count decoded bytes before anything is base64-encoded.
|
|
301
|
+
#
|
|
302
|
+
# `content` is a String of raw bytes -- Ruby's byte type (File.binread,
|
|
303
|
+
# IO#read in binary mode). It is never taken as base64: this SDK encodes.
|
|
304
|
+
def collect_attachments(raw)
|
|
305
|
+
return [] if raw.nil?
|
|
306
|
+
raise ValidationError.new("attachments must be an Array") unless raw.is_a?(Array)
|
|
307
|
+
|
|
308
|
+
raw.each_with_index.map do |attachment, index|
|
|
309
|
+
label = "attachments[#{index}]"
|
|
310
|
+
unless attachment.is_a?(Hash)
|
|
311
|
+
raise ValidationError.new("#{label} must be a Hash with filename and content")
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
entry = {}
|
|
315
|
+
attachment.each { |key, value| entry[key.to_s] = value }
|
|
316
|
+
|
|
317
|
+
# A file path is refused outright rather than opened. An SDK that reads
|
|
318
|
+
# whatever path it is handed is a local-file-disclosure primitive the
|
|
319
|
+
# moment a web handler passes user input into it -- the caller reads
|
|
320
|
+
# their own file and hands us the bytes.
|
|
321
|
+
if entry.key?("path")
|
|
322
|
+
raise ValidationError.new(
|
|
323
|
+
"#{label}: this SDK never opens files. Read the bytes yourself and pass them as content:",
|
|
324
|
+
)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
unknown = entry.keys - ALLOWED_ATTACHMENT_KEYS
|
|
328
|
+
unless unknown.empty?
|
|
329
|
+
raise ValidationError.new("#{label}: unknown key(s) #{unknown.sort.join(', ')}")
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
filename = entry["filename"]
|
|
333
|
+
unless filename.is_a?(String) && !filename.strip.empty?
|
|
334
|
+
raise ValidationError.new("#{label} needs a filename")
|
|
335
|
+
end
|
|
336
|
+
check_unsafe!(filename, "#{label} filename")
|
|
337
|
+
|
|
338
|
+
content = entry["content"]
|
|
339
|
+
unless content.is_a?(String)
|
|
340
|
+
raise ValidationError.new(
|
|
341
|
+
"#{label} content must be a String of bytes (read the file yourself; " \
|
|
342
|
+
"do not base64-encode it, this SDK does that)",
|
|
343
|
+
)
|
|
344
|
+
end
|
|
345
|
+
raise ValidationError.new("#{label} content is empty") if content.empty?
|
|
346
|
+
|
|
347
|
+
content_type = optional_string(entry["content_type"], "#{label} content_type")
|
|
348
|
+
check_unsafe!(content_type, "#{label} content_type")
|
|
349
|
+
|
|
350
|
+
content_id = optional_string(entry["content_id"], "#{label} content_id")
|
|
351
|
+
check_unsafe!(content_id, "#{label} content_id")
|
|
352
|
+
|
|
353
|
+
{ filename: filename, content: content, content_type: content_type, content_id: content_id }
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def encode_attachment(entry)
|
|
358
|
+
built = { "filename" => entry[:filename], "content" => base64(entry[:content]) }
|
|
359
|
+
built["content_type"] = entry[:content_type] if entry[:content_type]
|
|
360
|
+
built["content_id"] = entry[:content_id] if entry[:content_id]
|
|
361
|
+
built
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Strict base64: no line breaks. The server validates the alphabet by hand
|
|
365
|
+
# and refuses anything outside it rather than silently dropping characters,
|
|
366
|
+
# because a silently mangled invoice is worse than a rejected one -- wrapped
|
|
367
|
+
# output would fail that check.
|
|
368
|
+
def base64(bytes)
|
|
369
|
+
if defined?(::Base64)
|
|
370
|
+
::Base64.strict_encode64(bytes)
|
|
371
|
+
else
|
|
372
|
+
[bytes].pack("m0")
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# The server truncates an over-long tag. This rejects instead: a tag is an
|
|
377
|
+
# analytics label, and a truncated key that silently stops matching the
|
|
378
|
+
# dashboard query a customer built on it is a bug they will never find.
|
|
379
|
+
# The server truncates tags by JavaScript's `.length`, which counts UTF-16
|
|
380
|
+
# units: an emoji is 2 there and 1 in String#length. Counting the same way
|
|
381
|
+
# keeps "reject, never truncate" true for every tag that gets past here.
|
|
382
|
+
def utf16_length(text)
|
|
383
|
+
text.encode(Encoding::UTF_16LE).bytesize / 2
|
|
384
|
+
rescue EncodingError
|
|
385
|
+
text.length
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def build_tags(raw)
|
|
389
|
+
return {} if raw.nil?
|
|
390
|
+
raise ValidationError.new("tags must be a Hash of strings") unless raw.is_a?(Hash)
|
|
391
|
+
|
|
392
|
+
tags = {}
|
|
393
|
+
raw.each do |key, value|
|
|
394
|
+
key = key.to_s
|
|
395
|
+
raise ValidationError.new("a tag key cannot be empty") if key.empty?
|
|
396
|
+
unless value.is_a?(String)
|
|
397
|
+
raise ValidationError.new("tag #{key.inspect} must have a string value")
|
|
398
|
+
end
|
|
399
|
+
if utf16_length(key) > MAX_TAG_KEY_LEN
|
|
400
|
+
raise ValidationError.new("tag key #{key.inspect} is over #{MAX_TAG_KEY_LEN} characters")
|
|
401
|
+
end
|
|
402
|
+
if utf16_length(value) > MAX_TAG_VALUE_LEN
|
|
403
|
+
raise ValidationError.new("tag #{key.inspect} value is over #{MAX_TAG_VALUE_LEN} characters")
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
tags[key] = value
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
if tags.length > MAX_TAGS
|
|
410
|
+
raise ValidationError.new("too many tags: #{tags.length} (limit #{MAX_TAGS})")
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
tags
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def idempotency_key_for(supplied)
|
|
417
|
+
# A caller-supplied key always wins and is never regenerated: they may be
|
|
418
|
+
# deriving it from an order id precisely so that two independent processes
|
|
419
|
+
# cannot both send the receipt.
|
|
420
|
+
#
|
|
421
|
+
# An empty (or blank) key counts as none supplied: one is generated, as
|
|
422
|
+
# if it had been omitted -- the same in all five SDKs.
|
|
423
|
+
return SecureRandom.uuid if supplied.nil?
|
|
424
|
+
|
|
425
|
+
raise ValidationError.new("idempotency_key must be a string") unless supplied.is_a?(String)
|
|
426
|
+
return SecureRandom.uuid if supplied.strip.empty?
|
|
427
|
+
|
|
428
|
+
# Bytes, not characters: the server stores and compares the header's
|
|
429
|
+
# bytes, and the limit is the same in every SDK only if it is counted
|
|
430
|
+
# the same way.
|
|
431
|
+
if supplied.bytesize > MAX_IDEMPOTENCY_KEY_BYTES
|
|
432
|
+
raise ValidationError.new(
|
|
433
|
+
"idempotency_key must be #{MAX_IDEMPOTENCY_KEY_BYTES} bytes of UTF-8 or fewer",
|
|
434
|
+
)
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# It travels as a header, so it gets the same injection check as an address.
|
|
438
|
+
check_unsafe!(supplied, "idempotency_key")
|
|
439
|
+
supplied
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NaijaCloud
|
|
4
|
+
module Email
|
|
5
|
+
# One base class for everything this SDK raises, so a caller can wrap a send
|
|
6
|
+
# in a single `rescue NaijaCloud::Email::Error` and still reach for a
|
|
7
|
+
# specific subclass when it wants to treat one case differently.
|
|
8
|
+
#
|
|
9
|
+
# Failures caught locally (bad input, a plaintext base URL) carry
|
|
10
|
+
# `status_code == 0`: no request left the process, so there is no HTTP status
|
|
11
|
+
# to report and pretending otherwise would send someone hunting through
|
|
12
|
+
# server logs for a request that never arrived.
|
|
13
|
+
class Error < StandardError
|
|
14
|
+
# `body` / `raw_body`: the response text exactly as received (nil for a
|
|
15
|
+
# local error). `parsed_body`: that text decoded as JSON, or nil when it
|
|
16
|
+
# was not JSON. Both are always available, so a caller never has to
|
|
17
|
+
# re-parse or guess which one they were handed.
|
|
18
|
+
attr_reader :status_code, :error_label, :request_id, :body, :parsed_body
|
|
19
|
+
|
|
20
|
+
def initialize(message, status_code: 0, error_label: nil, request_id: nil, body: nil,
|
|
21
|
+
parsed_body: nil, retryable: nil)
|
|
22
|
+
super(message)
|
|
23
|
+
@status_code = status_code
|
|
24
|
+
@error_label = error_label
|
|
25
|
+
@request_id = request_id
|
|
26
|
+
@body = body
|
|
27
|
+
@parsed_body = parsed_body
|
|
28
|
+
@retryable = retryable
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def raw_body
|
|
32
|
+
@body
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Advisory, for callers that queue their own work. The transport does not
|
|
36
|
+
# consult this: it decides from the HTTP status, because that is the fact
|
|
37
|
+
# on the wire (see Transport#retryable_status?).
|
|
38
|
+
def retryable?
|
|
39
|
+
@retryable.nil? ? self.class.retryable_by_default? : @retryable
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.retryable_by_default?
|
|
43
|
+
false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Never interpolate the response body here. An error body can echo request
|
|
47
|
+
# content, and an SDK that prints it into a log by default is a way for a
|
|
48
|
+
# subject line or a customer address to end up in a log aggregator.
|
|
49
|
+
def inspect
|
|
50
|
+
"#<#{self.class.name}: #{message.inspect} status_code=#{status_code} " \
|
|
51
|
+
"error_label=#{error_label.inspect} request_id=#{request_id.inspect}>"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# 400/413/422 and any other unmapped 4xx, and anything the SDK refuses to
|
|
56
|
+
# put on the wire at all.
|
|
57
|
+
class ValidationError < Error; end
|
|
58
|
+
|
|
59
|
+
# 401. Missing, malformed, unknown or revoked key -- the server deliberately
|
|
60
|
+
# does not say which, so neither do we.
|
|
61
|
+
class AuthenticationError < Error; end
|
|
62
|
+
|
|
63
|
+
# 403. Authenticated but not permitted: a test key on the send path, an
|
|
64
|
+
# unverified From domain, a paused domain, or the daily quota. None of those
|
|
65
|
+
# get better by trying again, which is why 403 is not retryable.
|
|
66
|
+
class PermissionError < Error; end
|
|
67
|
+
|
|
68
|
+
# 404, and the 400 the control plane returns for an unknown message id.
|
|
69
|
+
class NotFoundError < Error; end
|
|
70
|
+
|
|
71
|
+
# 409.
|
|
72
|
+
class ConflictError < Error; end
|
|
73
|
+
|
|
74
|
+
# 429. `retry_after` is seconds, already parsed from either of the two forms
|
|
75
|
+
# the header may take and clamped by the transport before it is slept on.
|
|
76
|
+
class RateLimitError < Error
|
|
77
|
+
attr_reader :retry_after
|
|
78
|
+
|
|
79
|
+
def initialize(message, retry_after: nil, **kwargs)
|
|
80
|
+
super(message, **kwargs)
|
|
81
|
+
@retry_after = retry_after
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.retryable_by_default?
|
|
85
|
+
true
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# 5xx, plus the deliberate refusal to follow a 3xx.
|
|
90
|
+
class ServerError < Error
|
|
91
|
+
def self.retryable_by_default?
|
|
92
|
+
true
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Socket, DNS or TLS failure -- the request may or may not have been seen by
|
|
97
|
+
# the server, which is exactly why every send carries an idempotency key.
|
|
98
|
+
class ConnectionError < Error
|
|
99
|
+
def self.retryable_by_default?
|
|
100
|
+
true
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# A client-side deadline, or the server's own 408.
|
|
105
|
+
class TimeoutError < Error
|
|
106
|
+
def self.retryable_by_default?
|
|
107
|
+
true
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Raised by Webhooks.verify. Deliberately says nothing about *why* beyond a
|
|
112
|
+
# coarse reason: telling a caller "expected abc123" hands an attacker the
|
|
113
|
+
# answer they were trying to guess.
|
|
114
|
+
class WebhookVerificationError < Error; end
|
|
115
|
+
end
|
|
116
|
+
end
|