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
|
@@ -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: []
|