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.
@@ -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
@@ -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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZapSMTP
4
+ # Kept in step with the gemspec by the release script.
5
+ VERSION = "0.1.0.beta3"
6
+ 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: []