zapsmtp 0.1.0.beta3
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/LICENSE +21 -0
- data/README.md +326 -0
- data/lib/zapsmtp/attachment.rb +55 -0
- data/lib/zapsmtp/base_client.rb +225 -0
- data/lib/zapsmtp/core.rb +18 -0
- data/lib/zapsmtp/errors.rb +88 -0
- data/lib/zapsmtp/generated/client.rb +92 -0
- data/lib/zapsmtp/generated/models.rb +2618 -0
- data/lib/zapsmtp/generated/resources.rb +2818 -0
- data/lib/zapsmtp/model.rb +165 -0
- data/lib/zapsmtp/page.rb +147 -0
- data/lib/zapsmtp/rails/delivery_method.rb +71 -0
- data/lib/zapsmtp/rails/message.rb +74 -0
- data/lib/zapsmtp/rails/webhook_verification.rb +67 -0
- data/lib/zapsmtp/rails.rb +70 -0
- data/lib/zapsmtp/railtie.rb +17 -0
- data/lib/zapsmtp/response.rb +40 -0
- data/lib/zapsmtp/retry.rb +48 -0
- data/lib/zapsmtp/transport.rb +130 -0
- data/lib/zapsmtp/util.rb +119 -0
- data/lib/zapsmtp/version.rb +6 -0
- data/lib/zapsmtp/webhook.rb +130 -0
- data/lib/zapsmtp/webhook_middleware.rb +55 -0
- data/lib/zapsmtp.rb +17 -0
- metadata +75 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module ZapSMTP
|
|
6
|
+
# When a failed request is tried again, and after how long
|
|
7
|
+
# (docs/DESIGN-sdks.md, retries).
|
|
8
|
+
module Retry
|
|
9
|
+
RETRY_STATUSES = [408, 429, 500, 502, 503, 504].freeze
|
|
10
|
+
NO_RETRY_429 = %w[monthly_quota_exceeded daily_quota_exceeded thread_reply_limit].freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Whether an error answer is worth another attempt. A 429 was refused
|
|
15
|
+
# before any work, so it is always safe; other statuses only for a
|
|
16
|
+
# request that is safe to repeat.
|
|
17
|
+
def retryable?(status, code, safe)
|
|
18
|
+
return !NO_RETRY_429.include?(code) if status == 429
|
|
19
|
+
return code == "idempotency_request_in_progress" if status == 409
|
|
20
|
+
|
|
21
|
+
safe && RETRY_STATUSES.include?(status)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Seconds to wait before attempt number attempt + 1: Retry-After up to 60
|
|
25
|
+
# seconds, else exponential backoff (0.5, 1, 2 ... capped at 8) with jitter.
|
|
26
|
+
def backoff(attempt, retry_after)
|
|
27
|
+
return [retry_after, 60].min.to_f if retry_after
|
|
28
|
+
|
|
29
|
+
base = [0.5 * (2**attempt), 8.0].min
|
|
30
|
+
(base / 2) + (rand * (base / 2))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The Retry-After header in seconds (a number or an HTTP date), or nil.
|
|
34
|
+
def retry_after(headers)
|
|
35
|
+
value = headers && headers["retry-after"]
|
|
36
|
+
return nil if value.nil? || value.strip.empty?
|
|
37
|
+
|
|
38
|
+
seconds = Float(value, exception: false)
|
|
39
|
+
return [seconds, 0.0].max if seconds&.finite?
|
|
40
|
+
|
|
41
|
+
begin
|
|
42
|
+
[Time.httpdate(value.strip) - Time.now, 0.0].max
|
|
43
|
+
rescue ArgumentError, RangeError
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module ZapSMTP
|
|
8
|
+
# How requests reach the API. A transport is any object with
|
|
9
|
+
# #call(request) -> response, taking a Transport::Request and answering a
|
|
10
|
+
# Transport::Response (or anything with status, headers and body); it
|
|
11
|
+
# raises on a network failure or timeout. Pass one as
|
|
12
|
+
# ZapSMTP::Client.new(transport: ...) to use another HTTP library or to
|
|
13
|
+
# stub the API in tests.
|
|
14
|
+
module Transport
|
|
15
|
+
# One HTTP attempt: http_method ("GET"...), url, headers (Hash), body
|
|
16
|
+
# (String or nil) and timeout (seconds).
|
|
17
|
+
Request = Struct.new(:http_method, :url, :headers, :body, :timeout, keyword_init: true)
|
|
18
|
+
|
|
19
|
+
# An HTTP answer: status (Integer), headers (Hash) and body (String).
|
|
20
|
+
Response = Struct.new(:status, :headers, :body, keyword_init: true)
|
|
21
|
+
|
|
22
|
+
# A timeout: no connection, or no answer, within the timeout.
|
|
23
|
+
class TimeoutError < StandardError; end
|
|
24
|
+
|
|
25
|
+
# The answer's head came but its body broke off (or stalled): the
|
|
26
|
+
# status and headers are kept for the ConnectionError.
|
|
27
|
+
class ResponseBodyError < StandardError
|
|
28
|
+
# @return [Integer]
|
|
29
|
+
attr_reader :status
|
|
30
|
+
# @return [Hash]
|
|
31
|
+
attr_reader :headers
|
|
32
|
+
|
|
33
|
+
def initialize(message, status:, headers:)
|
|
34
|
+
super(message)
|
|
35
|
+
@status = status
|
|
36
|
+
@headers = headers
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The default transport: Net::HTTP, keeping one connection alive per
|
|
41
|
+
# thread and host. The timeout bounds connecting, each write and each
|
|
42
|
+
# read, the response body's included.
|
|
43
|
+
class NetHTTP
|
|
44
|
+
TIMEOUTS = [Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout].freeze
|
|
45
|
+
# The encodings Net::HTTP inflates (when it asked for compression itself).
|
|
46
|
+
INFLATED = %w[gzip deflate x-gzip].freeze
|
|
47
|
+
|
|
48
|
+
def initialize
|
|
49
|
+
@key = :"zapsmtp_connections_#{object_id}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def call(request)
|
|
53
|
+
uri = URI.parse(request.url)
|
|
54
|
+
head = nil
|
|
55
|
+
begin
|
|
56
|
+
http = connection(uri, request.timeout)
|
|
57
|
+
message = Net::HTTPGenericRequest.new(request.http_method, !request.body.nil?, true, uri.request_uri,
|
|
58
|
+
request.headers)
|
|
59
|
+
message.body = request.body
|
|
60
|
+
answer = http.request(message) do |response|
|
|
61
|
+
head = response
|
|
62
|
+
read_body(response)
|
|
63
|
+
end
|
|
64
|
+
rescue StandardError => e
|
|
65
|
+
close(uri)
|
|
66
|
+
raise failure(e, head, request.timeout)
|
|
67
|
+
end
|
|
68
|
+
Response.new(status: answer.code.to_i, headers: headers(answer),
|
|
69
|
+
body: answer.body.to_s.dup.force_encoding(Encoding::UTF_8))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def headers(response)
|
|
75
|
+
response.to_hash.transform_values { |v| v.join(", ") }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The error to raise for a failed attempt: a timeout says so, and a
|
|
79
|
+
# failure after the answer's head keeps its status and headers.
|
|
80
|
+
def failure(error, head, timeout)
|
|
81
|
+
timed_out = TIMEOUTS.any? { |t| error.is_a?(t) }
|
|
82
|
+
message = timed_out ? "timed out after #{timeout} s" : error.message
|
|
83
|
+
return ResponseBodyError.new(message, status: head.code.to_i, headers: headers(head)) if head
|
|
84
|
+
|
|
85
|
+
timed_out ? TimeoutError.new(message) : error
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Reads the body, refusing one cut short: Net::HTTP returns what came
|
|
89
|
+
# before the connection closed. Content-Length counts the bytes on the
|
|
90
|
+
# wire, so it is checked unless Net::HTTP inflated them (a compressed
|
|
91
|
+
# body cut short fails to inflate instead).
|
|
92
|
+
def read_body(response)
|
|
93
|
+
inflated = response.decode_content && INFLATED.include?(response["content-encoding"].to_s.downcase)
|
|
94
|
+
length = response.content_length
|
|
95
|
+
response.read_body
|
|
96
|
+
return if inflated || length.nil? || response.body.to_s.bytesize >= length
|
|
97
|
+
|
|
98
|
+
raise EOFError, "response body cut short: #{response.body.to_s.bytesize} of #{length} bytes"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# This thread's open connection to the URI's host, or a new one. A
|
|
102
|
+
# forked process never reuses its parent's connections.
|
|
103
|
+
def connection(uri, timeout)
|
|
104
|
+
pool = Thread.current[@key]
|
|
105
|
+
pool = Thread.current[@key] = { pid: Process.pid } if pool.nil? || pool[:pid] != Process.pid
|
|
106
|
+
http = pool[origin(uri)]
|
|
107
|
+
unless http&.started?
|
|
108
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
109
|
+
http.use_ssl = uri.scheme == "https"
|
|
110
|
+
http.open_timeout = timeout
|
|
111
|
+
http.max_retries = 0 # the client decides what is retried
|
|
112
|
+
http.start
|
|
113
|
+
pool[origin(uri)] = http
|
|
114
|
+
end
|
|
115
|
+
http.read_timeout = timeout
|
|
116
|
+
http.write_timeout = timeout
|
|
117
|
+
http
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def close(uri)
|
|
121
|
+
http = Thread.current[@key].delete(origin(uri))
|
|
122
|
+
http.finish if http&.started?
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def origin(uri)
|
|
126
|
+
"#{uri.scheme}://#{uri.host}:#{uri.port}"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
data/lib/zapsmtp/util.rb
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module ZapSMTP
|
|
8
|
+
# Request building helpers used by the resources.
|
|
9
|
+
#
|
|
10
|
+
# @api private
|
|
11
|
+
module Util
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# A call's parameters: a Hash and keyword arguments, merged, with String keys.
|
|
15
|
+
def params(params, kwargs)
|
|
16
|
+
raise ArgumentError, "params must be a Hash, got #{params.class}" unless params.respond_to?(:each_pair)
|
|
17
|
+
|
|
18
|
+
merged = {}
|
|
19
|
+
params.each_pair { |k, v| merged[k.to_s] = v }
|
|
20
|
+
kwargs.each_pair { |k, v| merged[k.to_s] = v }
|
|
21
|
+
merged
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The given keys of params, or nil when it has none of them.
|
|
25
|
+
def pick(params, keys)
|
|
26
|
+
picked = params.slice(*keys)
|
|
27
|
+
picked.empty? ? nil : picked
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# A path segment, escaped. An empty value, "." or ".." can be no id: URL
|
|
31
|
+
# normalization would resolve it into another path (percent-encoded too),
|
|
32
|
+
# so it is refused before anything is sent.
|
|
33
|
+
def seg(name, value)
|
|
34
|
+
value = value.to_s
|
|
35
|
+
if ["", ".", ".."].include?(value)
|
|
36
|
+
raise ValidationError.new("#{name} must not be #{value.empty? ? "empty" : "\"#{value}\""}",
|
|
37
|
+
code: "invalid_parameter")
|
|
38
|
+
end
|
|
39
|
+
URI.encode_www_form_component(value).gsub("+", "%20")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The query string: nil values are left out, arrays repeat their key and
|
|
43
|
+
# hashes become key[name]=value.
|
|
44
|
+
def query_string(query)
|
|
45
|
+
pairs = []
|
|
46
|
+
(query || {}).each do |key, value|
|
|
47
|
+
case value
|
|
48
|
+
when nil then next
|
|
49
|
+
when Array then value.each { |v| pairs << [key.to_s, scalar(v)] }
|
|
50
|
+
when Hash then value.each { |k, v| pairs << ["#{key}[#{k}]", scalar(v)] }
|
|
51
|
+
else pairs << [key.to_s, scalar(value)]
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
URI.encode_www_form(pairs)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def scalar(value)
|
|
58
|
+
value.respond_to?(:iso8601) ? value.iso8601 : value.to_s
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# A value made ready for JSON: times as RFC 3339, models as hashes.
|
|
62
|
+
def jsonable(value)
|
|
63
|
+
case value
|
|
64
|
+
when Hash then value.to_h { |k, v| [k, jsonable(v)] }
|
|
65
|
+
when Array then value.map { |v| jsonable(v) }
|
|
66
|
+
when Model then value.to_h
|
|
67
|
+
else value.respond_to?(:iso8601) ? value.iso8601 : value
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# A parsed JSON value with every number that is not finite (NaN,
|
|
72
|
+
# Infinity, or one too large for a Float) as nil.
|
|
73
|
+
def finite(value)
|
|
74
|
+
case value
|
|
75
|
+
when Hash then value.transform_values { |v| finite(v) }
|
|
76
|
+
when Array then value.map { |v| finite(v) }
|
|
77
|
+
when Float then value.finite? ? value : nil
|
|
78
|
+
else value
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# A multipart/form-data body.
|
|
83
|
+
Form = Struct.new(:body, :content_type)
|
|
84
|
+
|
|
85
|
+
# The multipart body of a file upload: field holds the file; name, when
|
|
86
|
+
# given, is sent as the "name" field.
|
|
87
|
+
def upload_form(field, file, name:, filename:)
|
|
88
|
+
data, own_name = read_file(file)
|
|
89
|
+
filename ||= name || own_name
|
|
90
|
+
boundary = "zapsmtp-#{SecureRandom.hex(16)}"
|
|
91
|
+
body = String.new(encoding: Encoding::BINARY)
|
|
92
|
+
type = Attachment.content_type_for(filename) || "application/octet-stream"
|
|
93
|
+
body << part_head(boundary, field, filename) << "Content-Type: #{type}\r\n\r\n".b << data.b << "\r\n".b
|
|
94
|
+
body << part_head(boundary, "name", nil) << "\r\n".b << name.to_s.b << "\r\n".b unless name.nil?
|
|
95
|
+
body << "--#{boundary}--\r\n".b
|
|
96
|
+
Form.new(body, "multipart/form-data; boundary=#{boundary}")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def part_head(boundary, field, filename)
|
|
100
|
+
head = "--#{boundary}\r\nContent-Disposition: form-data; name=\"#{quote(field)}\""
|
|
101
|
+
head << "; filename=\"#{quote(filename)}\"" if filename
|
|
102
|
+
(head << "\r\n").b
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# A form-data name or filename, escaped as browsers do.
|
|
106
|
+
def quote(value)
|
|
107
|
+
value.to_s.gsub("\"", "%22").gsub("\r", "%0D").gsub("\n", "%0A")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The bytes of a file given by path (a String or Pathname, or a File) or
|
|
111
|
+
# as another IO, and its own name.
|
|
112
|
+
def read_file(file)
|
|
113
|
+
path = file.respond_to?(:to_path) ? file.to_path : file
|
|
114
|
+
return [File.binread(path), File.basename(path)] if path.is_a?(String)
|
|
115
|
+
|
|
116
|
+
[file.read, "image"]
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
module ZapSMTP
|
|
7
|
+
# Webhook signature verification (docs: Webhook events). Every request
|
|
8
|
+
# ZapSMTP sends carries Zap-Signature: t=<unix seconds>,v1=<hex>, where v1
|
|
9
|
+
# is HMAC-SHA256 over "<t>.<raw body>" keyed with the whole secret (its
|
|
10
|
+
# whsec_ prefix included). Two v1 entries are sent for 24 hours after a
|
|
11
|
+
# rotation.
|
|
12
|
+
module Webhook
|
|
13
|
+
DEFAULT_TOLERANCE = 300
|
|
14
|
+
SIGNATURE_HEADER = "zap-signature"
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Checks a webhook request's signature and returns its event. Pass the
|
|
19
|
+
# body exactly as received, before any JSON parsing. A webhook with batch
|
|
20
|
+
# delivery sends an array of events: use .verify_events.
|
|
21
|
+
#
|
|
22
|
+
# event = ZapSMTP::Webhook.verify(request.raw_post, request.headers, ENV["ZAP_WEBHOOK_SECRET"])
|
|
23
|
+
# event.type # => "email.delivered"
|
|
24
|
+
#
|
|
25
|
+
# @param payload [String] the raw request body
|
|
26
|
+
# @param headers [Hash, #each, String] the request headers (a Hash in any
|
|
27
|
+
# case, a Rack env, ActionDispatch headers), or the Zap-Signature value
|
|
28
|
+
# @param secret [String] the webhook's signing secret, whsec_...
|
|
29
|
+
# @param tolerance [Integer] the oldest signature accepted, in seconds
|
|
30
|
+
# @param now [Integer, nil] the time to check against, in unix seconds (default: now)
|
|
31
|
+
# @return [ZapSMTP::WebhookEvent]
|
|
32
|
+
# @raise [ZapSMTP::WebhookVerificationError]
|
|
33
|
+
def verify(payload, headers, secret, tolerance: DEFAULT_TOLERANCE, now: nil)
|
|
34
|
+
body = verified(payload, headers, secret, tolerance: tolerance, now: now)
|
|
35
|
+
raise WebhookVerificationError, "The body is a batch of events: use verify_events" if body.is_a?(Array)
|
|
36
|
+
|
|
37
|
+
event(body)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Checks a webhook request's signature and returns its events: every
|
|
41
|
+
# event of a batch (a webhook with batch: true), or the one event of any
|
|
42
|
+
# other request. Takes the arguments of .verify.
|
|
43
|
+
#
|
|
44
|
+
# @return [Array<ZapSMTP::WebhookEvent>]
|
|
45
|
+
# @raise [ZapSMTP::WebhookVerificationError]
|
|
46
|
+
def verify_events(payload, headers, secret, tolerance: DEFAULT_TOLERANCE, now: nil)
|
|
47
|
+
body = verified(payload, headers, secret, tolerance: tolerance, now: now)
|
|
48
|
+
(body.is_a?(Array) ? body : [body]).map { |item| event(item) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Checks the signature and answers the parsed body: an event, or an
|
|
52
|
+
# array of them for a batch.
|
|
53
|
+
#
|
|
54
|
+
# @api private
|
|
55
|
+
def verified(payload, headers, secret, tolerance:, now:)
|
|
56
|
+
signature = headers.is_a?(String) ? headers : signature_header(headers)
|
|
57
|
+
raise WebhookVerificationError, "Missing Zap-Signature header" if signature.nil? || signature.empty?
|
|
58
|
+
raise WebhookVerificationError, "Missing webhook secret" if secret.nil? || secret.to_s.empty?
|
|
59
|
+
|
|
60
|
+
timestamp, signatures = parse_signature(signature)
|
|
61
|
+
# unix seconds: at most 12 digits
|
|
62
|
+
unless timestamp.match?(/\A\d{1,12}\z/) && !signatures.empty?
|
|
63
|
+
raise WebhookVerificationError, "Malformed Zap-Signature header"
|
|
64
|
+
end
|
|
65
|
+
if ((now || Time.now.to_i) - timestamp.to_i).abs > tolerance
|
|
66
|
+
raise WebhookVerificationError, "Webhook signature is too old (or from the future)"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
body = payload.to_s.b
|
|
70
|
+
expected = OpenSSL::HMAC.digest("SHA256", secret.to_s, "#{timestamp}.".b + body)
|
|
71
|
+
unless signatures.any? { |hex| matches?(hex, expected) }
|
|
72
|
+
raise WebhookVerificationError, "Webhook signature does not match"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
parse(body)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The Zap-Signature value among headers, whatever their case or form
|
|
79
|
+
# (Zap-Signature, zap_signature, Rack's HTTP_ZAP_SIGNATURE).
|
|
80
|
+
#
|
|
81
|
+
# @api private
|
|
82
|
+
def signature_header(headers)
|
|
83
|
+
return nil unless headers.respond_to?(:each)
|
|
84
|
+
|
|
85
|
+
headers.each do |name, value|
|
|
86
|
+
name = name.to_s.downcase.tr("_", "-").delete_prefix("http-")
|
|
87
|
+
return value.is_a?(Array) ? value.join(",") : value.to_s if name == SIGNATURE_HEADER
|
|
88
|
+
end
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @api private
|
|
93
|
+
def parse_signature(signature)
|
|
94
|
+
timestamp = ""
|
|
95
|
+
signatures = []
|
|
96
|
+
signature.split(",").each do |part|
|
|
97
|
+
key, value = part.split("=", 2).map(&:strip)
|
|
98
|
+
if key == "t"
|
|
99
|
+
timestamp = value.to_s
|
|
100
|
+
elsif key == "v1"
|
|
101
|
+
signatures << value.to_s
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
[timestamp, signatures]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Compares a v1 entry with the expected HMAC in constant time.
|
|
108
|
+
#
|
|
109
|
+
# @api private
|
|
110
|
+
def matches?(hex, expected)
|
|
111
|
+
return false unless hex.match?(/\A[0-9a-f]{64}\z/i)
|
|
112
|
+
|
|
113
|
+
OpenSSL.fixed_length_secure_compare([hex].pack("H*"), expected)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# @api private
|
|
117
|
+
def parse(body)
|
|
118
|
+
Util.finite(JSON.parse(body.dup.force_encoding(Encoding::UTF_8), symbolize_names: true))
|
|
119
|
+
rescue JSON::ParserError, EncodingError
|
|
120
|
+
raise WebhookVerificationError, "Webhook body is not JSON"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# @api private
|
|
124
|
+
def event(item)
|
|
125
|
+
raise WebhookVerificationError, "Webhook body is not an event" unless item.is_a?(Hash)
|
|
126
|
+
|
|
127
|
+
WebhookEvent.new(item)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module ZapSMTP
|
|
6
|
+
# Rack middleware that checks the signature of ZapSMTP webhook requests
|
|
7
|
+
# (Rails, Sinatra, Hanami, any Rack app). A request to +path+ (or every
|
|
8
|
+
# request, without one) with a valid signature reaches the app with its
|
|
9
|
+
# events in env["zapsmtp.events"] (one, or every event of a batch) and,
|
|
10
|
+
# unless it is a batch, its event in env["zapsmtp.event"]; any other is
|
|
11
|
+
# answered 400.
|
|
12
|
+
#
|
|
13
|
+
# # config/application.rb
|
|
14
|
+
# config.middleware.use ZapSMTP::WebhookMiddleware, path: "/webhooks/zapsmtp",
|
|
15
|
+
# secret: ENV["ZAP_WEBHOOK_SECRET"]
|
|
16
|
+
# # the controller then reads request.env["zapsmtp.event"]
|
|
17
|
+
class WebhookMiddleware
|
|
18
|
+
EVENT_KEY = "zapsmtp.event"
|
|
19
|
+
EVENTS_KEY = "zapsmtp.events"
|
|
20
|
+
|
|
21
|
+
def initialize(app, secret: ENV.fetch("ZAP_WEBHOOK_SECRET", nil), path: nil, tolerance: Webhook::DEFAULT_TOLERANCE)
|
|
22
|
+
@app = app
|
|
23
|
+
@secret = secret
|
|
24
|
+
@path = path
|
|
25
|
+
@tolerance = tolerance
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call(env)
|
|
29
|
+
return @app.call(env) unless @path.nil? || env["PATH_INFO"] == @path
|
|
30
|
+
|
|
31
|
+
begin
|
|
32
|
+
body = Webhook.verified(read_body(env), env, @secret, tolerance: @tolerance, now: nil)
|
|
33
|
+
events = (body.is_a?(Array) ? body : [body]).map { |item| Webhook.event(item) }
|
|
34
|
+
rescue WebhookVerificationError => e
|
|
35
|
+
return [400, { "content-type" => "application/json" }, [JSON.generate(error: e.message)]]
|
|
36
|
+
end
|
|
37
|
+
env[EVENTS_KEY] = events
|
|
38
|
+
env[EVENT_KEY] = events.first unless body.is_a?(Array)
|
|
39
|
+
@app.call(env)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# The raw body, the input left rewound for the app.
|
|
45
|
+
def read_body(env)
|
|
46
|
+
input = env["rack.input"]
|
|
47
|
+
return "" if input.nil?
|
|
48
|
+
|
|
49
|
+
input.rewind if input.respond_to?(:rewind)
|
|
50
|
+
body = input.read.to_s
|
|
51
|
+
input.rewind if input.respond_to?(:rewind)
|
|
52
|
+
body
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
data/lib/zapsmtp.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The official Ruby library for the ZapSMTP email API (https://zapsmtp.com).
|
|
4
|
+
#
|
|
5
|
+
# require "zapsmtp"
|
|
6
|
+
# zap = ZapSMTP::Client.new(api_key: "zap_...")
|
|
7
|
+
# zap.emails.send(from: "Acme <hello@acme.com>", to: "jane@example.org", subject: "Hi", html: "<p>Hi</p>")
|
|
8
|
+
module ZapSMTP
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
require_relative "zapsmtp/core"
|
|
12
|
+
|
|
13
|
+
# The Rails integration (delivery method, webhook concern) loads with Rails;
|
|
14
|
+
# without Rails, require "zapsmtp/rails" for the Mail delivery method alone.
|
|
15
|
+
# :nocov: a load-time switch, tested in separate processes (test/loading_test.rb)
|
|
16
|
+
require_relative "zapsmtp/railtie" if defined?(Rails::Railtie)
|
|
17
|
+
# :nocov:
|
metadata
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: zapsmtp
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0.beta3
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- ZapSMTP
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-10-30 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: 'Send, receive and track email with ZapSMTP: every API operation, safe
|
|
14
|
+
retries with idempotency keys, paging over every list, webhook signature verification
|
|
15
|
+
and an ActionMailer delivery method. No dependencies beyond Ruby''s standard library.'
|
|
16
|
+
email:
|
|
17
|
+
- support@zapsmtp.com
|
|
18
|
+
executables: []
|
|
19
|
+
extensions: []
|
|
20
|
+
extra_rdoc_files: []
|
|
21
|
+
files:
|
|
22
|
+
- LICENSE
|
|
23
|
+
- README.md
|
|
24
|
+
- lib/zapsmtp.rb
|
|
25
|
+
- lib/zapsmtp/attachment.rb
|
|
26
|
+
- lib/zapsmtp/base_client.rb
|
|
27
|
+
- lib/zapsmtp/core.rb
|
|
28
|
+
- lib/zapsmtp/errors.rb
|
|
29
|
+
- lib/zapsmtp/generated/client.rb
|
|
30
|
+
- lib/zapsmtp/generated/models.rb
|
|
31
|
+
- lib/zapsmtp/generated/resources.rb
|
|
32
|
+
- lib/zapsmtp/model.rb
|
|
33
|
+
- lib/zapsmtp/page.rb
|
|
34
|
+
- lib/zapsmtp/rails.rb
|
|
35
|
+
- lib/zapsmtp/rails/delivery_method.rb
|
|
36
|
+
- lib/zapsmtp/rails/message.rb
|
|
37
|
+
- lib/zapsmtp/rails/webhook_verification.rb
|
|
38
|
+
- lib/zapsmtp/railtie.rb
|
|
39
|
+
- lib/zapsmtp/response.rb
|
|
40
|
+
- lib/zapsmtp/retry.rb
|
|
41
|
+
- lib/zapsmtp/transport.rb
|
|
42
|
+
- lib/zapsmtp/util.rb
|
|
43
|
+
- lib/zapsmtp/version.rb
|
|
44
|
+
- lib/zapsmtp/webhook.rb
|
|
45
|
+
- lib/zapsmtp/webhook_middleware.rb
|
|
46
|
+
homepage: https://zapsmtp.com/docs/sdks/
|
|
47
|
+
licenses:
|
|
48
|
+
- MIT
|
|
49
|
+
metadata:
|
|
50
|
+
homepage_uri: https://zapsmtp.com/docs/sdks/
|
|
51
|
+
documentation_uri: https://zapsmtp.com/docs/
|
|
52
|
+
source_code_uri: https://github.com/zapsmtp/zapsmtp-ruby
|
|
53
|
+
changelog_uri: https://github.com/zapsmtp/zapsmtp-ruby/releases
|
|
54
|
+
rubygems_mfa_required: 'true'
|
|
55
|
+
post_install_message:
|
|
56
|
+
rdoc_options: []
|
|
57
|
+
require_paths:
|
|
58
|
+
- lib
|
|
59
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
60
|
+
requirements:
|
|
61
|
+
- - ">="
|
|
62
|
+
- !ruby/object:Gem::Version
|
|
63
|
+
version: '3.0'
|
|
64
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '0'
|
|
69
|
+
requirements: []
|
|
70
|
+
rubygems_version: 3.5.22
|
|
71
|
+
signing_key:
|
|
72
|
+
specification_version: 4
|
|
73
|
+
summary: The official Ruby library for the ZapSMTP email API, with a Rails delivery
|
|
74
|
+
method
|
|
75
|
+
test_files: []
|