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