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,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