runearc 0.1.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/LICENSE +21 -0
- data/README.md +369 -0
- data/lib/runearc/client.rb +265 -0
- data/lib/runearc/errors.rb +163 -0
- data/lib/runearc/resources.rb +396 -0
- data/lib/runearc/support.rb +18 -0
- data/lib/runearc/version.rb +5 -0
- data/lib/runearc/webhook.rb +123 -0
- data/lib/runearc.rb +15 -0
- data/sig/runearc.rbs +171 -0
- metadata +60 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
module Runearc
|
|
7
|
+
# Standard Webhooks verification (https://www.standardwebhooks.com). Mirrors
|
|
8
|
+
# +runearc_core::webhooks::sign+:
|
|
9
|
+
#
|
|
10
|
+
# signature = "v1," + base64(HMAC-SHA256(base64decode(secret without "whsec_"),
|
|
11
|
+
# "{webhook-id}.{webhook-timestamp}.{body}"))
|
|
12
|
+
module Webhook
|
|
13
|
+
# Signatures older or newer than this many seconds are refused (replay protection).
|
|
14
|
+
TOLERANCE_SECONDS = 5 * 60
|
|
15
|
+
|
|
16
|
+
HEADER_NAMES = %w[webhook-id webhook-timestamp webhook-signature].freeze
|
|
17
|
+
private_constant :HEADER_NAMES
|
|
18
|
+
|
|
19
|
+
# Checks that a webhook request came from Runearc and is fresh, then returns the parsed event.
|
|
20
|
+
#
|
|
21
|
+
# @param payload [String] the raw request body, exactly as received; do not parse it and serialize it again
|
|
22
|
+
# @param headers [Hash, #[]] the request headers: a Hash with keys in any case (or Rack style
|
|
23
|
+
# +HTTP_WEBHOOK_ID+ keys), or any object that answers +headers["webhook-id"]+
|
|
24
|
+
# @param secret [String] the endpoint's signing secret, +whsec_...+, from +webhooks.create+
|
|
25
|
+
# @param tolerance [Integer] seconds of clock difference allowed (default 300)
|
|
26
|
+
# @param now [Integer, nil] for tests: the current time in seconds since the epoch
|
|
27
|
+
# @return [Hash] the event: +:id+, +:type+, +:timestamp+ and +:data+ (symbol keys)
|
|
28
|
+
# @raise [WebhookVerificationError] when the payload is not a genuine, fresh Runearc webhook;
|
|
29
|
+
# its +code+ is +missing_headers+, +invalid_timestamp+, +timestamp_out_of_range+, +invalid_secret+,
|
|
30
|
+
# +invalid_signature+ or +invalid_payload+
|
|
31
|
+
def self.verify(payload:, headers:, secret:, tolerance: TOLERANCE_SECONDS, now: nil)
|
|
32
|
+
id, timestamp, signatures = HEADER_NAMES.map { |name| header(headers, name) }
|
|
33
|
+
if [id, timestamp, signatures].any? { |v| v.nil? || v.empty? }
|
|
34
|
+
raise WebhookVerificationError.new(
|
|
35
|
+
"the webhook-id, webhook-timestamp and webhook-signature headers are required",
|
|
36
|
+
code: "missing_headers",
|
|
37
|
+
fix: "Pass the request headers unchanged; check that a proxy does not strip them"
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
check_timestamp(timestamp, tolerance, now || Time.now.to_i)
|
|
41
|
+
|
|
42
|
+
body = payload.to_s.b
|
|
43
|
+
expected = OpenSSL::HMAC.digest("SHA256", decode_secret(secret), "#{id}.#{timestamp}.".b + body)
|
|
44
|
+
raise_invalid_signature unless signatures.split.any? { |part| match?(part, expected) }
|
|
45
|
+
|
|
46
|
+
parse(body)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.check_timestamp(timestamp, tolerance, now)
|
|
50
|
+
unless timestamp.strip.match?(/\A\d+\z/)
|
|
51
|
+
raise WebhookVerificationError.new(
|
|
52
|
+
"webhook-timestamp is not a number of seconds", code: "invalid_timestamp"
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
return if (now - timestamp.to_i).abs <= tolerance
|
|
56
|
+
|
|
57
|
+
raise WebhookVerificationError.new(
|
|
58
|
+
"the webhook timestamp is more than #{tolerance} seconds from now",
|
|
59
|
+
code: "timestamp_out_of_range",
|
|
60
|
+
fix: "Check your server clock; old deliveries are refused to stop replays"
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.decode_secret(secret)
|
|
65
|
+
secret.to_s.strip.delete_prefix("whsec_").unpack1("m0")
|
|
66
|
+
rescue ArgumentError
|
|
67
|
+
raise WebhookVerificationError.new(
|
|
68
|
+
"the webhook secret is not valid base64",
|
|
69
|
+
code: "invalid_secret",
|
|
70
|
+
fix: "Use the secret returned by webhooks.create, which starts with whsec_"
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.match?(part, expected)
|
|
75
|
+
version, sig = part.split(",", 2)
|
|
76
|
+
return false unless version == "v1" && sig
|
|
77
|
+
|
|
78
|
+
given = sig.unpack1("m0")
|
|
79
|
+
given.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(given, expected)
|
|
80
|
+
rescue ArgumentError
|
|
81
|
+
false
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.raise_invalid_signature
|
|
85
|
+
raise WebhookVerificationError.new(
|
|
86
|
+
"no webhook-signature matches the payload",
|
|
87
|
+
code: "invalid_signature",
|
|
88
|
+
fix: "Verify the raw body (not JSON parsed and serialized again) with this endpoint's whsec_ secret"
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.parse(body)
|
|
93
|
+
JSON.parse(body.dup.force_encoding(Encoding::UTF_8), symbolize_names: true)
|
|
94
|
+
rescue JSON::ParserError
|
|
95
|
+
raise WebhookVerificationError.new("the webhook body is not JSON", code: "invalid_payload")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.header(headers, name)
|
|
99
|
+
value = lookup(headers, name)
|
|
100
|
+
value = value.join(" ") if value.is_a?(Array)
|
|
101
|
+
value&.to_s
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def self.lookup(headers, name)
|
|
105
|
+
return headers[name] unless headers.is_a?(Hash)
|
|
106
|
+
|
|
107
|
+
wanted = name.tr("-", "_")
|
|
108
|
+
headers.each do |key, value|
|
|
109
|
+
return value if key.to_s.downcase.delete_prefix("http_").tr("-", "_") == wanted
|
|
110
|
+
end
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private_class_method :check_timestamp, :decode_secret, :match?, :raise_invalid_signature, :parse, :header, :lookup
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Same as {Webhook.verify}.
|
|
118
|
+
#
|
|
119
|
+
# @return [Hash]
|
|
120
|
+
def self.verify_webhook(**kwargs)
|
|
121
|
+
Webhook.verify(**kwargs)
|
|
122
|
+
end
|
|
123
|
+
end
|
data/lib/runearc.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "runearc/version"
|
|
4
|
+
|
|
5
|
+
# The official Ruby SDK for the Runearc email API. Start with {Runearc::Client}.
|
|
6
|
+
module Runearc
|
|
7
|
+
# The API origin used when neither +base_url:+ nor +RUNEARC_BASE_URL+ is set.
|
|
8
|
+
DEFAULT_BASE_URL = "https://api.runearc.com"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
require_relative "runearc/errors"
|
|
12
|
+
require_relative "runearc/support"
|
|
13
|
+
require_relative "runearc/webhook"
|
|
14
|
+
require_relative "runearc/resources"
|
|
15
|
+
require_relative "runearc/client"
|
data/sig/runearc.rbs
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
module Runearc
|
|
2
|
+
VERSION: String
|
|
3
|
+
DEFAULT_BASE_URL: String
|
|
4
|
+
|
|
5
|
+
type json = Hash[Symbol, untyped]
|
|
6
|
+
type time = Time | String
|
|
7
|
+
|
|
8
|
+
def self.verify_webhook: (payload: String, headers: untyped, secret: String, ?tolerance: Integer, ?now: Integer?) -> json
|
|
9
|
+
|
|
10
|
+
class Error < StandardError
|
|
11
|
+
attr_reader status: Integer
|
|
12
|
+
attr_reader code: String
|
|
13
|
+
attr_reader kind: String?
|
|
14
|
+
attr_reader fix: String?
|
|
15
|
+
attr_reader errors: Array[json]?
|
|
16
|
+
attr_reader retry_after: Integer?
|
|
17
|
+
|
|
18
|
+
def initialize: (?String? message, ?status: Integer, ?code: String, ?kind: String?, ?fix: String?, ?errors: Array[json]?, ?retry_after: Integer?) -> void
|
|
19
|
+
def to_h: () -> Hash[Symbol, untyped]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class ConfigurationError < Error
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class ConnectionError < Error
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class TimeoutError < ConnectionError
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class WebhookVerificationError < Error
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class ApiError < Error
|
|
35
|
+
def self.build: (String message, status: Integer, **untyped fields) -> ApiError
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class InvalidRequestError < ApiError
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class AuthenticationError < ApiError
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
class LimitReachedError < ApiError
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class PermissionError < ApiError
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class NotFoundError < ApiError
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class ConflictError < ApiError
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
class RateLimitError < ApiError
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class ServerError < ApiError
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
module Webhook
|
|
63
|
+
TOLERANCE_SECONDS: Integer
|
|
64
|
+
|
|
65
|
+
def self.verify: (payload: String, headers: untyped, secret: String, ?tolerance: Integer, ?now: Integer?) -> json
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
class Resource
|
|
69
|
+
def initialize: (Client client) -> void
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
class Emails < Resource
|
|
73
|
+
def deliver: (?idempotency_key: String?, ?request_timeout: Numeric?, **untyped email) -> json
|
|
74
|
+
def batch: (Array[json] emails, ?batch_validation: (String | Symbol)?, ?idempotency_key: String?, ?request_timeout: Numeric?) -> json
|
|
75
|
+
def get: (String id, ?request_timeout: Numeric?) -> json
|
|
76
|
+
def list: (?limit: Integer?, ?cursor: String?, ?request_timeout: Numeric?) -> json
|
|
77
|
+
def update: (String id, scheduled_at: time, ?request_timeout: Numeric?) -> json
|
|
78
|
+
def reschedule: (String id, time scheduled_at, ?request_timeout: Numeric?) -> json
|
|
79
|
+
def cancel: (String id, ?request_timeout: Numeric?) -> json
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
class Senders < Resource
|
|
83
|
+
def list: (?request_timeout: Numeric?) -> json
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
class Inboxes < Resource
|
|
87
|
+
def create: (?local_part: String?, ?display_name: String?, ?domain: String?, ?request_timeout: Numeric?) -> json
|
|
88
|
+
def list: (?request_timeout: Numeric?) -> json
|
|
89
|
+
def get: (String id, ?request_timeout: Numeric?) -> json
|
|
90
|
+
def delete: (String id, ?request_timeout: Numeric?) -> nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
class Messages < Resource
|
|
94
|
+
def list: (String inbox_id, ?limit: Integer?, ?cursor: String?, ?request_timeout: Numeric?) -> json
|
|
95
|
+
def get: (String id, ?request_timeout: Numeric?) -> json
|
|
96
|
+
def raw: (String id, ?request_timeout: Numeric?) -> String
|
|
97
|
+
def wait: (String inbox_id, ?from: String?, ?subject: String?, ?to: String?, ?timeout: Integer, ?after: time?, ?request_timeout: Numeric?) -> json?
|
|
98
|
+
def deliver: (String inbox_id, ?idempotency_key: String?, ?request_timeout: Numeric?, **untyped message) -> json
|
|
99
|
+
def reply: (String message_id, ?idempotency_key: String?, ?request_timeout: Numeric?, **untyped reply) -> json
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
class Threads < Resource
|
|
103
|
+
def list: (String inbox_id, ?request_timeout: Numeric?) -> json
|
|
104
|
+
def get: (String id, ?request_timeout: Numeric?) -> json
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
class Conversations < Resource
|
|
108
|
+
def list: (?status: String?, ?assignee: String?, ?inbox_id: String?, ?q: String?, ?cursor: String?, ?limit: Integer?, ?request_timeout: Numeric?) -> json
|
|
109
|
+
def counts: (?request_timeout: Numeric?) -> json
|
|
110
|
+
def update: (String id, ?request_timeout: Numeric?, **untyped fields) -> json
|
|
111
|
+
def read: (String id, ?request_timeout: Numeric?) -> nil
|
|
112
|
+
def unread: (String id, ?request_timeout: Numeric?) -> nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
class Domains < Resource
|
|
116
|
+
def create: (name: String, ?request_timeout: Numeric?) -> json
|
|
117
|
+
def list: (?request_timeout: Numeric?) -> json
|
|
118
|
+
def get: (String id, ?request_timeout: Numeric?) -> json
|
|
119
|
+
def delete: (String id, ?request_timeout: Numeric?) -> nil
|
|
120
|
+
def verify: (String id, ?request_timeout: Numeric?) -> json
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
class Webhooks < Resource
|
|
124
|
+
def create: (url: String, ?event_types: Array[String]?, ?inbox_id: String?, ?request_timeout: Numeric?) -> json
|
|
125
|
+
def list: (?request_timeout: Numeric?) -> json
|
|
126
|
+
def delete: (String id, ?request_timeout: Numeric?) -> nil
|
|
127
|
+
def deliveries: (String id, ?limit: Integer?, ?request_timeout: Numeric?) -> json
|
|
128
|
+
def replay: (String id, String delivery_id, ?request_timeout: Numeric?) -> json
|
|
129
|
+
def verify: (payload: String, headers: untyped, secret: String, ?tolerance: Integer, ?now: Integer?) -> json
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
class Keys < Resource
|
|
133
|
+
def create: (name: String, ?permissions: String?, ?inbox_id: String?, ?domain_id: String?, ?request_timeout: Numeric?) -> json
|
|
134
|
+
def list: (?request_timeout: Numeric?) -> json
|
|
135
|
+
def delete: (String id, ?request_timeout: Numeric?) -> nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
class Suppressions < Resource
|
|
139
|
+
def list: (?request_timeout: Numeric?) -> json
|
|
140
|
+
def delete: (String address, ?request_timeout: Numeric?) -> nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
class Events < Resource
|
|
144
|
+
def list: (?after: String?, ?limit: Integer?, ?request_timeout: Numeric?) -> json
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
class Client
|
|
148
|
+
DEFAULT_MAX_RETRIES: Integer
|
|
149
|
+
DEFAULT_TIMEOUT: Integer
|
|
150
|
+
MAX_RETRY_WAIT: Integer
|
|
151
|
+
|
|
152
|
+
attr_reader base_url: String
|
|
153
|
+
attr_reader max_retries: Integer
|
|
154
|
+
attr_reader timeout: Numeric
|
|
155
|
+
attr_reader emails: Emails
|
|
156
|
+
attr_reader senders: Senders
|
|
157
|
+
attr_reader inboxes: Inboxes
|
|
158
|
+
attr_reader messages: Messages
|
|
159
|
+
attr_reader threads: Threads
|
|
160
|
+
attr_reader conversations: Conversations
|
|
161
|
+
attr_reader domains: Domains
|
|
162
|
+
attr_reader webhooks: Webhooks
|
|
163
|
+
attr_reader keys: Keys
|
|
164
|
+
attr_reader suppressions: Suppressions
|
|
165
|
+
attr_reader events: Events
|
|
166
|
+
|
|
167
|
+
def initialize: (?String? api_key, ?base_url: String?, ?max_retries: Integer, ?timeout: Numeric, ?headers: Hash[String, String]) -> void
|
|
168
|
+
def inspect: () -> String
|
|
169
|
+
def request: (Symbol method, String path, ?query: Hash[Symbol, untyped]?, ?body: untyped, ?headers: Hash[String, String]?, ?sends_mail: bool, ?idempotent: bool, ?text: bool, ?idempotency_key: String?, ?request_timeout: Numeric?) -> untyped
|
|
170
|
+
end
|
|
171
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: runearc
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Runearc
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-11-15 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: Send, batch and schedule email, create inboxes your agents can read,
|
|
14
|
+
manage domains and verify webhooks with the Runearc API. Standard library only.
|
|
15
|
+
email:
|
|
16
|
+
executables: []
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- LICENSE
|
|
21
|
+
- README.md
|
|
22
|
+
- lib/runearc.rb
|
|
23
|
+
- lib/runearc/client.rb
|
|
24
|
+
- lib/runearc/errors.rb
|
|
25
|
+
- lib/runearc/resources.rb
|
|
26
|
+
- lib/runearc/support.rb
|
|
27
|
+
- lib/runearc/version.rb
|
|
28
|
+
- lib/runearc/webhook.rb
|
|
29
|
+
- sig/runearc.rbs
|
|
30
|
+
homepage: https://runearc.com/integrations/ruby
|
|
31
|
+
licenses:
|
|
32
|
+
- MIT
|
|
33
|
+
metadata:
|
|
34
|
+
homepage_uri: https://runearc.com/integrations/ruby
|
|
35
|
+
source_code_uri: https://github.com/runearc-labs/runearc-ruby
|
|
36
|
+
bug_tracker_uri: https://github.com/runearc-labs/runearc-ruby/issues
|
|
37
|
+
documentation_uri: https://runearc.com/docs/sdk
|
|
38
|
+
changelog_uri: https://github.com/runearc-labs/runearc-ruby/blob/main/CHANGELOG.md
|
|
39
|
+
rubygems_mfa_required: 'true'
|
|
40
|
+
post_install_message:
|
|
41
|
+
rdoc_options: []
|
|
42
|
+
require_paths:
|
|
43
|
+
- lib
|
|
44
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '3.0'
|
|
49
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
requirements: []
|
|
55
|
+
rubygems_version: 3.5.22
|
|
56
|
+
signing_key:
|
|
57
|
+
specification_version: 4
|
|
58
|
+
summary: 'Official Runearc SDK for Ruby: send and schedule email, read agent inboxes,
|
|
59
|
+
manage domains, verify webhooks.'
|
|
60
|
+
test_files: []
|