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.
- checksums.yaml +7 -0
- data/README.md +183 -0
- data/lib/openemail/api_paths.rb +249 -0
- data/lib/openemail/client.rb +69 -0
- data/lib/openemail/constants.rb +851 -0
- data/lib/openemail/credentials.rb +53 -0
- data/lib/openemail/decode.rb +34 -0
- data/lib/openemail/defaults.rb +111 -0
- data/lib/openemail/endpoint.rb +144 -0
- data/lib/openemail/errors.rb +121 -0
- data/lib/openemail/http.rb +172 -0
- data/lib/openemail/languages.rb +42 -0
- data/lib/openemail/pagination.rb +63 -0
- data/lib/openemail/protocol.rb +169 -0
- data/lib/openemail/raw_body.rb +83 -0
- data/lib/openemail/request_path.rb +50 -0
- data/lib/openemail/resource.rb +70 -0
- data/lib/openemail/resources/account.rb +64 -0
- data/lib/openemail/resources/addresses.rb +38 -0
- data/lib/openemail/resources/analytics.rb +17 -0
- data/lib/openemail/resources/app_host.rb +23 -0
- data/lib/openemail/resources/audiences.rb +90 -0
- data/lib/openemail/resources/billing.rb +64 -0
- data/lib/openemail/resources/branding.rb +24 -0
- data/lib/openemail/resources/broadcasts.rb +65 -0
- data/lib/openemail/resources/calendar.rb +59 -0
- data/lib/openemail/resources/chats.rb +31 -0
- data/lib/openemail/resources/contacts.rb +111 -0
- data/lib/openemail/resources/dns_connections.rb +19 -0
- data/lib/openemail/resources/domains.rb +153 -0
- data/lib/openemail/resources/drafts.rb +35 -0
- data/lib/openemail/resources/emails.rb +100 -0
- data/lib/openemail/resources/encryption.rb +19 -0
- data/lib/openemail/resources/exports.rb +27 -0
- data/lib/openemail/resources/files.rb +95 -0
- data/lib/openemail/resources/forms.rb +111 -0
- data/lib/openemail/resources/imports.rb +86 -0
- data/lib/openemail/resources/keys.rb +127 -0
- data/lib/openemail/resources/labels.rb +39 -0
- data/lib/openemail/resources/languages.rb +11 -0
- data/lib/openemail/resources/me.rb +19 -0
- data/lib/openemail/resources/members.rb +74 -0
- data/lib/openemail/resources/provider_imports.rb +40 -0
- data/lib/openemail/resources/roles.rb +39 -0
- data/lib/openemail/resources/rules.rb +57 -0
- data/lib/openemail/resources/security.rb +19 -0
- data/lib/openemail/resources/senders.rb +15 -0
- data/lib/openemail/resources/settings.rb +16 -0
- data/lib/openemail/resources/subscriptions.rb +30 -0
- data/lib/openemail/resources/support.rb +11 -0
- data/lib/openemail/resources/suppressions.rb +31 -0
- data/lib/openemail/resources/temp_mail.rb +67 -0
- data/lib/openemail/resources/templates.rb +151 -0
- data/lib/openemail/resources/threads.rb +104 -0
- data/lib/openemail/resources/tools.rb +19 -0
- data/lib/openemail/resources/tracking.rb +63 -0
- data/lib/openemail/resources/webhooks.rb +142 -0
- data/lib/openemail/resources/workspaces.rb +28 -0
- data/lib/openemail/results.rb +33 -0
- data/lib/openemail/transport.rb +272 -0
- data/lib/openemail/update_notice.rb +69 -0
- data/lib/openemail/version.rb +5 -0
- data/lib/openemail/webhook.rb +82 -0
- data/lib/openemail/wire.rb +95 -0
- data/lib/openemail.rb +125 -0
- 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,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
|