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,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenEmail
|
|
4
|
+
module Credentials
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def api_key?(value)
|
|
8
|
+
value.is_a?(String) && Protocol::API_KEY_PREFIXES.values.any? { |prefix| value.start_with?(prefix) }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def access_token?(value)
|
|
12
|
+
value.is_a?(String) &&
|
|
13
|
+
!value.empty? &&
|
|
14
|
+
value.length <= Protocol::ACCESS_TOKEN_RULES[:MAX_LENGTH] &&
|
|
15
|
+
!value.start_with?(Protocol::ACCESS_TOKEN_RULES[:RESERVED_PREFIX])
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def present?(value)
|
|
19
|
+
!value.nil? && value != ""
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def mode_of(api_key)
|
|
23
|
+
return Protocol::API_KEY_MODES[:TEST] if api_key.is_a?(String) && api_key.start_with?(Protocol::API_KEY_PREFIXES[:TEST])
|
|
24
|
+
|
|
25
|
+
Protocol::API_KEY_MODES[:LIVE]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def assert_api_key(api_key, subject)
|
|
29
|
+
raise ArgumentError, Defaults::MESSAGES[:API_KEY_REQUIRED] unless present?(api_key)
|
|
30
|
+
raise ArgumentError, "#{subject} #{Defaults::MESSAGES[:API_KEY_SHAPE]}" unless api_key?(api_key)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def assert_access_token(access_token, subject)
|
|
34
|
+
return if access_token.respond_to?(:call)
|
|
35
|
+
raise ArgumentError, "#{subject} #{Defaults::MESSAGES[:ACCESS_TOKEN_SHAPE]}" unless access_token?(access_token)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def assert_credential(api_key, access_token, required:)
|
|
39
|
+
with_key = present?(api_key)
|
|
40
|
+
with_token = present?(access_token)
|
|
41
|
+
|
|
42
|
+
raise ArgumentError, Defaults::MESSAGES[:CREDENTIAL_CONFLICT] if with_key && with_token
|
|
43
|
+
|
|
44
|
+
if with_key
|
|
45
|
+
assert_api_key(api_key, "The api_key")
|
|
46
|
+
elsif with_token
|
|
47
|
+
assert_access_token(access_token, "The access_token")
|
|
48
|
+
elsif required
|
|
49
|
+
raise ArgumentError, Defaults::MESSAGES[:CREDENTIAL_REQUIRED]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenEmail
|
|
4
|
+
module Decode
|
|
5
|
+
BYTE_ORDER_MARK = ""
|
|
6
|
+
ESCAPE = /\\u(d[89ab]\h\h)(\\u(d[c-f]\h\h))?|\\u(d[c-f]\h\h)|\\./i
|
|
7
|
+
REPLACEMENT_ESCAPE = "\\ufffd"
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def text(bytes)
|
|
12
|
+
bytes.to_s.dup.force_encoding(Encoding::UTF_8).scrub.delete_prefix(BYTE_ORDER_MARK)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def json(text)
|
|
16
|
+
JSON.parse(text, symbolize_names: true)
|
|
17
|
+
rescue JSON::ParserError
|
|
18
|
+
repaired = without_lone_surrogates(text)
|
|
19
|
+
raise if repaired == text
|
|
20
|
+
|
|
21
|
+
JSON.parse(repaired, symbolize_names: true)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def without_lone_surrogates(text)
|
|
25
|
+
text.gsub(ESCAPE) do |escape|
|
|
26
|
+
high, pair, _low, lone_low = Regexp.last_match.captures
|
|
27
|
+
next escape if high.nil? && lone_low.nil?
|
|
28
|
+
next escape if high && pair
|
|
29
|
+
|
|
30
|
+
REPLACEMENT_ESCAPE
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenEmail
|
|
4
|
+
module Defaults
|
|
5
|
+
BASE_URL = "https://api.openemail.uk"
|
|
6
|
+
MAX_RETRIES = 2
|
|
7
|
+
TIMEOUT = 30
|
|
8
|
+
UPLOAD_TIMEOUT = 600
|
|
9
|
+
USER_AGENT = "openemail-ruby/#{VERSION}"
|
|
10
|
+
KEEP_ALIVE_TIMEOUT = 2
|
|
11
|
+
MAX_IDLE_CONNECTIONS = 8
|
|
12
|
+
BODYLESS_WRITES = %w[POST PUT PATCH].freeze
|
|
13
|
+
REDACTED = "[redacted]"
|
|
14
|
+
|
|
15
|
+
LOOPBACK_IPV4_PATTERN = /\A127\.\d{1,3}\.\d{1,3}\.\d{1,3}\z/
|
|
16
|
+
ENDPOINT_SCHEME_PATTERN = %r{\A[a-z][a-z0-9+.-]*://}i
|
|
17
|
+
LANGUAGE_SUBTAG_PATTERN = /[-_]/
|
|
18
|
+
PATH_PARAM = /:([A-Za-z0-9_]+)(?:\{[^}]*\})?/
|
|
19
|
+
OPTIONAL_PATH_PARAM = %r{/:([A-Za-z0-9_]+)\?}
|
|
20
|
+
URI_COMPONENT_UNSAFE = /[^A-Za-z0-9\-_.!~*'()]/
|
|
21
|
+
DOT_SEGMENT = /\A\.+\z/
|
|
22
|
+
TRAILING_SLASHES = %r{/+\z}
|
|
23
|
+
IPV4_DECIMAL_PART = /\A(?:0|[1-9]\d*)\z/
|
|
24
|
+
IPV4_OCTAL_PART = /\A0[0-7]+\z/
|
|
25
|
+
IPV4_HEX_PART = /\A0x[0-9a-f]*\z/i
|
|
26
|
+
|
|
27
|
+
CONTENT_TYPES_BY_EXTENSION = {
|
|
28
|
+
".png" => "image/png",
|
|
29
|
+
".jpg" => "image/jpeg",
|
|
30
|
+
".jpeg" => "image/jpeg",
|
|
31
|
+
".webp" => "image/webp",
|
|
32
|
+
".gif" => "image/gif",
|
|
33
|
+
".svg" => "image/svg+xml",
|
|
34
|
+
".pdf" => "application/pdf",
|
|
35
|
+
".txt" => "text/plain",
|
|
36
|
+
".csv" => "text/csv",
|
|
37
|
+
".html" => "text/html",
|
|
38
|
+
".json" => "application/json",
|
|
39
|
+
".ics" => "text/calendar",
|
|
40
|
+
".eml" => "message/rfc822",
|
|
41
|
+
".mbox" => "application/mbox",
|
|
42
|
+
".zip" => "application/zip",
|
|
43
|
+
".gz" => "application/gzip",
|
|
44
|
+
".tgz" => "application/gzip"
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
UPDATE_NOTICE = {
|
|
48
|
+
ENV_DISABLE: "OPENEMAIL_DISABLE_UPDATE_NOTICE",
|
|
49
|
+
TIMEOUT: 2,
|
|
50
|
+
PACKAGE: "openemail",
|
|
51
|
+
REGISTRY_URL: "https://rubygems.org/api/v1/versions/openemail/latest.json",
|
|
52
|
+
PAGE_URL: "https://rubygems.org/gems/openemail",
|
|
53
|
+
ICON: "ℹ"
|
|
54
|
+
}.freeze
|
|
55
|
+
|
|
56
|
+
MESSAGES = {
|
|
57
|
+
API_KEY_REQUIRED: "An OpenEmail API key is required. Pass api_key: or set OPENEMAIL_API_KEY. " \
|
|
58
|
+
"Create one in OpenEmail under Settings, API keys.",
|
|
59
|
+
CREDENTIAL_REQUIRED: "An OpenEmail API key or OAuth access token is required. Pass api_key: or access_token:, " \
|
|
60
|
+
"or set OPENEMAIL_API_KEY or OPENEMAIL_ACCESS_TOKEN. Create a key in OpenEmail under Settings, API keys.",
|
|
61
|
+
CREDENTIAL_CONFLICT: "Pass api_key: or access_token:, not both. Every request carries one credential, " \
|
|
62
|
+
"so the client cannot tell which of the two you meant.",
|
|
63
|
+
KEY_TWICE: "Pass the API key once, either as the first argument or as api_key:.",
|
|
64
|
+
API_KEY_SHAPE: "is not an OpenEmail API key. The API accepts only keys beginning \"oe_live_\" or \"oe_test_\", " \
|
|
65
|
+
"so a session cookie, a session token or a key for another service is refused. " \
|
|
66
|
+
"Pass an OAuth access token as access_token: instead.",
|
|
67
|
+
ACCESS_TOKEN_SHAPE: "is not an OAuth access token. Pass the access token OpenEmail issued when the app was " \
|
|
68
|
+
"connected: a string of 1 to 512 characters that does not begin \"oe_\". An API key goes in api_key: instead.",
|
|
69
|
+
ACCESS_TOKEN_PROVIDED: "The value the access_token: callable returned",
|
|
70
|
+
BASE_URL_SHAPE: "is not a usable base URL. Pass an https origin such as \"https://api.openemail.uk\". " \
|
|
71
|
+
"Plain http is for a server on this machine only, at localhost, a 127.x.x.x address or ::1, " \
|
|
72
|
+
"because the client never sends a credential over plain http to any other host.",
|
|
73
|
+
BASE_URL_USERINFO: "A base URL must not carry a user name or password. Pass the origin alone and the " \
|
|
74
|
+
"credential as api_key: or access_token:.",
|
|
75
|
+
CLEARTEXT: "Refused to send a credential to %<base_url>s over plain http, where anyone on the network can read " \
|
|
76
|
+
"it. Use an https base URL. Plain http is accepted only for a server on this machine: localhost, " \
|
|
77
|
+
"a 127.x.x.x address or ::1.",
|
|
78
|
+
UNSPECIFIED_HOST: "%<value>s names %<host>s, an address a server listens on, not one to send requests to. " \
|
|
79
|
+
"For a server on this machine, use %<suggestion>s.",
|
|
80
|
+
PATH_SHAPE: "is not a usable request path. Pass a path on the API that begins with a single \"/\", such as " \
|
|
81
|
+
"\"/threads\". Anything else could send the request, and the credential it carries, to another host.",
|
|
82
|
+
OFF_ORIGIN: "leaves the API origin, so the request was not sent. Pass a path on the API that begins with a " \
|
|
83
|
+
"single \"/\", such as \"/threads\".",
|
|
84
|
+
EMPTY_SEGMENT: "An id must not be empty.",
|
|
85
|
+
DOT_SEGMENT: "is not a usable id: a path segment made only of dots is removed by URL parsers, so the request " \
|
|
86
|
+
"would reach a different endpoint.",
|
|
87
|
+
FILENAME_REQUIRED: "A file name is required. Pass it as filename:, the name the file is stored and " \
|
|
88
|
+
"downloaded under.",
|
|
89
|
+
BODY_SHAPE: "Pass the request body as a Hash, or as keyword arguments.",
|
|
90
|
+
RAW_BODY_SHAPE: "Pass the bytes as a String, an IO or a Pathname.",
|
|
91
|
+
HEADER_SHAPE: "A header name must be a token and a header value must not contain a line break.",
|
|
92
|
+
TIMEOUT_SHAPE: "Pass timeout: as a number of seconds, or 0 for no timeout.",
|
|
93
|
+
ATTACHMENT_CONTENT_SHAPE: "An attachment's content must be base64 text, or the raw bytes as a binary String " \
|
|
94
|
+
"(File.binread), an IO or a Pathname, which are encoded for you.",
|
|
95
|
+
SEGMENT_ENCODING: "An id must be valid UTF-8 text.",
|
|
96
|
+
TIMED_OUT: "Request timed out after %<seconds>ss.",
|
|
97
|
+
UNREACHABLE: "Could not reach %<base_url>s: %<reason>s",
|
|
98
|
+
NOT_JSON: "The API answered %<status>s with a body that is not JSON.",
|
|
99
|
+
UNRECOGNISED_BODY: "The API responded %<status>s with a body this client did not recognise.",
|
|
100
|
+
WEBHOOK_MISSING_HEADER: "Missing the X-OpenEmail-Signature header.",
|
|
101
|
+
WEBHOOK_MALFORMED_HEADER: "X-OpenEmail-Signature is not in the form t=<seconds>,v1=<hex>.",
|
|
102
|
+
WEBHOOK_OUT_OF_TOLERANCE: "Webhook timestamp is %<drift>ss out of tolerance (%<tolerance>ss).",
|
|
103
|
+
WEBHOOK_MISMATCH: "Webhook signature does not match the payload.",
|
|
104
|
+
WEBHOOK_PAYLOAD_SHAPE: "Pass the raw request body as a String, exactly as it arrived. A parsed or re-encoded " \
|
|
105
|
+
"body no longer matches its signature.",
|
|
106
|
+
WEBHOOK_SECRET_REQUIRED: "A webhook signing secret is required. Pass the secret the webhook was created " \
|
|
107
|
+
"with as secret:.",
|
|
108
|
+
UPDATE_AVAILABLE: "%<icon>s %<package>s %<latest>s is available, you are on %<current>s. %<page>s"
|
|
109
|
+
}.freeze
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenEmail
|
|
4
|
+
module Endpoint
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def valid_base_url(value)
|
|
8
|
+
text = value.to_s.strip.sub(Defaults::TRAILING_SLASHES, "")
|
|
9
|
+
uri = parse_http(text)
|
|
10
|
+
|
|
11
|
+
raise ArgumentError, "#{value.to_s.inspect} #{Defaults::MESSAGES[:BASE_URL_SHAPE]}" if uri.nil?
|
|
12
|
+
raise ArgumentError, Defaults::MESSAGES[:BASE_URL_USERINFO] unless uri.userinfo.nil?
|
|
13
|
+
raise ArgumentError, unspecified_host_message(value, uri) if unspecified_host?(uri.host)
|
|
14
|
+
|
|
15
|
+
text
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def normalise(value)
|
|
19
|
+
return nil if value.nil?
|
|
20
|
+
|
|
21
|
+
text = value.to_s.strip.sub(Defaults::TRAILING_SLASHES, "")
|
|
22
|
+
return nil if text.empty?
|
|
23
|
+
return text if Defaults::ENDPOINT_SCHEME_PATTERN.match?(text)
|
|
24
|
+
|
|
25
|
+
"#{local_host?(hostname_of(text)) ? "http" : "https"}://#{text}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def secure_origin?(url)
|
|
29
|
+
uri = parse_http(url)
|
|
30
|
+
return false if uri.nil?
|
|
31
|
+
|
|
32
|
+
uri.is_a?(URI::HTTPS) || loopback_host?(uri.host)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def build_url(base_url, path, query)
|
|
36
|
+
url = api_url(base_url, path)
|
|
37
|
+
pairs = (query || {}).filter_map do |key, value|
|
|
38
|
+
next if value.nil? || value == ""
|
|
39
|
+
|
|
40
|
+
[key.to_s, query_text(value)]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
pairs.empty? ? url : "#{url}?#{URI.encode_www_form(pairs)}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def query_text(value)
|
|
47
|
+
return Wire.list(value).map { |item| query_text(item) }.join(",") if Wire.collection?(value)
|
|
48
|
+
|
|
49
|
+
Wire.scalar(value).to_s
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def api_url(base_url, path)
|
|
53
|
+
unless path.is_a?(String) && path.start_with?("/") && !path.start_with?("//") && !path.start_with?("/\\")
|
|
54
|
+
raise ArgumentError, "#{path.inspect} #{Defaults::MESSAGES[:PATH_SHAPE]}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
url = "#{base_url.sub(Defaults::TRAILING_SLASHES, "")}#{path}"
|
|
58
|
+
origin = origin_of(url)
|
|
59
|
+
|
|
60
|
+
raise ArgumentError, "#{path.inspect} #{Defaults::MESSAGES[:OFF_ORIGIN]}" if origin.nil? || origin != origin_of(base_url)
|
|
61
|
+
|
|
62
|
+
url
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def loopback_host?(host)
|
|
66
|
+
canonical = canonical_host(host)
|
|
67
|
+
Protocol::LOOPBACK_HOSTNAMES.include?(canonical) || Defaults::LOOPBACK_IPV4_PATTERN.match?(canonical)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def local_host?(host)
|
|
71
|
+
canonical = canonical_host(host)
|
|
72
|
+
Protocol::LOCAL_HOSTS.include?(canonical) || Defaults::LOOPBACK_IPV4_PATTERN.match?(canonical)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def unspecified_host?(host)
|
|
76
|
+
Protocol::UNSPECIFIED_HOSTNAMES.include?(canonical_host(host))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def canonical_host(host)
|
|
80
|
+
text = host.to_s.downcase
|
|
81
|
+
return text if text.empty?
|
|
82
|
+
return ipv6_host(text) if text.start_with?("[")
|
|
83
|
+
|
|
84
|
+
parts = text.split(".", -1)
|
|
85
|
+
parts.pop if parts.size > 1 && parts.last.empty?
|
|
86
|
+
numbers = parts.map { |part| ipv4_number(part) }
|
|
87
|
+
return text if parts.empty? || parts.size > 4 || numbers.any?(&:nil?)
|
|
88
|
+
|
|
89
|
+
ipv4_address(numbers) || text
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def ipv6_host(text)
|
|
93
|
+
"[#{IPAddr.new(text.delete_prefix("[").delete_suffix("]"))}]"
|
|
94
|
+
rescue ArgumentError
|
|
95
|
+
text
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def ipv4_number(part)
|
|
99
|
+
return (part.length == 2) ? 0 : part[2..].to_i(16) if Defaults::IPV4_HEX_PART.match?(part)
|
|
100
|
+
return part.to_i(8) if Defaults::IPV4_OCTAL_PART.match?(part)
|
|
101
|
+
|
|
102
|
+
part.to_i if Defaults::IPV4_DECIMAL_PART.match?(part)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def ipv4_address(numbers)
|
|
106
|
+
*leading, last = numbers
|
|
107
|
+
return nil if leading.any? { |number| number > 255 } || last >= 256**(5 - numbers.size)
|
|
108
|
+
|
|
109
|
+
value = leading.each_with_index.sum { |number, index| number * (256**(3 - index)) } + last
|
|
110
|
+
[24, 16, 8, 0].map { |shift| (value >> shift) & 255 }.join(".")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def parse_http(text)
|
|
114
|
+
uri = URI.parse(text)
|
|
115
|
+
return nil unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
|
|
116
|
+
|
|
117
|
+
uri
|
|
118
|
+
rescue URI::InvalidURIError
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def origin_of(url)
|
|
123
|
+
uri = URI.parse(url)
|
|
124
|
+
return nil if uri.scheme.nil? || uri.host.nil?
|
|
125
|
+
|
|
126
|
+
[uri.scheme.downcase, canonical_host(uri.host), uri.port]
|
|
127
|
+
rescue URI::InvalidURIError
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def hostname_of(authority)
|
|
132
|
+
URI.parse("http://#{authority}").host.to_s
|
|
133
|
+
rescue URI::InvalidURIError
|
|
134
|
+
""
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def unspecified_host_message(value, uri)
|
|
138
|
+
substitute = uri.host.start_with?("[") ? Protocol::LOOPBACK_IPV6_ADDRESS : Protocol::LOOPBACK_IPV4_ADDRESS
|
|
139
|
+
suggestion = uri.dup.tap { |copy| copy.host = substitute }.to_s.sub(Defaults::TRAILING_SLASHES, "")
|
|
140
|
+
|
|
141
|
+
format(Defaults::MESSAGES[:UNSPECIFIED_HOST], value: value.to_s.inspect, host: canonical_host(uri.host), suggestion:)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenEmail
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
class ApiError < Error
|
|
8
|
+
attr_reader :status, :type, :code, :param, :doc_url, :request_id, :retry_after_seconds, :fields, :body
|
|
9
|
+
|
|
10
|
+
def self.build(type:, **properties)
|
|
11
|
+
TYPED_ERRORS.fetch(type, ApiError).new(type:, **properties)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(message:, status:, type:, code:, param: nil, doc_url: nil, request_id: nil,
|
|
15
|
+
retry_after_seconds: nil, fields: nil, body: nil)
|
|
16
|
+
super(message)
|
|
17
|
+
@status = status
|
|
18
|
+
@type = type
|
|
19
|
+
@code = code
|
|
20
|
+
@param = param
|
|
21
|
+
@doc_url = doc_url
|
|
22
|
+
@request_id = request_id
|
|
23
|
+
@retry_after_seconds = retry_after_seconds
|
|
24
|
+
@fields = fields
|
|
25
|
+
@body = body
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def auth?
|
|
29
|
+
type == ERROR_TYPES[:AUTHENTICATION_ERROR]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def permission?
|
|
33
|
+
type == ERROR_TYPES[:PERMISSION_ERROR]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def scope_missing?
|
|
37
|
+
code == Protocol::ERROR_CODES[:INSUFFICIENT_SCOPE]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def step_up_required?
|
|
41
|
+
code == STEP_UP_ERROR_CODES[:STEP_UP_REQUIRED]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def invalid_request?
|
|
45
|
+
type == ERROR_TYPES[:INVALID_REQUEST_ERROR]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def validation?
|
|
49
|
+
type == ERROR_TYPES[:VALIDATION_ERROR]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def not_found?
|
|
53
|
+
type == ERROR_TYPES[:NOT_FOUND_ERROR]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def conflict?
|
|
57
|
+
type == ERROR_TYPES[:CONFLICT_ERROR]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def rate_limited?
|
|
61
|
+
type == ERROR_TYPES[:RATE_LIMIT_ERROR]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def server_error?
|
|
65
|
+
!status.nil? && status >= 500
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def retryable?
|
|
69
|
+
!status.nil? && Protocol::RETRY[:STATUSES].include?(status)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
class InvalidRequestError < ApiError
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
class AuthenticationError < ApiError
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
class PermissionError < ApiError
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
class NotFoundError < ApiError
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
class ConflictError < ApiError
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
class ValidationError < ApiError
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
class RateLimitError < ApiError
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
TYPED_ERRORS = {
|
|
95
|
+
ERROR_TYPES[:INVALID_REQUEST_ERROR] => InvalidRequestError,
|
|
96
|
+
ERROR_TYPES[:AUTHENTICATION_ERROR] => AuthenticationError,
|
|
97
|
+
ERROR_TYPES[:PERMISSION_ERROR] => PermissionError,
|
|
98
|
+
ERROR_TYPES[:NOT_FOUND_ERROR] => NotFoundError,
|
|
99
|
+
ERROR_TYPES[:CONFLICT_ERROR] => ConflictError,
|
|
100
|
+
ERROR_TYPES[:VALIDATION_ERROR] => ValidationError,
|
|
101
|
+
ERROR_TYPES[:RATE_LIMIT_ERROR] => RateLimitError
|
|
102
|
+
}.freeze
|
|
103
|
+
private_constant :TYPED_ERRORS
|
|
104
|
+
|
|
105
|
+
class NetworkError < Error
|
|
106
|
+
attr_reader :original
|
|
107
|
+
|
|
108
|
+
def initialize(message, original: nil, timeout: false)
|
|
109
|
+
super(message)
|
|
110
|
+
@original = original
|
|
111
|
+
@timeout = timeout
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def timeout?
|
|
115
|
+
@timeout
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
class WebhookSignatureError < Error
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenEmail
|
|
4
|
+
HttpRequest = Data.define(:method, :url, :headers, :body, :timeout) do
|
|
5
|
+
def inspect
|
|
6
|
+
shown = headers.to_h do |name, value|
|
|
7
|
+
[name, name.to_s.casecmp?(Protocol::HEADER_KEYS[:AUTHORIZATION]) ? Defaults::REDACTED : value]
|
|
8
|
+
end
|
|
9
|
+
size = body.nil? ? "nil" : "#{body.bytesize} bytes"
|
|
10
|
+
|
|
11
|
+
"#<data #{self.class.name} method=#{method.inspect}, url=#{url.inspect}, headers=#{shown.inspect}, " \
|
|
12
|
+
"body=#{size}, timeout=#{timeout.inspect}>"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def to_s
|
|
16
|
+
inspect
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def pretty_print(printer)
|
|
20
|
+
printer.text(inspect)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
HttpResponse = Data.define(:status, :headers, :body) do
|
|
25
|
+
def ok?
|
|
26
|
+
status.between?(200, 299)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def header(name)
|
|
30
|
+
headers[name.to_s.downcase]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class NetHttpAdapter
|
|
35
|
+
def initialize(max_idle: Defaults::MAX_IDLE_CONNECTIONS, keep_alive_timeout: Defaults::KEEP_ALIVE_TIMEOUT)
|
|
36
|
+
@max_idle = max_idle
|
|
37
|
+
@keep_alive_timeout = keep_alive_timeout
|
|
38
|
+
@idle = Hash.new { |pools, key| pools[key] = [] }
|
|
39
|
+
@lock = Mutex.new
|
|
40
|
+
@pid = Process.pid
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def call(request)
|
|
44
|
+
deadline = request.timeout && (clock + request.timeout)
|
|
45
|
+
uri = URI.parse(request.url)
|
|
46
|
+
connection = checkout(uri, remaining(deadline))
|
|
47
|
+
|
|
48
|
+
begin
|
|
49
|
+
response = perform(connection, uri, request, deadline)
|
|
50
|
+
rescue
|
|
51
|
+
finish(connection)
|
|
52
|
+
raise
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
checkin(uri, connection)
|
|
56
|
+
response
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def close
|
|
60
|
+
connections = @lock.synchronize do
|
|
61
|
+
drained = @idle.values.flatten
|
|
62
|
+
@idle.clear
|
|
63
|
+
drained
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
connections.each { |connection| finish(connection) }
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def inspect
|
|
71
|
+
"#<#{self.class.name}>"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def perform(connection, uri, request, deadline)
|
|
77
|
+
limit(connection, deadline)
|
|
78
|
+
|
|
79
|
+
outbound = Net::HTTPGenericRequest.new(request.method, !request.body.nil?, request.method != "HEAD",
|
|
80
|
+
uri.request_uri, headers_for(request))
|
|
81
|
+
outbound.body = request.body unless request.body.nil?
|
|
82
|
+
|
|
83
|
+
body = "".b
|
|
84
|
+
response = connection.request(outbound) do |incoming|
|
|
85
|
+
incoming.read_body do |chunk|
|
|
86
|
+
body << chunk
|
|
87
|
+
limit(connection, deadline)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
HttpResponse.new(status: response.code.to_i, headers: response.each_header.to_h, body:)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def limit(connection, deadline)
|
|
95
|
+
left = remaining(deadline)
|
|
96
|
+
raise Net::ReadTimeout if left && left <= 0
|
|
97
|
+
|
|
98
|
+
connection.read_timeout = left
|
|
99
|
+
connection.write_timeout = left
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def remaining(deadline)
|
|
103
|
+
deadline && (deadline - clock)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def clock
|
|
107
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def headers_for(request)
|
|
111
|
+
return request.headers unless request.body.nil? && Defaults::BODYLESS_WRITES.include?(request.method)
|
|
112
|
+
|
|
113
|
+
request.headers.merge("Content-Length" => "0")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def checkout(uri, timeout)
|
|
117
|
+
key = pool_key(uri)
|
|
118
|
+
pooled = @lock.synchronize do
|
|
119
|
+
forget_inherited_connections
|
|
120
|
+
@idle[key].pop
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
pooled || connect(uri, timeout)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def checkin(uri, connection)
|
|
127
|
+
kept = @lock.synchronize do
|
|
128
|
+
next false unless @pid == Process.pid && @idle[pool_key(uri)].size < @max_idle
|
|
129
|
+
|
|
130
|
+
@idle[pool_key(uri)].push(connection)
|
|
131
|
+
true
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
finish(connection) unless kept
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def connect(uri, timeout)
|
|
138
|
+
proxy = uri.find_proxy
|
|
139
|
+
connection = Net::HTTP.new(uri.hostname, uri.port, proxy&.hostname, proxy&.port, decoded(proxy&.user),
|
|
140
|
+
decoded(proxy&.password))
|
|
141
|
+
connection.use_ssl = uri.is_a?(URI::HTTPS)
|
|
142
|
+
connection.min_version = OpenSSL::SSL::TLS1_2_VERSION if connection.use_ssl?
|
|
143
|
+
connection.open_timeout = timeout
|
|
144
|
+
connection.read_timeout = timeout
|
|
145
|
+
connection.write_timeout = timeout
|
|
146
|
+
connection.keep_alive_timeout = @keep_alive_timeout
|
|
147
|
+
connection.max_retries = 0
|
|
148
|
+
connection.start
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def decoded(text)
|
|
152
|
+
text && URI.decode_uri_component(text)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def finish(connection)
|
|
156
|
+
connection.finish if connection.started?
|
|
157
|
+
rescue IOError, SystemCallError, OpenSSL::SSL::SSLError
|
|
158
|
+
nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def forget_inherited_connections
|
|
162
|
+
return if @pid == Process.pid
|
|
163
|
+
|
|
164
|
+
@idle.clear
|
|
165
|
+
@pid = Process.pid
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def pool_key(uri)
|
|
169
|
+
[uri.scheme.downcase, uri.hostname.downcase, uri.port]
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenEmail
|
|
4
|
+
module Languages
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def key(value)
|
|
8
|
+
value.to_s.strip.downcase
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
BY_CODE = LANGUAGES.to_h { |language| [key(language[:code]), language] }.freeze
|
|
12
|
+
BY_LABEL = LANGUAGES.to_h { |language| [key(language[:label]), language] }.freeze
|
|
13
|
+
BY_NATIVE = LANGUAGES.to_h { |language| [key(language[:native]), language] }.freeze
|
|
14
|
+
ALIASES = Protocol::LANGUAGE_ALIASES.to_h { |(name, code)| [key(name), key(code)] }.freeze
|
|
15
|
+
|
|
16
|
+
def by_code(code)
|
|
17
|
+
return nil if code.nil? || code == ""
|
|
18
|
+
|
|
19
|
+
BY_CODE[key(code)]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def resolve(input)
|
|
23
|
+
return nil if input.nil? || input == ""
|
|
24
|
+
|
|
25
|
+
wanted = key(input)
|
|
26
|
+
return nil if wanted.empty?
|
|
27
|
+
return BY_CODE[ALIASES[wanted]] if ALIASES.key?(wanted)
|
|
28
|
+
|
|
29
|
+
direct = BY_CODE[wanted] || BY_LABEL[wanted] || BY_NATIVE[wanted]
|
|
30
|
+
return direct if direct
|
|
31
|
+
|
|
32
|
+
base = wanted.split(Defaults::LANGUAGE_SUBTAG_PATTERN).first
|
|
33
|
+
return nil if base.nil? || base.empty? || base == wanted
|
|
34
|
+
|
|
35
|
+
BY_CODE[ALIASES.fetch(base, base)]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def rtl?(code)
|
|
39
|
+
resolve(code)&.fetch(:rtl, false) == true
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|