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,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "json"
5
+ require "time"
6
+
7
+ module NaijaCloud
8
+ module Email
9
+ # Verifies a signed webhook delivery.
10
+ #
11
+ # The control plane delivers customer-facing event webhooks signed with
12
+ # exactly this scheme (SDK contract section 6).
13
+ module Webhooks
14
+ SIGNATURE_HEADER = "NC-Signature"
15
+ DEFAULT_TOLERANCE = 300
16
+
17
+ class << self
18
+ # payload: the raw request body, exactly as received.
19
+ # signature_header: the value of the NC-Signature header.
20
+ # secret: the endpoint secret (nmail_whsec_...).
21
+ #
22
+ # Returns a WebhookEvent, or raises WebhookVerificationError.
23
+ def verify(payload, signature_header, secret, tolerance: DEFAULT_TOLERANCE)
24
+ # A Hash here is the classic mistake: a framework has already parsed the
25
+ # body, the caller passes the parsed object, and re-serializing it
26
+ # produces different bytes (key order, unicode escaping, whitespace)
27
+ # than the ones that were signed. The signature then fails for every
28
+ # legitimate delivery, and the usual "fix" is to stop verifying.
29
+ unless payload.is_a?(String)
30
+ raise WebhookVerificationError.new(
31
+ "payload must be the raw request body as a String, not a parsed object",
32
+ )
33
+ end
34
+ unless secret.is_a?(String) && !secret.strip.empty?
35
+ raise WebhookVerificationError.new("a webhook signing secret is required")
36
+ end
37
+
38
+ # A tolerance that is not a finite, non-negative number would make the
39
+ # replay check meaningless ("abc".to_i is 0, NaN compares false against
40
+ # everything). 0 is strict -- only the current second passes -- and
41
+ # never means "use the default".
42
+ unless tolerance.is_a?(Numeric) && tolerance.real? && tolerance.to_f.finite? && tolerance >= 0
43
+ raise ValidationError.new("tolerance must be a finite number of seconds, 0 or more")
44
+ end
45
+
46
+ timestamp, signatures = parse_header(signature_header)
47
+
48
+ age = (Time.now.to_i - timestamp).abs
49
+ if age > tolerance
50
+ # This is the whole point of signing the timestamp: without it a
51
+ # captured delivery stays valid forever and can be replayed.
52
+ raise WebhookVerificationError.new(
53
+ "timestamp is #{age}s away from now, outside the #{tolerance}s tolerance",
54
+ )
55
+ end
56
+
57
+ # Signed over the raw bytes. Force binary on both halves so a UTF-8
58
+ # body and an ASCII timestamp cannot raise Encoding::CompatibilityError
59
+ # on concatenation.
60
+ signed = "#{timestamp}.".dup.force_encoding(Encoding::BINARY) +
61
+ payload.dup.force_encoding(Encoding::BINARY)
62
+ expected = OpenSSL::HMAC.hexdigest("SHA256", secret, signed)
63
+
64
+ # Several v1 values may be present while a secret is being rotated: the
65
+ # sender signs with both the old and the new secret so neither end has
66
+ # to cut over at an exact instant.
67
+ matched = signatures.any? { |candidate| secure_equal?(expected, candidate) }
68
+
69
+ unless matched
70
+ # Never include `expected` in this message. Handing back the
71
+ # signature an attacker failed to guess turns the verifier into an
72
+ # oracle that produces it for them.
73
+ raise WebhookVerificationError.new("signature does not match")
74
+ end
75
+
76
+ begin
77
+ parsed = JSON.parse(payload)
78
+ rescue JSON::ParserError => e
79
+ raise WebhookVerificationError.new("signature is valid but the payload is not JSON: #{e.class}")
80
+ end
81
+
82
+ # An array, string or number is valid JSON but not an event; turning it
83
+ # into an empty WebhookEvent would hand the caller a blank event they
84
+ # might act on.
85
+ unless parsed.is_a?(Hash)
86
+ raise WebhookVerificationError.new("signature is valid but the payload is not a JSON object")
87
+ end
88
+
89
+ WebhookEvent.from_hash(parsed)
90
+ end
91
+
92
+ private
93
+
94
+ # "t=1756468800,v1=abc,v1=def"
95
+ def parse_header(header)
96
+ unless header.is_a?(String) && !header.strip.empty?
97
+ raise WebhookVerificationError.new("missing #{SIGNATURE_HEADER} header")
98
+ end
99
+
100
+ timestamp = nil
101
+ signatures = []
102
+
103
+ header.split(",").each do |part|
104
+ name, value = part.strip.split("=", 2)
105
+ next if value.nil?
106
+
107
+ case name
108
+ when "t" then timestamp = value
109
+ # Hex is case-insensitive; the expected digest is lower-case.
110
+ when "v1" then signatures << value.downcase
111
+ end
112
+ end
113
+
114
+ # 1-12 ASCII digits and nothing else (contract section 6): no sign, no
115
+ # underscore, no exponent, and bounded so it can never be a huge
116
+ # Integer.
117
+ unless timestamp.is_a?(String) && timestamp.b =~ /\A[0-9]{1,12}\z/
118
+ raise WebhookVerificationError.new("#{SIGNATURE_HEADER} has no usable timestamp")
119
+ end
120
+ if signatures.empty?
121
+ raise WebhookVerificationError.new("#{SIGNATURE_HEADER} has no v1 signature")
122
+ end
123
+
124
+ [timestamp.to_i, signatures]
125
+ end
126
+
127
+ # Never `==`. String comparison stops at the first differing byte, so the
128
+ # time it takes leaks how much of a guess was right -- enough, over many
129
+ # requests, to reconstruct a signature a byte at a time.
130
+ #
131
+ # OpenSSL.secure_compare only exists from the openssl gem 2.2 (Ruby 3.0);
132
+ # on 2.7 the fallback below runs, so it is written to be constant time in
133
+ # the length of the compared strings. The length itself is not secret:
134
+ # both sides are a hex SHA-256, always 64 characters.
135
+ def secure_equal?(expected, candidate)
136
+ return false unless candidate.is_a?(String)
137
+
138
+ if OpenSSL.respond_to?(:secure_compare)
139
+ OpenSSL.secure_compare(expected, candidate)
140
+ else
141
+ a = expected.b
142
+ b = candidate.b
143
+ return false unless a.bytesize == b.bytesize
144
+
145
+ difference = 0
146
+ a.bytes.each_with_index { |byte, index| difference |= byte ^ b.getbyte(index) }
147
+ difference.zero?
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NaijaCloud
4
+ # The official Ruby SDK for Naijamail, Naija Cloud's transactional email API.
5
+ #
6
+ # Note for anyone editing files in this namespace: `NaijaCloud::Email::Email`
7
+ # is a class (the retrieve response), so inside `module Email` the bare
8
+ # constant `Email` resolves to that class and not to this module. Reach for
9
+ # this module by its full path, as the redaction calls below do.
10
+ module Email
11
+ # Renders a key safe to print. The prefix is kept because it is not secret
12
+ # and it is what makes a leaked key recognisable to a secret scanner; every
13
+ # byte after it is dropped. No length hint, no first-four/last-four: both
14
+ # narrow a brute force, and neither helps a human more than the prefix does.
15
+ def self.redact_key(key)
16
+ return "***" unless key.is_a?(String)
17
+
18
+ match = key.match(/\A(?:nmail_(?:live|test)|nc_live)_/)
19
+ match ? "#{match[0]}***" : "***"
20
+ end
21
+ end
22
+ end
23
+
24
+ require "naijacloud/email/version"
25
+ require "naijacloud/email/errors"
26
+ require "naijacloud/email/objects"
27
+ require "naijacloud/email/http"
28
+ require "naijacloud/email/emails"
29
+ require "naijacloud/email/webhooks"
30
+ require "naijacloud/email/client"
metadata ADDED
@@ -0,0 +1,91 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: naijacloud-email
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - Naija Cloud
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-10-07 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: minitest
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ description: 'Send and retrieve transactional email through the Naijamail API. No
42
+ runtime dependencies: standard-library net/http only.'
43
+ email:
44
+ - support@naijacloud.com
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - CHANGELOG.md
50
+ - CONTRIBUTING.md
51
+ - LICENSE
52
+ - README.md
53
+ - SECURITY.md
54
+ - lib/naijacloud/email.rb
55
+ - lib/naijacloud/email/client.rb
56
+ - lib/naijacloud/email/emails.rb
57
+ - lib/naijacloud/email/errors.rb
58
+ - lib/naijacloud/email/http.rb
59
+ - lib/naijacloud/email/objects.rb
60
+ - lib/naijacloud/email/version.rb
61
+ - lib/naijacloud/email/webhooks.rb
62
+ homepage: https://github.com/naijacloud/nc-email-ruby
63
+ licenses:
64
+ - MIT
65
+ metadata:
66
+ homepage_uri: https://github.com/naijacloud/nc-email-ruby
67
+ source_code_uri: https://github.com/naijacloud/nc-email-ruby
68
+ changelog_uri: https://github.com/naijacloud/nc-email-ruby/blob/main/CHANGELOG.md
69
+ bug_tracker_uri: https://github.com/naijacloud/nc-email-ruby/issues
70
+ documentation_uri: https://naijacloud.com/docs/api/email
71
+ rubygems_mfa_required: 'true'
72
+ post_install_message:
73
+ rdoc_options: []
74
+ require_paths:
75
+ - lib
76
+ required_ruby_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: 2.7.0
81
+ required_rubygems_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ requirements: []
87
+ rubygems_version: 3.5.22
88
+ signing_key:
89
+ specification_version: 4
90
+ summary: Ruby SDK for Naijamail, the Naija Cloud transactional email API.
91
+ test_files: []