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,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