Skip to content

About

Naijamail's official Ruby SDK

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Naijamail — Ruby SDK

gem ruby dependencies license

Install · The API surface · Client options · Errors · Retries · Webhooks · Security

naijacloud-email

The official Ruby SDK for Naijamail, the transactional email API of Naija Cloud.

Zero runtime dependencies. Standard-library net/http only.

require "naijacloud/email"

nm = NaijaCloud::Email::Client.new           # reads NAIJAMAIL_API_KEY

sent = nm.emails.send_email(
  from: "Acme <hello@acme.com>",
  to: "customer@example.com",
  subject: "Your receipt",
  html: "<p>Thanks for your order.</p>",
)

puts sent.id                                  # => "5b1e..."
puts sent.status                              # => "queued"

email = nm.emails.get(sent.id)
puts email.status                             # => "delivered"

Install

gem "naijacloud-email"

Ruby 2.7 or newer.

The API surface

This release wraps two endpoints, send and retrieve. The API also has batch send, a message list, limits, domains and suppressions (API docs); they are not wrapped yet.

nm.emails.send_email(...) POST /v1/emails, returns SendEmailResponse
nm.emails.create(...) alias of send_email
nm.emails.get(id) GET /v1/emails/{id}, returns Email
NaijaCloud::Email::Webhooks.verify(...) verifies a signed webhook delivery

Email#created_at and #delivered_at are the ISO-8601 Strings the server sent (delivered_at is nil until delivery); #created_at_time and #delivered_at_time parse them into Time on demand. Other SDKs surface a native date type here — that difference is deliberate, not a bug.

send_email, not send: send is Object#send, and shadowing it on a resource object means anything that dispatches by name against it — including some mocking libraries — tries to mail a message instead.

Both call styles work, on every supported Ruby:

nm.emails.send_email(from: "...", to: "...", subject: "Hi")
nm.emails.send_email({ from: "...", to: "...", subject: "Hi" })

Send options

Option Type Notes
from: String required. "Name <a@b.com>" or a bare address. The domain must be verified for your team.
to: String or Array required. At least one.
cc:, bcc: String or Array
reply_to: String or Array Sent on the wire as reply_to.
subject: String Always sent; defaults to "".
html:, text: String
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).
attachments: Array of Hashes { filename:, content:, content_type:, content_id: }. content is a String of raw bytes; it must not be empty.
tags: Hash At most 10, key ≤ 64 chars, value ≤ 256.
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.

An unknown option raises ValidationError rather than being dropped, so htlm: fails on your machine instead of sending a blank email to a customer.

Attachments

Pass bytes, not a path, and do not base64-encode them yourself:

nm.emails.send_email(
  from: "Acme <billing@acme.com>",
  to: "customer@example.com",
  subject: "Invoice #1024",
  html: "<p>Attached.</p>",
  attachments: [
    { filename: "invoice-1024.pdf",
      content: File.binread("invoice-1024.pdf"),   # you read the file, not us
      content_type: "application/pdf" },
  ],
)

content is always taken as raw bytes — in Ruby the String is the byte type (File.binread, IO#read in binary mode). It is never interpreted as base64: a String you have already base64-encoded is sent as those characters, encoded a second time. (The SDKs for languages with a separate byte type — Node, Python, Go — read a text string as base64; Ruby and PHP cannot tell the two apart, so they do not try.) An empty attachment is refused locally, as the server would refuse it.

The SDK never opens a file on your behalf. An SDK that reads whatever path it is handed becomes a local-file-disclosure primitive the moment a web handler passes user input into it — so you read your own file and hand over the bytes.

Rejected recipients

A 202 can still name recipients we refused (the suppression list). It is not an error: the rest of the message went.

sent = nm.emails.send_email(...)
sent.rejected.each { |r| puts "#{r.address}: #{r.reason}" }

rejected is always an array, never nil, even though the server omits the key when it is empty.

Statuses

queued, sent, delivered, bounced, deferred, complained, rejected, failed — as NaijaCloud::Email::MessageStatus::DELIVERED and so on. A status we have not seen before comes through as a plain String rather than raising, so a new server status does not break an installed gem:

NaijaCloud::Email::MessageStatus.known?(email.status)

Delivery is not a state machine. A message can go delivered and then complained, and providers deliver events out of order often enough that no client should assume otherwise.

Client options

nm = NaijaCloud::Email::Client.new(
  api_key: ENV["NAIJAMAIL_API_KEY"],   # default: ENV["NAIJAMAIL_API_KEY"]
  base_url: nil,                       # default: ENV["NAIJAMAIL_BASE_URL"] or https://api.naijacloud.com
  timeout: 30,                         # seconds, per attempt; must be > 0
  max_retries: 2,                      # 3 attempts in total; 0 to 10
  user_agent_suffix: "acme-billing/2.1",
)
  • timeout is a deadline on the whole attempt — connect, send and reading the entire response — not a per-socket-read timeout, so a server trickling a byte at a time cannot hold a request open past it. Each retry gets a fresh one.
  • A blank NAIJAMAIL_BASE_URL (set but empty) counts as unset.
  • A base_url with a query string or fragment is refused: every path the SDK appends would land after it.
  • The key is trimmed of surrounding whitespace (a trailing newline from a secrets file is common) before it is checked.

Which key

Two kinds work, and the SDK cannot tell them apart once it has one:

  • nc_live_… — a workspace API key from Settings → API keys, ticked for the Email send scope. Most teams already have one: it is the same credential CI deploys with. Add Platform API as well if the key also needs to manage sending domains or suppressions.
  • nmail_live_… / nmail_test_… — a Naijamail-only key from Email. The test variant is sandboxed: the API accepts the send, returns a real id and a final status, and never hands the message to a mail server. Use one in staging and CI. Send from any domain you have added, or from …@test.mail.naijacloud.dev; send to delivered@, bounced@ or complained@test.mail.naijacloud.dev to get that outcome. A message sent this way comes back from get with sandbox set to true. There is no test variant of a workspace key.

An nc_pat_… platform token is not accepted: those predate the Email send scope and the API refuses them on the mail routes, so the SDK refuses them at construction rather than a request later, with a message saying so — "this is a personal access token (nc_pat_…), which cannot send mail; use a mail API key (nmail_live_… or nmail_test_…) or a workspace API key with the Email send scope (nc_live_…)".

Everything lives on the instance. There is no global configuration, so two clients holding two teams' keys can run in one process without one borrowing the other's credential.

A client is safe to share across threads: each request opens its own connection and the client keeps no per-request state.

Errors

Every failure is a NaijaCloud::Email::Error, so one rescue covers the lot:

begin
  nm.emails.send_email(from: "...", to: "...", subject: "Hi", html: "<p>Hi</p>")
rescue NaijaCloud::Email::PermissionError => e
  # Unverified domain, a key without the right scope, or a quota.
  warn "#{e.message} (request #{e.request_id})"
rescue NaijaCloud::Email::RateLimitError => e
  warn "rate limited, retry after #{e.retry_after}s"
rescue NaijaCloud::Email::Error => e
  warn "#{e.class}: #{e.message} (HTTP #{e.status_code})"
end
HTTP Class Retried
400 ValidationError (NotFoundError when the message is message not found) no
401 AuthenticationError no
403 PermissionError no
404 NotFoundError no
408 TimeoutError yes
409 ConflictError no
413, 422 ValidationError no
any other 4xx (405, 415, 451…) ValidationError no
429 RateLimitError (#retry_after) yes
5xx ServerError yes
3xx ServerError ("unexpected redirect") no
2xx that is not a JSON object, or a send response with no id ServerError ("malformed response") no
socket / DNS / TLS ConnectionError yes
client-side deadline TimeoutError yes
bad input, caught locally ValidationError, status_code == 0 n/a

Every error carries message, status_code, error_label (the server's short label), request_id (from x-request-id), the response text exactly as received (raw_body, also available as body) and that text parsed as JSON (parsed_body, nil when it was not JSON). Quote the request_id in a support ticket.

A 400 for an id that does not exist is a known control-plane quirk — the retrieve endpoint raises BadRequestException('message not found') instead of a 404. The SDK maps that one case to NotFoundError, so your code keeps working when the server is fixed.

Retries

Three attempts by default, with full-jitter exponential backoff — base 500ms, cap 8s — retried only on 429, 408, 5xx, and connection or timeout failures. A Retry-After header (integer seconds or an HTTP date) on any retried response — a 429 or a 503 alike — overrides the computed backoff and is clamped to 60 seconds; RateLimitError#retry_after reports the clamped value.

A 403 on an unverified domain is never retried. It will not become verified between two attempts, and retrying only burns your rate limit.

Retrying a POST is safe because the SDK generates a UUIDv4 once per send_email call and sends it as Idempotency-Key on every attempt of that call. Without it, a timeout followed by a retry mails your customer twice — you cannot tell "never arrived" from "arrived, response lost". Pass your own idempotency_key: (derived from an order id, say) and it is used verbatim and never regenerated.

Security

The full list is in SECURITY.md. In short:

  • HTTPS is enforced at construction. A plaintext base_url is refused unless the host is localhost, 127.0.0.1 or ::1.
  • Redirects are never followed. Following one would re-send your Authorization header to whatever host the response named.
  • The key is never printed. inspect, to_s and any dump of the client's instance variables show nmail_live_***. The client does not even keep the key as one of its own instance variables. There is no verbose mode, because a verbose mode is a way to print an Authorization header.
  • Header injection is rejected locally — a \r, \n or NUL in from, any address, subject, a custom header name or value, or an attachment filename, content_type or content_id.
  • Limits are checked before the round trip: 50 recipients, 25 headers, 10 tags, and 10 MiB of message — measured as the server measures it: the UTF-8 bytes of html and text plus the raw (not base64) attachment bytes.
  • Webhook signatures are compared in constant time.

Webhooks

Live. Naija Cloud delivers these events to endpoints you register, signed exactly as below. Two details this verifier already handles: the timestamp is taken per delivery attempt, so a retry never arrives outside the tolerance window; and during a secret rotation the header carries two v1= values for 24 hours, which is why any match is accepted.

# Rails
class NaijamailWebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def create
    event = NaijaCloud::Email::Webhooks.verify(
      request.raw_post,                       # the RAW body, not params
      request.headers["NC-Signature"],
      ENV.fetch("NAIJAMAIL_WEBHOOK_SECRET"),
    )

    ProcessEmailEvent.perform_later(event.type, event.email_id)
    head :ok
  rescue NaijaCloud::Email::WebhookVerificationError
    head :bad_request
  end
end

Pass the raw bytes. A parsed-and-re-serialized body produces different bytes than the ones that were signed (key order, unicode escaping, whitespace), the signature then fails for every legitimate delivery, and the usual "fix" for that is to stop verifying. Webhooks.verify refuses a Hash outright for this reason.

Header format: NC-Signature: t=1756468800,v1=<hex sha256 hmac>. The signed payload is "<t>.<raw body>", HMAC-SHA256 with the endpoint secret, hex lowercase (the verifier accepts either case). The default replay tolerance is 300 seconds (tolerance:); 0 is strict (only the current second passes), and a negative or non-numeric tolerance raises ValidationError. t must be 1–12 ASCII digits. A payload that verifies but is not a JSON object (an array, say) raises WebhookVerificationError. Several v1= values may appear at once during a secret rotation; any match is accepted.

Local development against a dev control plane

nm = NaijaCloud::Email::Client.new(
  api_key: ENV["NAIJAMAIL_API_KEY"],
  base_url: "http://localhost:3000",
)

Contributing

See CONTRIBUTING.md. The test suite runs offline against a mock HTTP server on 127.0.0.1; it makes no outbound connection.

License

MIT. Copyright (c) 2026 Naija Cloud.

About

Naijamail's official Ruby SDK

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages