openemail 0.0.1

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.
Files changed (66) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +183 -0
  3. data/lib/openemail/api_paths.rb +249 -0
  4. data/lib/openemail/client.rb +69 -0
  5. data/lib/openemail/constants.rb +851 -0
  6. data/lib/openemail/credentials.rb +53 -0
  7. data/lib/openemail/decode.rb +34 -0
  8. data/lib/openemail/defaults.rb +111 -0
  9. data/lib/openemail/endpoint.rb +144 -0
  10. data/lib/openemail/errors.rb +121 -0
  11. data/lib/openemail/http.rb +172 -0
  12. data/lib/openemail/languages.rb +42 -0
  13. data/lib/openemail/pagination.rb +63 -0
  14. data/lib/openemail/protocol.rb +169 -0
  15. data/lib/openemail/raw_body.rb +83 -0
  16. data/lib/openemail/request_path.rb +50 -0
  17. data/lib/openemail/resource.rb +70 -0
  18. data/lib/openemail/resources/account.rb +64 -0
  19. data/lib/openemail/resources/addresses.rb +38 -0
  20. data/lib/openemail/resources/analytics.rb +17 -0
  21. data/lib/openemail/resources/app_host.rb +23 -0
  22. data/lib/openemail/resources/audiences.rb +90 -0
  23. data/lib/openemail/resources/billing.rb +64 -0
  24. data/lib/openemail/resources/branding.rb +24 -0
  25. data/lib/openemail/resources/broadcasts.rb +65 -0
  26. data/lib/openemail/resources/calendar.rb +59 -0
  27. data/lib/openemail/resources/chats.rb +31 -0
  28. data/lib/openemail/resources/contacts.rb +111 -0
  29. data/lib/openemail/resources/dns_connections.rb +19 -0
  30. data/lib/openemail/resources/domains.rb +153 -0
  31. data/lib/openemail/resources/drafts.rb +35 -0
  32. data/lib/openemail/resources/emails.rb +100 -0
  33. data/lib/openemail/resources/encryption.rb +19 -0
  34. data/lib/openemail/resources/exports.rb +27 -0
  35. data/lib/openemail/resources/files.rb +95 -0
  36. data/lib/openemail/resources/forms.rb +111 -0
  37. data/lib/openemail/resources/imports.rb +86 -0
  38. data/lib/openemail/resources/keys.rb +127 -0
  39. data/lib/openemail/resources/labels.rb +39 -0
  40. data/lib/openemail/resources/languages.rb +11 -0
  41. data/lib/openemail/resources/me.rb +19 -0
  42. data/lib/openemail/resources/members.rb +74 -0
  43. data/lib/openemail/resources/provider_imports.rb +40 -0
  44. data/lib/openemail/resources/roles.rb +39 -0
  45. data/lib/openemail/resources/rules.rb +57 -0
  46. data/lib/openemail/resources/security.rb +19 -0
  47. data/lib/openemail/resources/senders.rb +15 -0
  48. data/lib/openemail/resources/settings.rb +16 -0
  49. data/lib/openemail/resources/subscriptions.rb +30 -0
  50. data/lib/openemail/resources/support.rb +11 -0
  51. data/lib/openemail/resources/suppressions.rb +31 -0
  52. data/lib/openemail/resources/temp_mail.rb +67 -0
  53. data/lib/openemail/resources/templates.rb +151 -0
  54. data/lib/openemail/resources/threads.rb +104 -0
  55. data/lib/openemail/resources/tools.rb +19 -0
  56. data/lib/openemail/resources/tracking.rb +63 -0
  57. data/lib/openemail/resources/webhooks.rb +142 -0
  58. data/lib/openemail/resources/workspaces.rb +28 -0
  59. data/lib/openemail/results.rb +33 -0
  60. data/lib/openemail/transport.rb +272 -0
  61. data/lib/openemail/update_notice.rb +69 -0
  62. data/lib/openemail/version.rb +5 -0
  63. data/lib/openemail/webhook.rb +82 -0
  64. data/lib/openemail/wire.rb +95 -0
  65. data/lib/openemail.rb +125 -0
  66. metadata +109 -0
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenEmail
4
+ module Resources
5
+ class Webhooks < Resource
6
+ def list(limit: nil, cursor: nil, api_key: nil)
7
+ fetch_page(ApiPaths::WEBHOOKS, limit:, cursor:, api_key:)
8
+ end
9
+
10
+ def list_all(limit: nil, cursor: nil, api_key: nil)
11
+ collect_all(ApiPaths::WEBHOOKS, limit:, cursor:, api_key:)
12
+ end
13
+
14
+ def iterate(limit: nil, cursor: nil, api_key: nil, &)
15
+ iterate_pages(ApiPaths::WEBHOOKS, limit:, cursor:, api_key:, &)
16
+ end
17
+
18
+ def get(id, api_key: nil)
19
+ request(fill(ApiPaths::WEBHOOK, id:), api_key:)
20
+ end
21
+
22
+ def create(body = nil, api_key: nil, **fields)
23
+ request(ApiPaths::WEBHOOKS, method: :post, body: payload(body, fields), api_key:)
24
+ end
25
+
26
+ def update(id, patch = nil, api_key: nil, **fields)
27
+ request(fill(ApiPaths::WEBHOOK, id:), method: :patch, body: payload(patch, fields), repeatable: true, api_key:)
28
+ end
29
+
30
+ def delete(id, api_key: nil)
31
+ request(fill(ApiPaths::WEBHOOK, id:), method: :delete, api_key:)
32
+ end
33
+
34
+ def rotate_secret(id, api_key: nil)
35
+ request(fill(ApiPaths::WEBHOOK_ROTATE_SECRET, id:), method: :post, api_key:)
36
+ end
37
+
38
+ def test(id, api_key: nil)
39
+ request(fill(ApiPaths::WEBHOOK_TEST, id:), method: :post, api_key:)
40
+ end
41
+
42
+ def list_deliveries(id, status: nil, since: nil, until: nil, limit: nil, cursor: nil, api_key: nil)
43
+ query = delivery_query(status, since, binding.local_variable_get(:until))
44
+ fetch_page(fill(ApiPaths::WEBHOOK_DELIVERIES, id:), query, limit:, cursor:, api_key:)
45
+ end
46
+
47
+ def list_all_deliveries(id, status: nil, since: nil, until: nil, limit: nil, cursor: nil, api_key: nil)
48
+ query = delivery_query(status, since, binding.local_variable_get(:until))
49
+ collect_all(fill(ApiPaths::WEBHOOK_DELIVERIES, id:), query, limit:, cursor:, api_key:)
50
+ end
51
+
52
+ def iterate_deliveries(id, status: nil, since: nil, until: nil, limit: nil, cursor: nil, api_key: nil, &)
53
+ query = delivery_query(status, since, binding.local_variable_get(:until))
54
+ iterate_pages(fill(ApiPaths::WEBHOOK_DELIVERIES, id:), query, limit:, cursor:, api_key:, &)
55
+ end
56
+
57
+ def get_delivery(id, delivery_id, api_key: nil)
58
+ request(fill(ApiPaths::WEBHOOK_DELIVERY, id:, deliveryId: delivery_id), api_key:)
59
+ end
60
+
61
+ def replay_delivery(id, delivery_id, api_key: nil)
62
+ request(fill(ApiPaths::WEBHOOK_DELIVERY_REPLAY, id:, deliveryId: delivery_id), method: :post, api_key:)
63
+ end
64
+
65
+ def list_workspace_deliveries(endpoint_ids: nil, status: nil, since: nil, until: nil, limit: nil, cursor: nil,
66
+ api_key: nil)
67
+ query = workspace_delivery_query(endpoint_ids, status, since, binding.local_variable_get(:until))
68
+ fetch_page(ApiPaths::WEBHOOKS_DELIVERIES, query, limit:, cursor:, api_key:)
69
+ end
70
+
71
+ def list_all_workspace_deliveries(endpoint_ids: nil, status: nil, since: nil, until: nil, limit: nil,
72
+ cursor: nil, api_key: nil)
73
+ query = workspace_delivery_query(endpoint_ids, status, since, binding.local_variable_get(:until))
74
+ collect_all(ApiPaths::WEBHOOKS_DELIVERIES, query, limit:, cursor:, api_key:)
75
+ end
76
+
77
+ def iterate_workspace_deliveries(endpoint_ids: nil, status: nil, since: nil, until: nil, limit: nil, cursor: nil,
78
+ api_key: nil, &)
79
+ query = workspace_delivery_query(endpoint_ids, status, since, binding.local_variable_get(:until))
80
+ iterate_pages(ApiPaths::WEBHOOKS_DELIVERIES, query, limit:, cursor:, api_key:, &)
81
+ end
82
+
83
+ def list_activity(id, since: nil, until: nil, limit: nil, cursor: nil, api_key: nil)
84
+ query = window_query(since, binding.local_variable_get(:until))
85
+ fetch_page(fill(ApiPaths::WEBHOOK_ACTIVITY, id:), query, limit:, cursor:, api_key:)
86
+ end
87
+
88
+ def list_all_activity(id, since: nil, until: nil, limit: nil, cursor: nil, api_key: nil)
89
+ query = window_query(since, binding.local_variable_get(:until))
90
+ collect_all(fill(ApiPaths::WEBHOOK_ACTIVITY, id:), query, limit:, cursor:, api_key:)
91
+ end
92
+
93
+ def iterate_activity(id, since: nil, until: nil, limit: nil, cursor: nil, api_key: nil, &)
94
+ query = window_query(since, binding.local_variable_get(:until))
95
+ iterate_pages(fill(ApiPaths::WEBHOOK_ACTIVITY, id:), query, limit:, cursor:, api_key:, &)
96
+ end
97
+
98
+ def list_workspace_activity(endpoint_ids: nil, since: nil, until: nil, limit: nil, cursor: nil, api_key: nil)
99
+ query = workspace_activity_query(endpoint_ids, since, binding.local_variable_get(:until))
100
+ fetch_page(ApiPaths::WEBHOOKS_ACTIVITY, query, limit:, cursor:, api_key:)
101
+ end
102
+
103
+ def list_all_workspace_activity(endpoint_ids: nil, since: nil, until: nil, limit: nil, cursor: nil, api_key: nil)
104
+ query = workspace_activity_query(endpoint_ids, since, binding.local_variable_get(:until))
105
+ collect_all(ApiPaths::WEBHOOKS_ACTIVITY, query, limit:, cursor:, api_key:)
106
+ end
107
+
108
+ def iterate_workspace_activity(endpoint_ids: nil, since: nil, until: nil, limit: nil, cursor: nil, api_key: nil, &)
109
+ query = workspace_activity_query(endpoint_ids, since, binding.local_variable_get(:until))
110
+ iterate_pages(ApiPaths::WEBHOOKS_ACTIVITY, query, limit:, cursor:, api_key:, &)
111
+ end
112
+
113
+ def list_events(api_key: nil)
114
+ request(ApiPaths::WEBHOOKS_EVENTS, api_key:)
115
+ end
116
+
117
+ def stats(endpoint_ids: nil, since: nil, until: nil, grain: nil, offset_minutes: nil, api_key: nil)
118
+ query = window_query(since, binding.local_variable_get(:until))
119
+ .merge(query_params(endpoint_ids: joined(endpoint_ids), grain:, offset_minutes:))
120
+ request(ApiPaths::WEBHOOKS_STATS, query:, api_key:)
121
+ end
122
+
123
+ private
124
+
125
+ def window_query(since, until_value)
126
+ query_params(since: instant(since), until: instant(until_value))
127
+ end
128
+
129
+ def delivery_query(status, since, until_value)
130
+ window_query(since, until_value).merge(query_params(status:))
131
+ end
132
+
133
+ def workspace_delivery_query(endpoint_ids, status, since, until_value)
134
+ delivery_query(status, since, until_value).merge(query_params(endpoint_ids: joined(endpoint_ids)))
135
+ end
136
+
137
+ def workspace_activity_query(endpoint_ids, since, until_value)
138
+ window_query(since, until_value).merge(query_params(endpoint_ids: joined(endpoint_ids)))
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenEmail
4
+ module Resources
5
+ class Workspaces < Resource
6
+ def list(api_key: nil)
7
+ request(ApiPaths::WORKSPACES, api_key:)
8
+ end
9
+
10
+ def create(body = nil, api_key: nil, **fields)
11
+ request(ApiPaths::WORKSPACES, method: :post, body: payload(body, fields), api_key:)
12
+ end
13
+
14
+ def get_active(api_key: nil)
15
+ request(ApiPaths::WORKSPACES_ACTIVE, api_key:)
16
+ end
17
+
18
+ def set_active(workspace_id, api_key: nil)
19
+ request(ApiPaths::WORKSPACES_ACTIVE, method: :put, body: {"workspaceId" => workspace_id}, repeatable: true,
20
+ api_key:)
21
+ end
22
+
23
+ def delete(id, confirm:, api_key: nil)
24
+ request(fill(ApiPaths::WORKSPACE, id:), method: :delete, query: query_params(confirm:), api_key:)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenEmail
4
+ Page = Data.define(:items, :has_more, :next_cursor) do
5
+ def has_more?
6
+ has_more
7
+ end
8
+ end
9
+
10
+ PeoplePage = Data.define(:items, :has_more, :next_cursor, :seen) do
11
+ def has_more?
12
+ has_more
13
+ end
14
+ end
15
+
16
+ TempMessagesPage = Data.define(:items, :has_more, :next_cursor, :expires_at) do
17
+ def has_more?
18
+ has_more
19
+ end
20
+ end
21
+
22
+ AddressBookPage = Data.define(:unrestricted, :addresses, :domains, :has_more, :next_cursor) do
23
+ def has_more?
24
+ has_more
25
+ end
26
+ end
27
+
28
+ AddressBook = Data.define(:unrestricted, :addresses, :domains)
29
+
30
+ BatchResult = Data.define(:items, :sent, :failed)
31
+
32
+ TemplateSends = Data.define(:items, :total, :page, :page_size)
33
+ end
@@ -0,0 +1,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenEmail
4
+ class Transport
5
+ HTTP_DATE_FORMATS = %i[httpdate rfc2822 iso8601].freeze
6
+ LINE_BREAK = /[\r\n]/
7
+ HEADER_NAME = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/
8
+ PROGRAMMING_ERRORS = [NameError, TypeError, ArgumentError].freeze
9
+
10
+ attr_reader :base_url, :timeout
11
+
12
+ def initialize(api_key: nil, access_token: nil, inbox_token: nil, base_url: nil, adapter: nil, max_retries: nil,
13
+ timeout: nil, user_agent: nil, headers: nil, disable_update_notice: false)
14
+ Credentials.assert_credential(api_key, access_token, required: false)
15
+
16
+ @api_key = Credentials.present?(api_key) ? api_key : nil
17
+ @access_token = Credentials.present?(access_token) ? access_token : nil
18
+ @inbox_token = inbox_token
19
+ @base_url = Endpoint.valid_base_url(base_url || Defaults::BASE_URL)
20
+ @secure_origin = Endpoint.secure_origin?(@base_url)
21
+ @adapter = adapter || NetHttpAdapter.new
22
+ @max_retries = [Integer(max_retries || Defaults::MAX_RETRIES), 0].max
23
+ @timeout = timeout.nil? ? Defaults::TIMEOUT : timeout_option(timeout)
24
+ @user_agent = header_value(user_agent || Defaults::USER_AGENT)
25
+ @headers = (headers || {}).to_h { |name, value| [header_name(name), header_value(value)] }.freeze
26
+
27
+ UpdateNotice.check(@adapter) unless disable_update_notice
28
+ end
29
+
30
+ def request(path, method: :get, query: nil, body: nil, raw: nil, content_type: nil, api_key: nil, inbox_token: nil,
31
+ anonymous: false, idempotent: false, idempotency_key: nil, repeatable: nil, accept: nil, binary: false,
32
+ timeout: nil)
33
+ verb = method.to_s.upcase
34
+ outbound = HttpRequest.new(
35
+ method: verb,
36
+ url: Endpoint.build_url(@base_url, path, query),
37
+ headers: headers_for(body:, raw:, content_type:, api_key:, inbox_token:, anonymous:, idempotent:,
38
+ idempotency_key:, accept:),
39
+ body: raw.nil? ? json_body(body) : RawBody.read(raw),
40
+ timeout: seconds(timeout.nil? ? @timeout : timeout_option(timeout))
41
+ )
42
+
43
+ deliver(outbound, repeatable.nil? ? Protocol::RETRY[:METHODS].include?(verb) : repeatable, accept, binary)
44
+ end
45
+
46
+ def inspect
47
+ "#<#{self.class.name} base_url=#{@base_url.inspect}>"
48
+ end
49
+
50
+ private
51
+
52
+ def deliver(outbound, repeatable, accept, binary)
53
+ attempt = 0
54
+
55
+ loop do
56
+ outcome = exchange(outbound)
57
+
58
+ if outcome.is_a?(NetworkError)
59
+ raise outcome, cause: outcome.original unless repeatable && attempt < @max_retries
60
+
61
+ sleep(backoff(attempt))
62
+ attempt += 1
63
+ next
64
+ end
65
+
66
+ return binary ? outcome.body.b : parse(outcome, accept) if outcome.ok?
67
+
68
+ wait = retry_after(outcome.header(Protocol::HEADER_KEYS[:RETRY_AFTER]))
69
+
70
+ if repeatable && attempt < @max_retries && retry_status?(outcome.status) && honoured?(outcome.status, wait)
71
+ sleep(wait.nil? ? backoff(attempt) : wait)
72
+ attempt += 1
73
+ next
74
+ end
75
+
76
+ raise api_error(outcome, wait)
77
+ end
78
+ end
79
+
80
+ def exchange(outbound)
81
+ response = @adapter.call(outbound)
82
+ HttpResponse.new(status: Integer(response.status), headers: header_table(response.headers), body: response.body.to_s)
83
+ rescue Timeout::Error => error
84
+ NetworkError.new(format(Defaults::MESSAGES[:TIMED_OUT], seconds: outbound.timeout), original: error, timeout: true)
85
+ rescue *PROGRAMMING_ERRORS
86
+ raise
87
+ rescue => error
88
+ NetworkError.new(format(Defaults::MESSAGES[:UNREACHABLE], base_url: @base_url, reason: error.message),
89
+ original: error)
90
+ end
91
+
92
+ def header_table(headers)
93
+ (headers || {}).to_h { |name, value| [name.to_s.downcase, value.is_a?(Array) ? value.join(", ") : value.to_s] }
94
+ end
95
+
96
+ def headers_for(body:, raw:, content_type:, api_key:, inbox_token:, anonymous:, idempotent:, idempotency_key:, accept:)
97
+ headers = @headers.merge(
98
+ Protocol::HEADER_KEYS[:ACCEPT] => accept || Protocol::CONTENT_TYPES[:JSON],
99
+ Protocol::HEADER_KEYS[:USER_AGENT] => @user_agent
100
+ )
101
+
102
+ if !raw.nil?
103
+ headers[Protocol::HEADER_KEYS[:CONTENT_TYPE]] = header_value(content_type || Protocol::CONTENT_TYPES[:OCTET_STREAM])
104
+ elsif !body.nil?
105
+ headers[Protocol::HEADER_KEYS[:CONTENT_TYPE]] = Protocol::CONTENT_TYPES[:JSON]
106
+ end
107
+
108
+ authorize(headers, api_key, inbox_token) unless anonymous
109
+
110
+ if idempotent
111
+ headers[Protocol::HEADER_KEYS[:IDEMPOTENCY_KEY]] = header_value(idempotency_key || generated_idempotency_key)
112
+ end
113
+ headers
114
+ end
115
+
116
+ def authorize(headers, api_key, inbox_token)
117
+ Credentials.assert_api_key(api_key, "The api_key passed to this call") unless api_key.nil?
118
+
119
+ direct = [inbox_token, api_key, @inbox_token, @api_key].find { |value| !value.nil? }
120
+
121
+ if Credentials.present?(direct.nil? ? @access_token : direct) && !@secure_origin
122
+ raise ArgumentError, format(Defaults::MESSAGES[:CLEARTEXT], base_url: @base_url)
123
+ end
124
+
125
+ credential = direct.nil? ? resolved_access_token : direct
126
+ return unless Credentials.present?(credential)
127
+
128
+ headers[Protocol::HEADER_KEYS[:AUTHORIZATION]] = header_value("Bearer #{credential}")
129
+ end
130
+
131
+ def resolved_access_token
132
+ return @access_token unless @access_token.respond_to?(:call)
133
+
134
+ token = @access_token.call
135
+ return token if Credentials.access_token?(token)
136
+
137
+ raise ArgumentError, "#{Defaults::MESSAGES[:ACCESS_TOKEN_PROVIDED]} #{Defaults::MESSAGES[:ACCESS_TOKEN_SHAPE]}"
138
+ end
139
+
140
+ def generated_idempotency_key
141
+ "#{Protocol::IDEMPOTENCY_KEY_PREFIX}#{SecureRandom.uuid}"
142
+ end
143
+
144
+ def header_name(name)
145
+ text = name.to_s
146
+ raise ArgumentError, Defaults::MESSAGES[:HEADER_SHAPE] unless HEADER_NAME.match?(text)
147
+
148
+ text
149
+ end
150
+
151
+ def header_value(value)
152
+ text = value.to_s
153
+ raise ArgumentError, Defaults::MESSAGES[:HEADER_SHAPE] if LINE_BREAK.match?(text)
154
+
155
+ text
156
+ end
157
+
158
+ def timeout_option(value)
159
+ return value if value.is_a?(Numeric) && !(value.is_a?(Float) && value.nan?) && !value.negative?
160
+
161
+ raise ArgumentError, Defaults::MESSAGES[:TIMEOUT_SHAPE]
162
+ end
163
+
164
+ def json_body(body)
165
+ body.nil? ? nil : JSON.generate(Wire.json_ready(body))
166
+ end
167
+
168
+ def seconds(value)
169
+ value.positive? ? value : nil
170
+ end
171
+
172
+ def parse(response, accept)
173
+ text = Decode.text(response.body)
174
+ return text if accept && accept != Protocol::CONTENT_TYPES[:JSON]
175
+ return nil if text.empty?
176
+
177
+ Decode.json(text)
178
+ rescue JSON::ParserError
179
+ raise ApiError.build(
180
+ message: format(Defaults::MESSAGES[:NOT_JSON], status: response.status),
181
+ status: response.status,
182
+ type: ERROR_TYPES[:API_ERROR],
183
+ code: Protocol::ERROR_CODES[:UNRECOGNISED_RESPONSE],
184
+ request_id: response.header(Protocol::HEADER_KEYS[:REQUEST_ID])
185
+ )
186
+ end
187
+
188
+ def api_error(response, wait)
189
+ text = Decode.text(response.body)
190
+ body = parse_json(text)
191
+ payload = (body.is_a?(Hash) && body[:error].is_a?(Hash)) ? body[:error] : {}
192
+
193
+ ApiError.build(
194
+ message: text_of(payload[:message]) || fallback_message(response.status, text),
195
+ status: response.status,
196
+ type: ERROR_TYPES.value?(payload[:type]) ? payload[:type] : type_for_status(response.status),
197
+ code: text_of(payload[:code]) || Protocol::ERROR_CODES[:UNRECOGNISED_RESPONSE],
198
+ param: text_of(payload[:param]),
199
+ doc_url: text_of(payload[:docUrl]),
200
+ request_id: text_of(payload[:requestId]) || response.header(Protocol::HEADER_KEYS[:REQUEST_ID]),
201
+ retry_after_seconds: wait,
202
+ fields: fields_of(payload[:fields]),
203
+ body:
204
+ )
205
+ end
206
+
207
+ def fallback_message(status, text)
208
+ text.empty? ? format(Defaults::MESSAGES[:UNRECOGNISED_BODY], status:) : text[0, 300]
209
+ end
210
+
211
+ def type_for_status(status)
212
+ return ERROR_TYPES[:API_ERROR] if status >= 500
213
+
214
+ mapped = Protocol::ERROR_TYPE_BY_STATUS[status]
215
+ ERROR_TYPES.value?(mapped) ? mapped : ERROR_TYPES[:INVALID_REQUEST_ERROR]
216
+ end
217
+
218
+ def fields_of(value)
219
+ return nil unless value.is_a?(Array)
220
+
221
+ value.select { |field| field.is_a?(Hash) && field[:key].is_a?(String) && field[:error].is_a?(String) }
222
+ end
223
+
224
+ def text_of(value)
225
+ (value.is_a?(String) && !value.empty?) ? value : nil
226
+ end
227
+
228
+ def parse_json(text)
229
+ text.empty? ? nil : Decode.json(text)
230
+ rescue JSON::ParserError
231
+ nil
232
+ end
233
+
234
+ def retry_status?(status)
235
+ Protocol::RETRY[:STATUSES].include?(status)
236
+ end
237
+
238
+ def honoured?(status, wait)
239
+ return status != Protocol::RETRY[:RATE_LIMIT_STATUS] if wait.nil?
240
+
241
+ wait * 1000 <= Protocol::RETRY[:MAX_HONOURED_RETRY_AFTER_MS]
242
+ end
243
+
244
+ def backoff(attempt)
245
+ ceiling = [Protocol::RETRY[:MAX_BACKOFF_MS], Protocol::RETRY[:BASE_BACKOFF_MS] * (2**attempt)].min
246
+ (ceiling * (0.5 + (rand * 0.5))).round / 1000.0
247
+ end
248
+
249
+ def retry_after(value)
250
+ return nil if value.nil? || value.strip.empty?
251
+
252
+ seconds = Float(value, exception: false)
253
+ return whole(seconds) if seconds&.finite? && seconds >= 0
254
+
255
+ at = http_date(value.strip)
256
+ at && [0, (at - Time.now).ceil].max
257
+ end
258
+
259
+ def http_date(text)
260
+ HTTP_DATE_FORMATS.each do |format|
261
+ return Time.public_send(format, text)
262
+ rescue ArgumentError
263
+ next
264
+ end
265
+ nil
266
+ end
267
+
268
+ def whole(number)
269
+ (number == number.to_i) ? number.to_i : number
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenEmail
4
+ module UpdateNotice
5
+ @checked = false
6
+ @lock = Mutex.new
7
+
8
+ module_function
9
+
10
+ def check(adapter)
11
+ return unless claim
12
+
13
+ Thread.new do
14
+ Thread.current.report_on_exception = false
15
+ latest = latest_version(adapter)
16
+ warn(notice(latest)) if latest && newer?(latest, VERSION)
17
+ end
18
+ end
19
+
20
+ def claim
21
+ @lock.synchronize do
22
+ next false if @checked || disabled?
23
+
24
+ @checked = true
25
+ end
26
+ end
27
+
28
+ def disabled?
29
+ !ENV.fetch(Defaults::UPDATE_NOTICE[:ENV_DISABLE], "").empty? || !$stdout.tty?
30
+ end
31
+
32
+ def latest_version(adapter)
33
+ response = adapter.call(HttpRequest.new(
34
+ method: Protocol::HTTP_METHODS[:GET],
35
+ url: Defaults::UPDATE_NOTICE[:REGISTRY_URL],
36
+ headers: {
37
+ Protocol::HEADER_KEYS[:ACCEPT] => Protocol::CONTENT_TYPES[:JSON],
38
+ Protocol::HEADER_KEYS[:USER_AGENT] => Defaults::USER_AGENT
39
+ },
40
+ body: nil,
41
+ timeout: Defaults::UPDATE_NOTICE[:TIMEOUT]
42
+ ))
43
+ return nil unless Integer(response.status).between?(200, 299)
44
+
45
+ version = JSON.parse(response.body.to_s)["version"]
46
+ version.is_a?(String) ? version : nil
47
+ rescue
48
+ nil
49
+ end
50
+
51
+ def newer?(candidate, current)
52
+ left = parts(candidate)
53
+ right = parts(current)
54
+ return false if left.nil? || right.nil?
55
+
56
+ (left <=> right) == 1
57
+ end
58
+
59
+ def parts(value)
60
+ numbers = value.to_s.strip.delete_prefix("v").split(".").map { |part| Integer(part, 10, exception: false) }
61
+ (numbers.size == 3 && numbers.none?(&:nil?)) ? numbers : nil
62
+ end
63
+
64
+ def notice(latest)
65
+ format(Defaults::MESSAGES[:UPDATE_AVAILABLE], icon: Defaults::UPDATE_NOTICE[:ICON],
66
+ package: Defaults::UPDATE_NOTICE[:PACKAGE], latest:, current: VERSION, page: Defaults::UPDATE_NOTICE[:PAGE_URL])
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenEmail
4
+ VERSION = "0.0.1"
5
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenEmail
4
+ module Webhook
5
+ module_function
6
+
7
+ def verify(payload:, headers:, secret:, tolerance_seconds: nil)
8
+ raise ArgumentError, Defaults::MESSAGES[:WEBHOOK_PAYLOAD_SHAPE] unless payload.is_a?(String)
9
+ raise ArgumentError, Defaults::MESSAGES[:WEBHOOK_SECRET_REQUIRED] unless secret.is_a?(String) && !secret.empty?
10
+
11
+ header = read_header(headers, WEBHOOK_SIGNATURE_HEADERS[:SIGNATURE])
12
+ raise WebhookSignatureError, Defaults::MESSAGES[:WEBHOOK_MISSING_HEADER] if header.nil? || header.empty?
13
+
14
+ timestamp, signatures = parse_signature(header)
15
+ raise WebhookSignatureError, Defaults::MESSAGES[:WEBHOOK_MALFORMED_HEADER] if timestamp.nil?
16
+
17
+ assert_fresh(timestamp, tolerance_seconds.nil? ? Protocol::WEBHOOK_SIGNATURE[:TOLERANCE_SECONDS] : tolerance_seconds)
18
+
19
+ expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{payload}")
20
+ raise WebhookSignatureError, Defaults::MESSAGES[:WEBHOOK_MISMATCH] unless signatures.any? { |candidate| same?(expected, candidate) }
21
+
22
+ Decode.json(Decode.text(payload))
23
+ end
24
+
25
+ def read_header(headers, name)
26
+ wanted = name.downcase
27
+ rack = "HTTP_#{name.upcase.tr("-", "_")}"
28
+
29
+ if headers.respond_to?(:each_pair)
30
+ headers.each_pair do |key, value|
31
+ return first_value(value) if key.to_s.downcase == wanted || key.to_s == rack
32
+ end
33
+ nil
34
+ elsif headers.respond_to?(:[])
35
+ first_value(headers[name])
36
+ end
37
+ end
38
+
39
+ def first_value(value)
40
+ value = value.first if value.is_a?(Array)
41
+ value&.to_s
42
+ end
43
+
44
+ def parse_signature(header)
45
+ timestamp = nil
46
+ signatures = []
47
+
48
+ header.split(Protocol::WEBHOOK_SIGNATURE[:PAIR_SEPARATOR]).each do |pair|
49
+ at = pair.index(Protocol::WEBHOOK_SIGNATURE[:VALUE_SEPARATOR])
50
+ next if at.nil? || at < 1
51
+
52
+ key = pair[0, at].strip
53
+ value = pair[(at + 1)..].strip
54
+ timestamp = number(value) if key == Protocol::WEBHOOK_SIGNATURE[:TIMESTAMP]
55
+ signatures << value if key == Protocol::WEBHOOK_SIGNATURE[:VERSION] && !value.empty?
56
+ end
57
+
58
+ (timestamp.nil? || signatures.empty?) ? nil : [timestamp, signatures]
59
+ end
60
+
61
+ def number(value)
62
+ integer = Integer(value, 10, exception: false)
63
+ return integer unless integer.nil?
64
+
65
+ float = Float(value, exception: false)
66
+ return nil if float.nil? || !float.finite?
67
+
68
+ (float == float.to_i) ? float.to_i : float
69
+ end
70
+
71
+ def assert_fresh(timestamp, tolerance)
72
+ drift = (Time.now.to_i - timestamp).abs
73
+ return unless tolerance.positive? && drift > tolerance
74
+
75
+ raise WebhookSignatureError, format(Defaults::MESSAGES[:WEBHOOK_OUT_OF_TOLERANCE], drift:, tolerance:)
76
+ end
77
+
78
+ def same?(expected, candidate)
79
+ candidate.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(expected, candidate)
80
+ end
81
+ end
82
+ end