letmesendemail 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +14 -0
- data/LICENSE.md +21 -0
- data/README.md +220 -0
- data/docs/docs.md +513 -0
- data/examples/attachments.rb +21 -0
- data/examples/errors.rb +18 -0
- data/examples/idempotency.rb +17 -0
- data/examples/pagination.rb +20 -0
- data/examples/send.rb +16 -0
- data/examples/template.rb +18 -0
- data/examples/webhooks.rb +12 -0
- data/lib/letmesendemail/client.rb +109 -0
- data/lib/letmesendemail/config.rb +80 -0
- data/lib/letmesendemail/errors.rb +60 -0
- data/lib/letmesendemail/http_transport.rb +129 -0
- data/lib/letmesendemail/models.rb +99 -0
- data/lib/letmesendemail/resources/base.rb +31 -0
- data/lib/letmesendemail/resources/contact_categories.rb +45 -0
- data/lib/letmesendemail/resources/contacts.rb +60 -0
- data/lib/letmesendemail/resources/domains.rb +28 -0
- data/lib/letmesendemail/resources/email_topics.rb +53 -0
- data/lib/letmesendemail/resources/emails.rb +76 -0
- data/lib/letmesendemail/version.rb +6 -0
- data/lib/letmesendemail/webhooks.rb +109 -0
- data/lib/letmesendemail.rb +15 -0
- metadata +82 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module LetMeSendEmail
|
|
6
|
+
# HTTP client and entry point for all API resources.
|
|
7
|
+
class Client
|
|
8
|
+
RETRYABLE_HTTP_STATUSES = [408, 500, 502, 503, 504].freeze
|
|
9
|
+
SAFE_METHODS = %i[get delete].freeze
|
|
10
|
+
MAX_RETRY_DELAY_SECONDS = 300
|
|
11
|
+
BASE_RETRY_DELAY_SECONDS = 0.1
|
|
12
|
+
|
|
13
|
+
# @return [Resources::Emails]
|
|
14
|
+
attr_reader :emails
|
|
15
|
+
# @return [Resources::Domains]
|
|
16
|
+
attr_reader :domains
|
|
17
|
+
# @return [Resources::Contacts]
|
|
18
|
+
attr_reader :contacts
|
|
19
|
+
# @return [Resources::ContactCategories]
|
|
20
|
+
attr_reader :contact_categories
|
|
21
|
+
# @return [Resources::EmailTopics]
|
|
22
|
+
attr_reader :email_topics
|
|
23
|
+
|
|
24
|
+
# @param api_key [String, nil]
|
|
25
|
+
# @param config [Config, nil]
|
|
26
|
+
# @raise [ArgumentError] when configuration is invalid
|
|
27
|
+
def initialize(api_key: nil, config: nil)
|
|
28
|
+
raise ArgumentError, 'provide api_key or config, not both' if api_key && config
|
|
29
|
+
|
|
30
|
+
@config = config || Config.new(api_key || raise(ArgumentError, 'api_key is required'))
|
|
31
|
+
raise ArgumentError, 'config must be a LetMeSendEmail::Config' unless @config.is_a?(Config)
|
|
32
|
+
|
|
33
|
+
@config = @config.dup.validate!.freeze
|
|
34
|
+
@transport = HttpTransport.new(@config)
|
|
35
|
+
initialize_resources
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Performs an API request. Resource classes provide the primary public API.
|
|
39
|
+
# @param method [Symbol]
|
|
40
|
+
# @param path [String]
|
|
41
|
+
# @param body [Hash, nil]
|
|
42
|
+
# @param extra_headers [Hash]
|
|
43
|
+
# @return [Models::Model]
|
|
44
|
+
# @raise [Error]
|
|
45
|
+
def request(method, path, body: nil, extra_headers: {})
|
|
46
|
+
retry_eligible = retry_eligible?(method, extra_headers)
|
|
47
|
+
max_attempts = retry_eligible ? @config.retries + 1 : 1
|
|
48
|
+
last_error = nil
|
|
49
|
+
|
|
50
|
+
max_attempts.times do |attempt|
|
|
51
|
+
return @transport.call(method, path, body: body, extra_headers: extra_headers)
|
|
52
|
+
rescue RateLimitError => e
|
|
53
|
+
raise unless attempt < max_attempts - 1 && e.retry_after
|
|
54
|
+
|
|
55
|
+
last_error = e
|
|
56
|
+
sleep(e.retry_after)
|
|
57
|
+
rescue NetworkError, TimeoutError => e
|
|
58
|
+
raise unless attempt < max_attempts - 1
|
|
59
|
+
|
|
60
|
+
last_error = e
|
|
61
|
+
sleep(retry_delay(attempt))
|
|
62
|
+
rescue ApiError => e
|
|
63
|
+
raise unless RETRYABLE_HTTP_STATUSES.include?(e.status_code) && attempt < max_attempts - 1
|
|
64
|
+
|
|
65
|
+
last_error = e
|
|
66
|
+
sleep(retry_delay(attempt))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
raise last_error
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Encodes an untrusted identifier as exactly one URL path segment.
|
|
73
|
+
# @param id [String]
|
|
74
|
+
# @return [String]
|
|
75
|
+
# @raise [ArgumentError] when the identifier is empty or unsafe
|
|
76
|
+
def resource_path_segment(id)
|
|
77
|
+
raise ArgumentError, 'resource id must be a string' unless id.is_a?(String)
|
|
78
|
+
|
|
79
|
+
value = id
|
|
80
|
+
if value.empty? || value != value.strip || %w[. ..].include?(value) || value.match?(/[[:cntrl:]]/)
|
|
81
|
+
raise ArgumentError, 'resource id must be non-empty and must not be a dot segment or contain unsafe whitespace'
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
URI::DEFAULT_PARSER.escape(value, /[^A-Za-z0-9\-._~]/)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def initialize_resources
|
|
90
|
+
@emails = Resources::Emails.new(self)
|
|
91
|
+
@domains = Resources::Domains.new(self)
|
|
92
|
+
@contacts = Resources::Contacts.new(self)
|
|
93
|
+
@contact_categories = Resources::ContactCategories.new(self)
|
|
94
|
+
@email_topics = Resources::EmailTopics.new(self)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def retry_eligible?(method, headers)
|
|
98
|
+
return true if SAFE_METHODS.include?(method)
|
|
99
|
+
|
|
100
|
+
key = headers['Idempotency-Key'] || headers['idempotency-key']
|
|
101
|
+
%i[post put].include?(method) && key.is_a?(String) && !key.empty?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def retry_delay(attempt)
|
|
105
|
+
base = [BASE_RETRY_DELAY_SECONDS * (2**attempt), MAX_RETRY_DELAY_SECONDS].min
|
|
106
|
+
[base + (rand * base * 0.25), MAX_RETRY_DELAY_SECONDS].min
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module LetMeSendEmail
|
|
6
|
+
# Validated client configuration.
|
|
7
|
+
class Config
|
|
8
|
+
DEFAULT_BASE_URL = 'https://letmesend.email/api/v1'
|
|
9
|
+
DEFAULT_TIMEOUT_MS = 30_000
|
|
10
|
+
DEFAULT_RETRIES = 0
|
|
11
|
+
MAX_RETRIES = 20
|
|
12
|
+
|
|
13
|
+
attr_reader :base_url
|
|
14
|
+
attr_accessor :api_key, :timeout_ms, :retries
|
|
15
|
+
|
|
16
|
+
# @param api_key [String] API bearer token
|
|
17
|
+
def initialize(api_key)
|
|
18
|
+
@api_key = api_key
|
|
19
|
+
@base_url = DEFAULT_BASE_URL
|
|
20
|
+
@timeout_ms = DEFAULT_TIMEOUT_MS
|
|
21
|
+
@retries = DEFAULT_RETRIES
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param url [String] absolute API base URL
|
|
25
|
+
# @return [String]
|
|
26
|
+
def base_url=(url)
|
|
27
|
+
@base_url = url.to_s.sub(%r{/+\z}, '')
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Validates and normalizes configuration before use.
|
|
31
|
+
# @return [self]
|
|
32
|
+
# @raise [ArgumentError] for unsafe or unsupported configuration
|
|
33
|
+
def validate!
|
|
34
|
+
validate_api_key!
|
|
35
|
+
validate_base_url!
|
|
36
|
+
validate_timeout!
|
|
37
|
+
validate_retries!
|
|
38
|
+
@api_key = api_key.dup.freeze
|
|
39
|
+
@base_url = base_url.dup.freeze
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def validate_api_key!
|
|
46
|
+
return if api_key.is_a?(String) && !api_key.empty? && api_key == api_key.strip && !api_key.match?(/[[:cntrl:]]/)
|
|
47
|
+
|
|
48
|
+
raise ArgumentError, 'api_key must be a non-empty string without surrounding whitespace or control characters'
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def validate_base_url!
|
|
52
|
+
uri = URI.parse(base_url)
|
|
53
|
+
unless valid_base_uri?(uri)
|
|
54
|
+
raise ArgumentError,
|
|
55
|
+
'base_url must be an absolute HTTP(S) URL without credentials, query, or fragment'
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
self.base_url = base_url
|
|
59
|
+
rescue URI::InvalidURIError
|
|
60
|
+
raise ArgumentError, 'base_url must be an absolute HTTP(S) URL without credentials, query, or fragment'
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def valid_base_uri?(uri)
|
|
64
|
+
uri.is_a?(URI::HTTP) && %w[http https].include?(uri.scheme) && uri.host &&
|
|
65
|
+
!uri.userinfo && !uri.query && !uri.fragment
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def validate_timeout!
|
|
69
|
+
return if timeout_ms.is_a?(Numeric) && timeout_ms.positive?
|
|
70
|
+
|
|
71
|
+
raise ArgumentError, 'timeout_ms must be greater than zero'
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def validate_retries!
|
|
75
|
+
return if retries.is_a?(Integer) && retries.between?(0, MAX_RETRIES)
|
|
76
|
+
|
|
77
|
+
raise ArgumentError, "retries must be an integer between 0 and #{MAX_RETRIES}"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LetMeSendEmail
|
|
4
|
+
# Base class for all SDK failures.
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
attr_reader :status_code, :api_code, :validation_errors, :request_id, :response_headers, :raw_body
|
|
7
|
+
|
|
8
|
+
def initialize(message = nil, status_code: nil, api_code: nil,
|
|
9
|
+
validation_errors: nil, request_id: nil,
|
|
10
|
+
response_headers: nil, raw_body: nil)
|
|
11
|
+
super(message)
|
|
12
|
+
@status_code = status_code
|
|
13
|
+
@api_code = api_code
|
|
14
|
+
@validation_errors = validation_errors || {}
|
|
15
|
+
@request_id = request_id
|
|
16
|
+
@response_headers = response_headers || {}
|
|
17
|
+
@raw_body = raw_body
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# An API response that does not have a more specific mapping.
|
|
22
|
+
class ApiError < Error; end
|
|
23
|
+
# HTTP 401 authentication failure.
|
|
24
|
+
class AuthenticationError < Error; end
|
|
25
|
+
# HTTP 403 authorization failure.
|
|
26
|
+
class AuthorizationError < Error; end
|
|
27
|
+
# HTTP 400, 413, or 422 validation failure.
|
|
28
|
+
class ValidationError < Error; end
|
|
29
|
+
|
|
30
|
+
# HTTP 429 failure with rate-limit metadata.
|
|
31
|
+
class RateLimitError < Error
|
|
32
|
+
attr_reader :retry_after, :limit, :remaining, :reset_at
|
|
33
|
+
|
|
34
|
+
def initialize(message = nil, status_code: nil, api_code: nil,
|
|
35
|
+
validation_errors: nil, request_id: nil,
|
|
36
|
+
response_headers: nil, raw_body: nil,
|
|
37
|
+
retry_after: nil, limit: nil, remaining: nil, reset_at: nil)
|
|
38
|
+
super(message, status_code: status_code, api_code: api_code,
|
|
39
|
+
validation_errors: validation_errors, request_id: request_id,
|
|
40
|
+
response_headers: response_headers, raw_body: raw_body)
|
|
41
|
+
@retry_after = retry_after
|
|
42
|
+
@limit = limit
|
|
43
|
+
@remaining = remaining
|
|
44
|
+
@reset_at = reset_at
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# HTTP 404 failure.
|
|
49
|
+
class NotFoundError < Error; end
|
|
50
|
+
# HTTP 409 failure.
|
|
51
|
+
class ConflictError < Error; end
|
|
52
|
+
# Connection, socket, TLS, or response transport failure.
|
|
53
|
+
class NetworkError < Error; end
|
|
54
|
+
# HTTP connection, read, or write timeout.
|
|
55
|
+
class TimeoutError < Error; end
|
|
56
|
+
# Invalid webhook request or signature.
|
|
57
|
+
class WebhookVerificationError < Error; end
|
|
58
|
+
# Invalid webhook signing secret.
|
|
59
|
+
class WebhookSigningError < Error; end
|
|
60
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'openssl'
|
|
6
|
+
require 'time'
|
|
7
|
+
require 'uri'
|
|
8
|
+
|
|
9
|
+
module LetMeSendEmail
|
|
10
|
+
# Internal net/http transport.
|
|
11
|
+
class HttpTransport
|
|
12
|
+
MAX_RETRY_AFTER_SECONDS = 300
|
|
13
|
+
|
|
14
|
+
def initialize(config)
|
|
15
|
+
@config = config
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(method, path, body: nil, extra_headers: {})
|
|
19
|
+
uri = URI.parse("#{@config.base_url}/#{path.sub(%r{^/}, '')}")
|
|
20
|
+
response = perform(uri, method, body, extra_headers)
|
|
21
|
+
headers = response.each_header.to_h { |key, value| [key.downcase, value] }
|
|
22
|
+
status = response.code.to_i
|
|
23
|
+
raw_body = response.body.to_s
|
|
24
|
+
parsed = parse_response_body(raw_body, status, headers)
|
|
25
|
+
|
|
26
|
+
return Models.wrap(parsed) if status.between?(200, 299)
|
|
27
|
+
|
|
28
|
+
raise error_from_status(status, parsed, headers, raw_body)
|
|
29
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout => e
|
|
30
|
+
raise TimeoutError, e.message
|
|
31
|
+
rescue SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError, Net::HTTPBadResponse => e
|
|
32
|
+
raise NetworkError, e.message
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def perform(uri, method, body, extra_headers)
|
|
38
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
39
|
+
http.use_ssl = uri.scheme == 'https'
|
|
40
|
+
timeout_seconds = @config.timeout_ms / 1000.0
|
|
41
|
+
http.open_timeout = timeout_seconds
|
|
42
|
+
http.read_timeout = timeout_seconds
|
|
43
|
+
http.write_timeout = timeout_seconds if http.respond_to?(:write_timeout=)
|
|
44
|
+
|
|
45
|
+
request = request_class(method).new(uri)
|
|
46
|
+
request['Authorization'] = "Bearer #{@config.api_key}"
|
|
47
|
+
request['Content-Type'] = 'application/json'
|
|
48
|
+
request['Accept'] = 'application/json'
|
|
49
|
+
request['User-Agent'] = "letmesendemail-ruby/#{LetMeSendEmail::VERSION}"
|
|
50
|
+
extra_headers.each { |key, value| request[key] = value }
|
|
51
|
+
request.body = JSON.generate(body) if body
|
|
52
|
+
http.request(request)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def request_class(method)
|
|
56
|
+
{
|
|
57
|
+
get: Net::HTTP::Get,
|
|
58
|
+
post: Net::HTTP::Post,
|
|
59
|
+
put: Net::HTTP::Put,
|
|
60
|
+
delete: Net::HTTP::Delete
|
|
61
|
+
}.fetch(method) { raise ArgumentError, "unknown HTTP method: #{method}" }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def parse_response_body(raw_body, status, headers)
|
|
65
|
+
return {} if raw_body.empty?
|
|
66
|
+
|
|
67
|
+
parsed = JSON.parse(raw_body)
|
|
68
|
+
return parsed if parsed.is_a?(Hash)
|
|
69
|
+
|
|
70
|
+
raise_response_error('API response must be a JSON object.', status, headers, raw_body)
|
|
71
|
+
rescue JSON::ParserError => e
|
|
72
|
+
raise_response_error("API returned invalid JSON: #{e.message}", status, headers, raw_body)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def raise_response_error(message, status, headers, raw_body)
|
|
76
|
+
raise ApiError.new(message, status_code: status, response_headers: headers, raw_body: raw_body)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def error_from_status(status, body, headers, raw_body)
|
|
80
|
+
message = body['message'] || 'Unknown error.'
|
|
81
|
+
keywords = error_keywords(status, body, headers, raw_body)
|
|
82
|
+
|
|
83
|
+
case status
|
|
84
|
+
when 400, 413, 422 then ValidationError.new(message, **keywords)
|
|
85
|
+
when 401 then AuthenticationError.new(message, **keywords)
|
|
86
|
+
when 403 then AuthorizationError.new(message, **keywords)
|
|
87
|
+
when 404 then NotFoundError.new(message, **keywords)
|
|
88
|
+
when 409 then ConflictError.new(message, **keywords)
|
|
89
|
+
when 429 then rate_limit_error(message, keywords, headers)
|
|
90
|
+
else ApiError.new(message, **keywords)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def error_keywords(status, body, headers, raw_body)
|
|
95
|
+
{
|
|
96
|
+
status_code: status,
|
|
97
|
+
api_code: body['name'],
|
|
98
|
+
validation_errors: body['errors'],
|
|
99
|
+
request_id: headers['x-request-id'],
|
|
100
|
+
response_headers: headers,
|
|
101
|
+
raw_body: raw_body
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def rate_limit_error(message, keywords, headers)
|
|
106
|
+
RateLimitError.new(
|
|
107
|
+
message,
|
|
108
|
+
**keywords,
|
|
109
|
+
retry_after: parse_retry_after(headers['retry-after']),
|
|
110
|
+
limit: parse_integer(headers['x-ratelimit-limit']),
|
|
111
|
+
remaining: parse_integer(headers['x-ratelimit-remaining']),
|
|
112
|
+
reset_at: headers['x-ratelimit-reset']
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def parse_retry_after(value)
|
|
117
|
+
return if value.nil?
|
|
118
|
+
|
|
119
|
+
seconds = value.match?(/\A\d+\z/) ? value.to_i : (Time.httpdate(value) - Time.now).ceil
|
|
120
|
+
seconds if seconds.between?(1, MAX_RETRY_AFTER_SECONDS)
|
|
121
|
+
rescue ArgumentError
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def parse_integer(value)
|
|
126
|
+
Integer(value, exception: false)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module LetMeSendEmail
|
|
6
|
+
# Plain-data wrappers for API responses.
|
|
7
|
+
module Models
|
|
8
|
+
# Application-friendly representation of an API response.
|
|
9
|
+
#
|
|
10
|
+
# Nested hashes and arrays are recursively wrapped. Use {#to_h} for a
|
|
11
|
+
# defensive plain-data copy suitable for database persistence.
|
|
12
|
+
class Model
|
|
13
|
+
include Enumerable
|
|
14
|
+
|
|
15
|
+
# @param attributes [Hash] response attributes
|
|
16
|
+
def initialize(attributes)
|
|
17
|
+
raise ArgumentError, 'attributes must be a Hash' unless attributes.is_a?(Hash)
|
|
18
|
+
|
|
19
|
+
@attributes = attributes.each_with_object({}) do |(key, value), result|
|
|
20
|
+
result[key.to_s.dup.freeze] = Models.wrap(value)
|
|
21
|
+
end.freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param key [String, Symbol] field name
|
|
25
|
+
# @return [Object, nil] wrapped field value
|
|
26
|
+
def [](key)
|
|
27
|
+
@attributes[key.to_s]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @param key [String, Symbol] field name
|
|
31
|
+
# @return [Object] wrapped field value
|
|
32
|
+
def fetch(key, *defaults, &)
|
|
33
|
+
@attributes.fetch(key.to_s, *defaults, &)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param key [String, Symbol] field name
|
|
37
|
+
# @return [Boolean] whether the field exists
|
|
38
|
+
def key?(key)
|
|
39
|
+
@attributes.key?(key.to_s)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Iterates through response fields.
|
|
43
|
+
# @yieldparam key [String]
|
|
44
|
+
# @yieldparam value [Object]
|
|
45
|
+
# @return [Enumerator, self]
|
|
46
|
+
def each(&block)
|
|
47
|
+
return enum_for(:each) unless block
|
|
48
|
+
|
|
49
|
+
@attributes.each(&block)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Hash] recursive defensive plain-data copy
|
|
53
|
+
def to_h
|
|
54
|
+
Models.unwrap(@attributes)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Rails-compatible JSON representation.
|
|
58
|
+
# @return [Hash]
|
|
59
|
+
def as_json(*)
|
|
60
|
+
to_h
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @return [String] JSON document
|
|
64
|
+
def to_json(*args)
|
|
65
|
+
to_h.to_json(*args)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @return [String]
|
|
69
|
+
def inspect
|
|
70
|
+
"#<#{self.class.name} #{@attributes.inspect}>"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
module_function
|
|
75
|
+
|
|
76
|
+
# Recursively wraps hashes returned by the API.
|
|
77
|
+
# @param value [Object]
|
|
78
|
+
# @return [Object]
|
|
79
|
+
def wrap(value)
|
|
80
|
+
return value.dup.freeze if value.is_a?(String)
|
|
81
|
+
return Model.new(value) if value.is_a?(Hash)
|
|
82
|
+
return value.map { |item| wrap(item) }.freeze if value.is_a?(Array)
|
|
83
|
+
|
|
84
|
+
value
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Recursively converts models into plain mutable Ruby data.
|
|
88
|
+
# @param value [Object]
|
|
89
|
+
# @return [Object]
|
|
90
|
+
def unwrap(value)
|
|
91
|
+
return value.dup if value.is_a?(String)
|
|
92
|
+
return value.to_h if value.is_a?(Model)
|
|
93
|
+
return value.to_h { |key, item| [key.dup, unwrap(item)] } if value.is_a?(Hash)
|
|
94
|
+
return value.map { |item| unwrap(item) } if value.is_a?(Array)
|
|
95
|
+
|
|
96
|
+
value
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module LetMeSendEmail
|
|
6
|
+
module Resources
|
|
7
|
+
# Shared resource URL and pagination behavior.
|
|
8
|
+
class Base
|
|
9
|
+
# @param client [Client]
|
|
10
|
+
def initialize(client)
|
|
11
|
+
@client = client
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def path_for(collection, id)
|
|
17
|
+
"/#{collection}/#{@client.resource_path_segment(id)}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def list_path(collection, per_page:, after:, before:)
|
|
21
|
+
raise ArgumentError, 'after and before cannot be used together' if after && before
|
|
22
|
+
|
|
23
|
+
params = {}
|
|
24
|
+
params[:per_page] = per_page unless per_page.nil?
|
|
25
|
+
params[:after] = after unless after.nil?
|
|
26
|
+
params[:before] = before unless before.nil?
|
|
27
|
+
params.empty? ? "/#{collection}" : "/#{collection}?#{URI.encode_www_form(params)}"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LetMeSendEmail
|
|
4
|
+
module Resources
|
|
5
|
+
# Contact Categories API operations.
|
|
6
|
+
class ContactCategories < Base
|
|
7
|
+
# Creates a contact category.
|
|
8
|
+
# @return [Models::Model]
|
|
9
|
+
def create(name:, slug: nil)
|
|
10
|
+
body = { name: name }
|
|
11
|
+
body[:slug] = slug if slug
|
|
12
|
+
@client.request(:post, '/contact-categories', body: body)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Lists one cursor page of contact categories.
|
|
16
|
+
# @return [Models::Model]
|
|
17
|
+
def list(per_page: nil, after: nil, before: nil)
|
|
18
|
+
@client.request(:get, list_path('contact-categories', per_page: per_page, after: after, before: before))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Retrieves a contact category.
|
|
22
|
+
# @param id [String]
|
|
23
|
+
# @return [Models::Model]
|
|
24
|
+
def get(id)
|
|
25
|
+
@client.request(:get, path_for('contact-categories', id))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Updates a contact category.
|
|
29
|
+
# @param id [String]
|
|
30
|
+
# @return [Models::Model]
|
|
31
|
+
def update(id, name:, slug: nil)
|
|
32
|
+
body = { name: name }
|
|
33
|
+
body[:slug] = slug if slug
|
|
34
|
+
@client.request(:put, path_for('contact-categories', id), body: body)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Deletes a contact category.
|
|
38
|
+
# @param id [String]
|
|
39
|
+
# @return [Models::Model]
|
|
40
|
+
def delete(id)
|
|
41
|
+
@client.request(:delete, path_for('contact-categories', id))
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LetMeSendEmail
|
|
4
|
+
module Resources
|
|
5
|
+
# Contacts API operations.
|
|
6
|
+
class Contacts < Base
|
|
7
|
+
# Creates a contact.
|
|
8
|
+
# @return [Models::Model]
|
|
9
|
+
def create(email:, first_name: nil, last_name: nil, phone: nil,
|
|
10
|
+
is_globally_unsubscribed: nil, categories: nil, email_topics: nil)
|
|
11
|
+
body = { email: email }
|
|
12
|
+
body[:first_name] = first_name if first_name
|
|
13
|
+
body[:last_name] = last_name if last_name
|
|
14
|
+
body[:phone] = phone if phone
|
|
15
|
+
body[:is_globally_unsubscribed] = is_globally_unsubscribed unless is_globally_unsubscribed.nil?
|
|
16
|
+
body[:categories] = categories if categories
|
|
17
|
+
body[:email_topics] = email_topics if email_topics
|
|
18
|
+
@client.request(:post, '/contacts', body: body)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Lists one cursor page of contacts.
|
|
22
|
+
# @return [Models::Model]
|
|
23
|
+
def list(per_page: nil, after: nil, before: nil)
|
|
24
|
+
@client.request(:get, list_path('contacts', per_page: per_page, after: after, before: before))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Retrieves a contact.
|
|
28
|
+
# @param id [String]
|
|
29
|
+
# @return [Models::Model]
|
|
30
|
+
def get(id)
|
|
31
|
+
@client.request(:get, path_for('contacts', id))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Updates a contact.
|
|
35
|
+
# @param id [String]
|
|
36
|
+
# @return [Models::Model]
|
|
37
|
+
def update(id, first_name: nil, last_name: nil, phone: nil,
|
|
38
|
+
is_globally_unsubscribed: nil, categories: nil, email_topics: nil,
|
|
39
|
+
sync_categories: nil, sync_email_topics: nil)
|
|
40
|
+
body = {}
|
|
41
|
+
body[:first_name] = first_name if first_name
|
|
42
|
+
body[:last_name] = last_name if last_name
|
|
43
|
+
body[:phone] = phone if phone
|
|
44
|
+
body[:is_globally_unsubscribed] = is_globally_unsubscribed unless is_globally_unsubscribed.nil?
|
|
45
|
+
body[:categories] = categories if categories
|
|
46
|
+
body[:email_topics] = email_topics if email_topics
|
|
47
|
+
body[:sync_categories] = sync_categories unless sync_categories.nil?
|
|
48
|
+
body[:sync_email_topics] = sync_email_topics unless sync_email_topics.nil?
|
|
49
|
+
@client.request(:put, path_for('contacts', id), body: body)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Deletes a contact.
|
|
53
|
+
# @param id [String]
|
|
54
|
+
# @return [Models::Model]
|
|
55
|
+
def delete(id)
|
|
56
|
+
@client.request(:delete, path_for('contacts', id))
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LetMeSendEmail
|
|
4
|
+
module Resources
|
|
5
|
+
# Domains API operations.
|
|
6
|
+
class Domains < Base
|
|
7
|
+
# Lists one cursor page of domains.
|
|
8
|
+
# @return [Models::Model]
|
|
9
|
+
def list(per_page: nil, after: nil, before: nil)
|
|
10
|
+
@client.request(:get, list_path('domains', per_page: per_page, after: after, before: before))
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Retrieves a domain.
|
|
14
|
+
# @param id [String]
|
|
15
|
+
# @return [Models::Model]
|
|
16
|
+
def get(id)
|
|
17
|
+
@client.request(:get, path_for('domains', id))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Requests verification for a domain name.
|
|
21
|
+
# @param domain [String]
|
|
22
|
+
# @return [Models::Model]
|
|
23
|
+
def verify(domain)
|
|
24
|
+
@client.request(:post, '/domains/verify', body: { domain: domain })
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LetMeSendEmail
|
|
4
|
+
module Resources
|
|
5
|
+
# Email Topics API operations.
|
|
6
|
+
class EmailTopics < Base
|
|
7
|
+
# Creates an email topic.
|
|
8
|
+
# @return [Models::Model]
|
|
9
|
+
def create(name:, slug:, auto_subscribe: nil, public: nil,
|
|
10
|
+
description: nil, domain_id: nil)
|
|
11
|
+
body = { name: name, slug: slug }
|
|
12
|
+
body[:auto_subscribe] = auto_subscribe unless auto_subscribe.nil?
|
|
13
|
+
body[:public] = public unless public.nil?
|
|
14
|
+
body[:description] = description if description
|
|
15
|
+
body[:domain] = { id: domain_id } if domain_id
|
|
16
|
+
@client.request(:post, '/email-topics', body: body)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Lists one cursor page of email topics.
|
|
20
|
+
# @return [Models::Model]
|
|
21
|
+
def list(per_page: nil, after: nil, before: nil)
|
|
22
|
+
@client.request(:get, list_path('email-topics', per_page: per_page, after: after, before: before))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Retrieves an email topic.
|
|
26
|
+
# @param id [String]
|
|
27
|
+
# @return [Models::Model]
|
|
28
|
+
def get(id)
|
|
29
|
+
@client.request(:get, path_for('email-topics', id))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Updates an email topic.
|
|
33
|
+
# @param id [String]
|
|
34
|
+
# @return [Models::Model]
|
|
35
|
+
def update(id, name: nil, slug: nil, description: nil, public: nil, auto_subscribe: nil)
|
|
36
|
+
body = {}
|
|
37
|
+
body[:name] = name if name
|
|
38
|
+
body[:slug] = slug if slug
|
|
39
|
+
body[:description] = description if description
|
|
40
|
+
body[:public] = public unless public.nil?
|
|
41
|
+
body[:auto_subscribe] = auto_subscribe unless auto_subscribe.nil?
|
|
42
|
+
@client.request(:put, path_for('email-topics', id), body: body)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Deletes an email topic.
|
|
46
|
+
# @param id [String]
|
|
47
|
+
# @return [Models::Model]
|
|
48
|
+
def delete(id)
|
|
49
|
+
@client.request(:delete, path_for('email-topics', id))
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|