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