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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 07014ab220bebb646b9afa26fbe3529f85a067f17bef42ff1c9fc6a2a601cde9
4
+ data.tar.gz: 5259d09d2ac514de8cd8845d020e30a4598f2a0a48e3b58f47a13e86477d57da
5
+ SHA512:
6
+ metadata.gz: 570e205a0d015e549c0fbc2e5a3d05940b1736736a4af577b8e2a1337b210d41a26b7e933df1d491b9ef1125d5ee552f92f680d8da84ef4b0f42494d9eed4fea
7
+ data.tar.gz: 30bd2808294bfeb93ab181fe51c127dbe36505793953618cc6d9a8e4b807056ebecbcd4e12547005394234a5282479575b59072c8e8c4629b28e3bc8e6032e51
data/CHANGELOG.md ADDED
@@ -0,0 +1,116 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.3.0] - 2026-10-07
11
+
12
+ Conformance pass across the five Naijamail SDKs (TGL-741). Where they had drifted
13
+ apart, each now does what the SDK contract settles on.
14
+
15
+ ### Changed
16
+
17
+ - The 10 MiB limit is measured the way the server measures it: the UTF-8 bytes of
18
+ `html` and `text` plus the raw (decoded) attachment bytes, instead of the whole
19
+ encoded JSON. Attachments between ~7.5 and 10 MiB are no longer refused locally.
20
+ - Any unmapped 4xx (405, 415, 451…) raises `ValidationError` instead of the base
21
+ `Error`.
22
+ - A `base_url` with a query string or fragment is refused (it used to be kept,
23
+ with every request path appended after it).
24
+ - A blank `NAIJAMAIL_BASE_URL` is treated as unset instead of failing
25
+ construction.
26
+ - An empty `idempotency_key:` generates one, as if it were omitted, instead of
27
+ raising. The 255 limit is counted in UTF-8 bytes, not characters.
28
+ - An `nc_pat_…` key is refused with a message saying it is a personal access
29
+ token and which keys to use, instead of "does not look like a Naijamail key".
30
+ - `max_retries` above 10 is refused.
31
+ - `timeout` is now a deadline on the whole attempt (connect, send, and reading
32
+ the full response), not a per-socket-read timeout.
33
+ - `Webhooks.verify`: a negative or non-numeric `tolerance` raises
34
+ `ValidationError` (`0` remains strict); `t` must be 1–12 ASCII digits.
35
+ - Forbidden custom headers are matched on the trimmed name, so `" From"` is
36
+ refused as an override rather than as an invalid name.
37
+
38
+ ### Added
39
+
40
+ - `Error#raw_body` (the response text, same as `#body`) and `Error#parsed_body`
41
+ (the JSON-decoded body, or `nil`).
42
+
43
+ ### Fixed
44
+
45
+ - A `http://[::1]` base URL could not connect: Net::HTTP was given the bracketed
46
+ host. It now uses the bare address.
47
+ - `Webhooks.verify` accepts upper-case hex signatures.
48
+ - `Webhooks.verify` raises on a verified payload that is not a JSON object (an
49
+ array used to come back as an empty event).
50
+ - A send response with no `id` raises `ServerError` instead of returning a
51
+ response whose `id` is `nil`.
52
+ - Text that is not valid UTF-8 raises `ValidationError` instead of
53
+ `JSON::GeneratorError` or `ArgumentError`.
54
+
55
+ ## [0.2.0] - 2026-10-04
56
+
57
+ The first version published to RubyGems (`gem install naijacloud-email`).
58
+ 0.1.0 was written up here but never pushed, so its entries below are part of
59
+ this release too.
60
+
61
+ ### Added
62
+
63
+ - Accept a workspace API key (`nc_live_…`) alongside the Naijamail keys. It is
64
+ the credential from **Settings → API keys**, and it reaches the mail API when
65
+ it carries the **Email send** scope — so a team that already has one for
66
+ deploys and the platform API does not need a second secret to send mail.
67
+ Redaction knows the new prefix, so a dump still shows which kind of credential
68
+ a process is holding. `nc_pat_…` platform tokens remain refused: they predate
69
+ the scope and the API rejects them on the mail routes.
70
+ - `Email#sandbox` / `#sandbox?` on a retrieved email: true for a message sent with a test key,
71
+ which is recorded but never delivered, so a simulated bounce can be told from
72
+ a real one.
73
+
74
+ ### Fixed
75
+
76
+ - `YAML.dump` of a client or its `emails` resource raises instead of writing
77
+ the API key.
78
+ - A 413 (request too large) is a `ValidationError`.
79
+ - Tag length is counted in UTF-16 units, the way the server counts it.
80
+ - Test keys (`nmail_test_…`) are sandboxed by the API, not refused with a 403.
81
+ The README said otherwise.
82
+
83
+ ## 0.1.0 - 2026-08-29 (never published)
84
+
85
+ First release. Implements the Naijamail SDK contract for Ruby.
86
+
87
+ ### Added
88
+
89
+ - `NaijaCloud::Email::Client` with `api_key`, `base_url`, `timeout`,
90
+ `max_retries` and `user_agent_suffix`. The key defaults to `NAIJAMAIL_API_KEY`
91
+ and the base URL to `NAIJAMAIL_BASE_URL`, else `https://api.naijacloud.com`.
92
+ - `emails.send_email` (aliased `create`) for `POST /v1/emails`, accepting
93
+ keywords or a single Hash.
94
+ - `emails.get(id)` for `GET /v1/emails/{id}`.
95
+ - Response objects `SendEmailResponse`, `Email`, `RejectedRecipient` and
96
+ `WebhookEvent`, all of which ignore unknown fields so a new server field does
97
+ not break an installed gem. `rejected` is always an array.
98
+ - `MessageStatus` constants, with unknown statuses passing through as strings.
99
+ - Error hierarchy under `NaijaCloud::Email::Error` carrying `status_code`,
100
+ `error_label`, `request_id` and `body`; `RateLimitError#retry_after`.
101
+ - Retries: 3 attempts, full-jitter exponential backoff (500ms base, 8s cap), only
102
+ on 429/408/5xx/connection/timeout, with `Retry-After` honoured in both its
103
+ forms and clamped to 60s.
104
+ - An `Idempotency-Key` generated once per send call and reused across that call's
105
+ retries, so a retry after a timeout cannot double-mail.
106
+ - `Webhooks.verify` for the `NC-Signature` scheme, with constant-time comparison
107
+ and a 300-second default replay tolerance. Naija Cloud emits these events;
108
+ the scheme is shared with every other Naijamail SDK, so all of them verify
109
+ identically.
110
+ - Security enforcement described in SECURITY.md: HTTPS-only base URLs, no
111
+ redirect following, key redaction, header-injection rejection, forbidden
112
+ header names, client-side limits and bytes-only attachments.
113
+
114
+ [Unreleased]: https://github.com/naijacloud/nc-email-ruby/compare/v0.3.0...HEAD
115
+ [0.3.0]: https://github.com/naijacloud/nc-email-ruby/compare/v0.2.0...v0.3.0
116
+ [0.2.0]: https://github.com/naijacloud/nc-email-ruby/releases/tag/v0.2.0
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,90 @@
1
+ # Contributing
2
+
3
+ ## Setup
4
+
5
+ Nothing to install. The gem has no runtime dependencies, and minitest and rake
6
+ ship with Ruby.
7
+
8
+ ```sh
9
+ rake test
10
+ ```
11
+
12
+ or, without bundler or rake at all:
13
+
14
+ ```sh
15
+ ruby -Ilib -Itest test/emails_send_test.rb
16
+ ```
17
+
18
+ `bundle install` is only needed if you want a lockfile; the `Gemfile` exists so
19
+ `bundle exec rake test` works, not because anything requires it.
20
+
21
+ ## Tests
22
+
23
+ The suite runs **offline**. `test/test_helper.rb` starts a small HTTP server on
24
+ `127.0.0.1` with an ephemeral port, built on raw `TCPServer` — webrick left the
25
+ standard library in Ruby 3.0, so building on it would mean a fresh checkout
26
+ could not be tested without a network. There is no WebMock and no VCR, and no
27
+ test makes an outbound connection.
28
+
29
+ Scripting a response:
30
+
31
+ ```ruby
32
+ @server.enqueue(status: 429, body: { "statusCode" => 429, "message" => "Too many requests" },
33
+ headers: { "Retry-After" => "3" })
34
+ @server.enqueue(status: 202, body: { "id" => "m1", "status" => "queued" })
35
+ ```
36
+
37
+ `hang:` holds a connection open without answering, which is how the client-side
38
+ deadline is tested without waiting out the 30-second default.
39
+
40
+ Retry timing is asserted, not slept through: `capture_sleeps(client)` replaces
41
+ the transport's `sleeper` and pins its `jitter` at the ceiling, and returns the
42
+ array of intervals that would have been slept.
43
+
44
+ **Never commit a real key.** Tests use the literal
45
+ `nmail_live_test0000000000000000`, which is the value nominated by the SDK
46
+ contract and has never been a real key.
47
+
48
+ ## What to keep in mind when changing this gem
49
+
50
+ - **The SDK contract is binding.** `email-sdks/spec/SDK-CONTRACT.md` is the same
51
+ document every other Naijamail SDK implements, so a behaviour change here is a
52
+ change to all of them. If the contract and the control plane disagree, the
53
+ control plane wins and the contract gets fixed.
54
+ - **Do not invent endpoints.** The server has two: `POST /v1/emails` and
55
+ `GET /v1/emails/{id}`.
56
+ - **Zero runtime dependencies.** See SECURITY.md for why this is not negotiable.
57
+ - **Every rule in section 5 of the contract is a security rule.** Loosening one
58
+ needs a reason written down, not a commit message saying "simplify".
59
+ - **Ruby 2.7 is the floor.** No endless method definitions, no `Data.define`, no
60
+ rightward assignment, no `Hash#except`. CI runs 2.7, 3.0, 3.2 and 3.3.
61
+
62
+ ## House style
63
+
64
+ Comments explain **why** — the decision, the failure it prevents, the trap it
65
+ avoids. A comment that restates the code is noise and will be asked about in
66
+ review. `# frozen_string_literal: true` at the top of every file.
67
+
68
+ ## Releasing
69
+
70
+ Releases come from CI, on a tag, with RubyGems trusted publishing: the gemspec
71
+ requires MFA to push, and the short-lived key the workflow gets from
72
+ rubygems.org is what satisfies that without anyone typing a code.
73
+
74
+ 1. Update `lib/naijacloud/email/version.rb`.
75
+ 2. Move the `Unreleased` changelog entries under `## [x.y.z] - YYYY-MM-DD` and
76
+ update the link definitions at the foot of the file.
77
+ 3. Tag and push: `git tag v<version> && git push origin v<version>`.
78
+
79
+ `.github/workflows/release.yml` runs the CI matrix, refuses a tag that disagrees
80
+ with `VERSION` or has no changelog section, builds and pushes the gem, then
81
+ creates the GitHub release from the changelog section.
82
+
83
+ ### One-time setup
84
+
85
+ - **rubygems.org → Settings → Trusted publishers → Create a pending trusted
86
+ publisher** (before the first push; afterwards it is on the gem's own page):
87
+ gem name `naijacloud-email`, repository owner `naijacloud`, repository
88
+ `nc-email-ruby`, workflow `release.yml`, environment `rubygems`.
89
+ - **GitHub → Settings → Environments → New environment `rubygems`.** Add
90
+ required reviewers to make a push wait for a human.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NaijaCloud
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,357 @@
1
+ <p align="center">
2
+ <a href="https://www.naijacloud.com">
3
+ <img alt="Naijamail — Ruby SDK" src="https://raw.githubusercontent.com/naijacloud/nc-email-ruby/main/.github/assets/banner.png" width="100%">
4
+ </a>
5
+ </p>
6
+
7
+ <p align="center">
8
+ <a href="https://rubygems.org/gems/naijacloud-email"><img alt="gem" src="https://img.shields.io/badge/gem-naijacloud--email-008751?style=flat-square&labelColor=0A0E0C"></a>
9
+ <img alt="ruby" src="https://img.shields.io/badge/ruby-%3E%3D_2.7-E0483F?style=flat-square&labelColor=0A0E0C">
10
+ <img alt="dependencies" src="https://img.shields.io/badge/dependencies-0-46C98A?style=flat-square&labelColor=0A0E0C">
11
+ <a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-8A988F?style=flat-square&labelColor=0A0E0C"></a>
12
+ </p>
13
+
14
+ <p align="center">
15
+ <a href="#install">Install</a> ·
16
+ <a href="#the-api-surface">The API surface</a> ·
17
+ <a href="#client-options">Client options</a> ·
18
+ <a href="#errors">Errors</a> ·
19
+ <a href="#retries">Retries</a> ·
20
+ <a href="#webhooks">Webhooks</a> ·
21
+ <a href="#security">Security</a>
22
+ </p>
23
+
24
+ # naijacloud-email
25
+
26
+ The official Ruby SDK for **Naijamail**, the transactional email API of
27
+ [Naija Cloud](https://www.naijacloud.com).
28
+
29
+ Zero runtime dependencies. Standard-library `net/http` only.
30
+
31
+ ```ruby
32
+ require "naijacloud/email"
33
+
34
+ nm = NaijaCloud::Email::Client.new # reads NAIJAMAIL_API_KEY
35
+
36
+ sent = nm.emails.send_email(
37
+ from: "Acme <hello@acme.com>",
38
+ to: "customer@example.com",
39
+ subject: "Your receipt",
40
+ html: "<p>Thanks for your order.</p>",
41
+ )
42
+
43
+ puts sent.id # => "5b1e..."
44
+ puts sent.status # => "queued"
45
+
46
+ email = nm.emails.get(sent.id)
47
+ puts email.status # => "delivered"
48
+ ```
49
+
50
+ ## Install
51
+
52
+ ```ruby
53
+ gem "naijacloud-email"
54
+ ```
55
+
56
+ Ruby 2.7 or newer.
57
+
58
+ ## The API surface
59
+
60
+ This release wraps two endpoints, send and retrieve. The API also has batch send,
61
+ a message list, limits, domains and suppressions
62
+ ([API docs](https://naijacloud.com/docs/api/email)); they are not wrapped yet.
63
+
64
+ | | |
65
+ | --- | --- |
66
+ | `nm.emails.send_email(...)` | `POST /v1/emails`, returns `SendEmailResponse` |
67
+ | `nm.emails.create(...)` | alias of `send_email` |
68
+ | `nm.emails.get(id)` | `GET /v1/emails/{id}`, returns `Email` |
69
+ | `NaijaCloud::Email::Webhooks.verify(...)` | verifies a signed webhook delivery |
70
+
71
+ `Email#created_at` and `#delivered_at` are the ISO-8601 **Strings** the server
72
+ sent (`delivered_at` is `nil` until delivery); `#created_at_time` and
73
+ `#delivered_at_time` parse them into `Time` on demand. Other SDKs surface a
74
+ native date type here — that difference is deliberate, not a bug.
75
+
76
+ `send_email`, not `send`: `send` is `Object#send`, and shadowing it on a resource
77
+ object means anything that dispatches by name against it — including some mocking
78
+ libraries — tries to mail a message instead.
79
+
80
+ Both call styles work, on every supported Ruby:
81
+
82
+ ```ruby
83
+ nm.emails.send_email(from: "...", to: "...", subject: "Hi")
84
+ nm.emails.send_email({ from: "...", to: "...", subject: "Hi" })
85
+ ```
86
+
87
+ ### Send options
88
+
89
+ | Option | Type | Notes |
90
+ | --- | --- | --- |
91
+ | `from:` | String | **required.** `"Name <a@b.com>"` or a bare address. The domain must be verified for your team. |
92
+ | `to:` | String or Array | **required.** At least one. |
93
+ | `cc:`, `bcc:` | String or Array | |
94
+ | `reply_to:` | String or Array | Sent on the wire as `reply_to`. |
95
+ | `subject:` | String | Always sent; defaults to `""`. |
96
+ | `html:`, `text:` | String | |
97
+ | `headers:` | Hash | At most 25. `From`, `To`, `Cc`, `Bcc`, `Subject`, `DKIM-Signature` and `Received` are refused, matched case-insensitively on the name with surrounding whitespace trimmed (`" From"` is refused too). |
98
+ | `attachments:` | Array of Hashes | `{ filename:, content:, content_type:, content_id: }`. `content` is a String of **raw bytes**; it must not be empty. |
99
+ | `tags:` | Hash | At most 10, key ≤ 64 chars, value ≤ 256. |
100
+ | `idempotency_key:` | String | Optional; one is generated per call if you do not pass one, or pass an empty one. At most 255 bytes of UTF-8. Sent as the `Idempotency-Key` header only. |
101
+
102
+ An unknown option raises `ValidationError` rather than being dropped, so
103
+ `htlm:` fails on your machine instead of sending a blank email to a customer.
104
+
105
+ ### Attachments
106
+
107
+ Pass **bytes**, not a path, and do not base64-encode them yourself:
108
+
109
+ ```ruby
110
+ nm.emails.send_email(
111
+ from: "Acme <billing@acme.com>",
112
+ to: "customer@example.com",
113
+ subject: "Invoice #1024",
114
+ html: "<p>Attached.</p>",
115
+ attachments: [
116
+ { filename: "invoice-1024.pdf",
117
+ content: File.binread("invoice-1024.pdf"), # you read the file, not us
118
+ content_type: "application/pdf" },
119
+ ],
120
+ )
121
+ ```
122
+
123
+ `content` is always taken as raw bytes — in Ruby the String *is* the byte type
124
+ (`File.binread`, `IO#read` in binary mode). It is never interpreted as base64: a
125
+ String you have already base64-encoded is sent as those characters, encoded a
126
+ second time. (The SDKs for languages with a separate byte type — Node, Python,
127
+ Go — read a text string as base64; Ruby and PHP cannot tell the two apart, so
128
+ they do not try.) An empty attachment is refused locally, as the server would
129
+ refuse it.
130
+
131
+ The SDK never opens a file on your behalf. An SDK that reads whatever path it is
132
+ handed becomes a local-file-disclosure primitive the moment a web handler passes
133
+ user input into it — so you read your own file and hand over the bytes.
134
+
135
+ ### Rejected recipients
136
+
137
+ A `202` can still name recipients we refused (the suppression list). It is not an
138
+ error: the rest of the message went.
139
+
140
+ ```ruby
141
+ sent = nm.emails.send_email(...)
142
+ sent.rejected.each { |r| puts "#{r.address}: #{r.reason}" }
143
+ ```
144
+
145
+ `rejected` is always an array, never `nil`, even though the server omits the key
146
+ when it is empty.
147
+
148
+ ### Statuses
149
+
150
+ `queued`, `sent`, `delivered`, `bounced`, `deferred`, `complained`, `rejected`,
151
+ `failed` — as `NaijaCloud::Email::MessageStatus::DELIVERED` and so on. A status
152
+ we have not seen before comes through as a plain String rather than raising, so a
153
+ new server status does not break an installed gem:
154
+
155
+ ```ruby
156
+ NaijaCloud::Email::MessageStatus.known?(email.status)
157
+ ```
158
+
159
+ Delivery is not a state machine. A message can go `delivered` and then
160
+ `complained`, and providers deliver events out of order often enough that no
161
+ client should assume otherwise.
162
+
163
+ ## Client options
164
+
165
+ ```ruby
166
+ nm = NaijaCloud::Email::Client.new(
167
+ api_key: ENV["NAIJAMAIL_API_KEY"], # default: ENV["NAIJAMAIL_API_KEY"]
168
+ base_url: nil, # default: ENV["NAIJAMAIL_BASE_URL"] or https://api.naijacloud.com
169
+ timeout: 30, # seconds, per attempt; must be > 0
170
+ max_retries: 2, # 3 attempts in total; 0 to 10
171
+ user_agent_suffix: "acme-billing/2.1",
172
+ )
173
+ ```
174
+
175
+ - `timeout` is a deadline on the **whole attempt** — connect, send and reading
176
+ the entire response — not a per-socket-read timeout, so a server trickling a
177
+ byte at a time cannot hold a request open past it. Each retry gets a fresh one.
178
+ - A blank `NAIJAMAIL_BASE_URL` (set but empty) counts as unset.
179
+ - A `base_url` with a query string or fragment is refused: every path the SDK
180
+ appends would land after it.
181
+ - The key is trimmed of surrounding whitespace (a trailing newline from a secrets
182
+ file is common) before it is checked.
183
+
184
+ ### Which key
185
+
186
+ Two kinds work, and the SDK cannot tell them apart once it has one:
187
+
188
+ - **`nc_live_…`** — a workspace API key from **Settings → API keys**, ticked for
189
+ the **Email send** scope. Most teams already have one: it is the same
190
+ credential CI deploys with. Add **Platform API** as well if the key also needs
191
+ to manage sending domains or suppressions.
192
+ - **`nmail_live_…` / `nmail_test_…`** — a Naijamail-only key from **Email**. The
193
+ test variant is **sandboxed**: the API accepts the send, returns a real id
194
+ and a final status, and never hands the message to a mail server. Use one in
195
+ staging and CI. Send from any domain you have added, or from
196
+ `…@test.mail.naijacloud.dev`; send *to* `delivered@`, `bounced@` or
197
+ `complained@test.mail.naijacloud.dev` to get that outcome. A message sent
198
+ this way comes back from `get` with `sandbox` set to true. There is no test
199
+ variant of a workspace key.
200
+
201
+ An `nc_pat_…` platform token is not accepted: those predate the Email send scope
202
+ and the API refuses them on the mail routes, so the SDK refuses them at
203
+ construction rather than a request later, with a message saying so — "this is a
204
+ personal access token (nc_pat_…), which cannot send mail; use a mail API key
205
+ (nmail_live_… or nmail_test_…) or a workspace API key with the Email send scope
206
+ (nc_live_…)".
207
+
208
+ Everything lives on the instance. There is no global configuration, so two
209
+ clients holding two teams' keys can run in one process without one borrowing the
210
+ other's credential.
211
+
212
+ A client is safe to share across threads: each request opens its own connection
213
+ and the client keeps no per-request state.
214
+
215
+ ## Errors
216
+
217
+ Every failure is a `NaijaCloud::Email::Error`, so one `rescue` covers the lot:
218
+
219
+ ```ruby
220
+ begin
221
+ nm.emails.send_email(from: "...", to: "...", subject: "Hi", html: "<p>Hi</p>")
222
+ rescue NaijaCloud::Email::PermissionError => e
223
+ # Unverified domain, a key without the right scope, or a quota.
224
+ warn "#{e.message} (request #{e.request_id})"
225
+ rescue NaijaCloud::Email::RateLimitError => e
226
+ warn "rate limited, retry after #{e.retry_after}s"
227
+ rescue NaijaCloud::Email::Error => e
228
+ warn "#{e.class}: #{e.message} (HTTP #{e.status_code})"
229
+ end
230
+ ```
231
+
232
+ | HTTP | Class | Retried |
233
+ | --- | --- | --- |
234
+ | 400 | `ValidationError` (`NotFoundError` when the message is `message not found`) | no |
235
+ | 401 | `AuthenticationError` | no |
236
+ | 403 | `PermissionError` | no |
237
+ | 404 | `NotFoundError` | no |
238
+ | 408 | `TimeoutError` | yes |
239
+ | 409 | `ConflictError` | no |
240
+ | 413, 422 | `ValidationError` | no |
241
+ | any other 4xx (405, 415, 451…) | `ValidationError` | no |
242
+ | 429 | `RateLimitError` (`#retry_after`) | yes |
243
+ | 5xx | `ServerError` | yes |
244
+ | 3xx | `ServerError` ("unexpected redirect") | no |
245
+ | 2xx that is not a JSON object, or a send response with no `id` | `ServerError` ("malformed response") | no |
246
+ | socket / DNS / TLS | `ConnectionError` | yes |
247
+ | client-side deadline | `TimeoutError` | yes |
248
+ | bad input, caught locally | `ValidationError`, `status_code == 0` | n/a |
249
+
250
+ Every error carries `message`, `status_code`, `error_label` (the server's short
251
+ label), `request_id` (from `x-request-id`), the response text exactly as received
252
+ (`raw_body`, also available as `body`) and that text parsed as JSON
253
+ (`parsed_body`, `nil` when it was not JSON). Quote the `request_id` in a support
254
+ ticket.
255
+
256
+ A `400` for an id that does not exist is a known control-plane quirk — the
257
+ retrieve endpoint raises `BadRequestException('message not found')` instead of a
258
+ 404. The SDK maps that one case to `NotFoundError`, so your code keeps working
259
+ when the server is fixed.
260
+
261
+ ## Retries
262
+
263
+ Three attempts by default, with full-jitter exponential backoff — `base` 500ms,
264
+ `cap` 8s — retried only on `429`, `408`, `5xx`, and connection or timeout
265
+ failures. A `Retry-After` header (integer seconds or an HTTP date) on any
266
+ retried response — a `429` or a `503` alike — overrides the computed backoff and
267
+ is clamped to 60 seconds; `RateLimitError#retry_after` reports the clamped value.
268
+
269
+ A `403` on an unverified domain is never retried. It will not become verified
270
+ between two attempts, and retrying only burns your rate limit.
271
+
272
+ Retrying a `POST` is safe because the SDK generates a UUIDv4 **once per
273
+ `send_email` call** and sends it as `Idempotency-Key` on every attempt of that
274
+ call. Without it, a timeout followed by a retry mails your customer twice — you
275
+ cannot tell "never arrived" from "arrived, response lost". Pass your own
276
+ `idempotency_key:` (derived from an order id, say) and it is used verbatim and
277
+ never regenerated.
278
+
279
+ ## Security
280
+
281
+ The full list is in [SECURITY.md](SECURITY.md). In short:
282
+
283
+ - **HTTPS is enforced** at construction. A plaintext `base_url` is refused unless
284
+ the host is `localhost`, `127.0.0.1` or `::1`.
285
+ - **Redirects are never followed.** Following one would re-send your
286
+ `Authorization` header to whatever host the response named.
287
+ - **The key is never printed.** `inspect`, `to_s` and any dump of the client's
288
+ instance variables show `nmail_live_***`. The client does not even keep the key
289
+ as one of its own instance variables. There is no verbose mode, because a
290
+ verbose mode is a way to print an `Authorization` header.
291
+ - **Header injection is rejected locally** — a `\r`, `\n` or NUL in `from`, any
292
+ address, `subject`, a custom header name or value, or an attachment
293
+ filename, `content_type` or `content_id`.
294
+ - **Limits are checked before the round trip**: 50 recipients, 25 headers, 10
295
+ tags, and 10 MiB of message — measured as the server measures it: the UTF-8
296
+ bytes of `html` and `text` plus the raw (not base64) attachment bytes.
297
+ - **Webhook signatures are compared in constant time.**
298
+
299
+ ## Webhooks
300
+
301
+ > **Live.** Naija Cloud delivers these events to endpoints you register, signed
302
+ > exactly as below. Two details this verifier already handles: the timestamp is
303
+ > taken per delivery *attempt*, so a retry never arrives outside the tolerance
304
+ > window; and during a secret rotation the header carries two `v1=` values for
305
+ > 24 hours, which is why any match is accepted.
306
+
307
+ ```ruby
308
+ # Rails
309
+ class NaijamailWebhooksController < ApplicationController
310
+ skip_before_action :verify_authenticity_token
311
+
312
+ def create
313
+ event = NaijaCloud::Email::Webhooks.verify(
314
+ request.raw_post, # the RAW body, not params
315
+ request.headers["NC-Signature"],
316
+ ENV.fetch("NAIJAMAIL_WEBHOOK_SECRET"),
317
+ )
318
+
319
+ ProcessEmailEvent.perform_later(event.type, event.email_id)
320
+ head :ok
321
+ rescue NaijaCloud::Email::WebhookVerificationError
322
+ head :bad_request
323
+ end
324
+ end
325
+ ```
326
+
327
+ Pass the raw bytes. A parsed-and-re-serialized body produces different bytes than
328
+ the ones that were signed (key order, unicode escaping, whitespace), the
329
+ signature then fails for every legitimate delivery, and the usual "fix" for that
330
+ is to stop verifying. `Webhooks.verify` refuses a Hash outright for this reason.
331
+
332
+ Header format: `NC-Signature: t=1756468800,v1=<hex sha256 hmac>`. The signed
333
+ payload is `"<t>.<raw body>"`, HMAC-SHA256 with the endpoint secret, hex
334
+ lowercase (the verifier accepts either case). The default replay tolerance is 300
335
+ seconds (`tolerance:`); `0` is strict (only the current second passes), and a
336
+ negative or non-numeric tolerance raises `ValidationError`. `t` must be 1–12
337
+ ASCII digits. A payload that verifies but is not a JSON object (an array, say)
338
+ raises `WebhookVerificationError`. Several `v1=` values may appear at once during
339
+ a secret rotation; any match is accepted.
340
+
341
+ ## Local development against a dev control plane
342
+
343
+ ```ruby
344
+ nm = NaijaCloud::Email::Client.new(
345
+ api_key: ENV["NAIJAMAIL_API_KEY"],
346
+ base_url: "http://localhost:3000",
347
+ )
348
+ ```
349
+
350
+ ## Contributing
351
+
352
+ See [CONTRIBUTING.md](CONTRIBUTING.md). The test suite runs offline against a
353
+ mock HTTP server on `127.0.0.1`; it makes no outbound connection.
354
+
355
+ ## License
356
+
357
+ MIT. Copyright (c) 2026 Naija Cloud.