anypost 1.0.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/LICENSE +21 -0
- data/README.md +352 -0
- data/lib/anypost/client.rb +72 -0
- data/lib/anypost/errors.rb +203 -0
- data/lib/anypost/http_client.rb +149 -0
- data/lib/anypost/page.rb +55 -0
- data/lib/anypost/resources/api_keys.rb +38 -0
- data/lib/anypost/resources/base.rb +36 -0
- data/lib/anypost/resources/domains.rb +44 -0
- data/lib/anypost/resources/email.rb +48 -0
- data/lib/anypost/resources/events.rb +34 -0
- data/lib/anypost/resources/identity.rb +13 -0
- data/lib/anypost/resources/suppressions.rb +55 -0
- data/lib/anypost/resources/templates.rb +62 -0
- data/lib/anypost/resources/webhooks.rb +51 -0
- data/lib/anypost/response.rb +67 -0
- data/lib/anypost/version.rb +7 -0
- data/lib/anypost/webhook_signature.rb +114 -0
- data/lib/anypost.rb +22 -0
- metadata +81 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "faraday"
|
|
6
|
+
|
|
7
|
+
module Anypost
|
|
8
|
+
# Owns a Faraday connection and implements the request loop: header assembly,
|
|
9
|
+
# retries with full-jitter backoff, idempotency keys, and error mapping.
|
|
10
|
+
#
|
|
11
|
+
# @api private
|
|
12
|
+
class HttpClient
|
|
13
|
+
RETRYABLE_STATUS = [429, 502, 503].freeze
|
|
14
|
+
MAX_BACKOFF_SECONDS = 8.0
|
|
15
|
+
BASE_BACKOFF_SECONDS = 0.5
|
|
16
|
+
|
|
17
|
+
# @param sleeper [#call] override the sleep between retries (tests)
|
|
18
|
+
# @param jitter [#call] override the [0,1) jitter factor (tests)
|
|
19
|
+
def initialize(api_key:, base_url:, timeout:, max_retries:, default_headers: {},
|
|
20
|
+
connection: nil, sleeper: nil, jitter: nil)
|
|
21
|
+
@api_key = api_key
|
|
22
|
+
@max_retries = max_retries
|
|
23
|
+
@default_headers = default_headers
|
|
24
|
+
@connection = connection || build_connection(base_url, timeout)
|
|
25
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) if seconds.positive? }
|
|
26
|
+
@jitter = jitter || -> { rand }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Perform a request and return the decoded JSON body.
|
|
30
|
+
def request(method, path, body: nil, query: nil, idempotent: false,
|
|
31
|
+
idempotency_key: nil, max_retries: nil, extra_headers: nil)
|
|
32
|
+
retries = max_retries.nil? ? @max_retries : max_retries
|
|
33
|
+
headers = build_headers(
|
|
34
|
+
has_body: !body.nil?,
|
|
35
|
+
idempotent: idempotent,
|
|
36
|
+
idempotency_key: idempotency_key,
|
|
37
|
+
max_retries: retries,
|
|
38
|
+
extra_headers: extra_headers
|
|
39
|
+
)
|
|
40
|
+
payload = body.nil? ? nil : JSON.generate(body)
|
|
41
|
+
params = clean_query(query)
|
|
42
|
+
relative = path.sub(%r{\A/+}, "")
|
|
43
|
+
|
|
44
|
+
attempt = 0
|
|
45
|
+
loop do
|
|
46
|
+
begin
|
|
47
|
+
response = @connection.run_request(method, relative, payload, headers) do |req|
|
|
48
|
+
req.params.update(params) unless params.empty?
|
|
49
|
+
end
|
|
50
|
+
rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e
|
|
51
|
+
raise APIConnectionError.new(connection_message(e), cause: e) unless attempt < retries
|
|
52
|
+
|
|
53
|
+
@sleeper.call(backoff(attempt, nil))
|
|
54
|
+
attempt += 1
|
|
55
|
+
next
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
status = response.status
|
|
59
|
+
return decode(response) if status >= 200 && status < 300
|
|
60
|
+
|
|
61
|
+
if RETRYABLE_STATUS.include?(status) && attempt < retries
|
|
62
|
+
@sleeper.call(backoff(attempt, response.headers))
|
|
63
|
+
attempt += 1
|
|
64
|
+
next
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
raise Errors.from_response(status, decode(response), response.headers)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.user_agent
|
|
72
|
+
"anypost-ruby/#{Anypost::VERSION} Ruby/#{RUBY_VERSION}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def build_connection(base_url, timeout)
|
|
78
|
+
url = base_url.end_with?("/") ? base_url : "#{base_url}/"
|
|
79
|
+
Faraday.new(url: url) do |f|
|
|
80
|
+
f.options.timeout = timeout
|
|
81
|
+
f.adapter Faraday.default_adapter
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def build_headers(has_body:, idempotent:, idempotency_key:, max_retries:, extra_headers:)
|
|
86
|
+
headers = {
|
|
87
|
+
"Authorization" => "Bearer #{@api_key}",
|
|
88
|
+
"Accept" => "application/json",
|
|
89
|
+
"User-Agent" => self.class.user_agent
|
|
90
|
+
}.merge(@default_headers)
|
|
91
|
+
|
|
92
|
+
headers["Content-Type"] = "application/json" if has_body
|
|
93
|
+
|
|
94
|
+
if idempotent
|
|
95
|
+
if idempotency_key && !idempotency_key.empty?
|
|
96
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
97
|
+
elsif max_retries.positive?
|
|
98
|
+
# Auto-key so built-in retries of a send cannot deliver twice.
|
|
99
|
+
headers["Idempotency-Key"] = SecureRandom.uuid
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
headers.merge!(extra_headers) if extra_headers
|
|
104
|
+
headers
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def clean_query(query)
|
|
108
|
+
return {} if query.nil?
|
|
109
|
+
|
|
110
|
+
query.each_with_object({}) do |(key, value), out|
|
|
111
|
+
next if value.nil?
|
|
112
|
+
|
|
113
|
+
out[key.to_s] = case value
|
|
114
|
+
when true then "true"
|
|
115
|
+
when false then "false"
|
|
116
|
+
else value.to_s
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def backoff(attempt, headers)
|
|
122
|
+
unless headers.nil?
|
|
123
|
+
after = Errors.retry_after_seconds(headers)
|
|
124
|
+
return [after, MAX_BACKOFF_SECONDS].min if after
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
ceiling = [BASE_BACKOFF_SECONDS * (2**attempt), MAX_BACKOFF_SECONDS].min
|
|
128
|
+
@jitter.call * ceiling # full jitter
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def decode(response)
|
|
132
|
+
return nil if response.status == 204
|
|
133
|
+
|
|
134
|
+
body = response.body
|
|
135
|
+
return nil if body.nil? || (body.respond_to?(:empty?) && body.empty?)
|
|
136
|
+
return body unless body.is_a?(String)
|
|
137
|
+
|
|
138
|
+
begin
|
|
139
|
+
JSON.parse(body)
|
|
140
|
+
rescue JSON::ParserError
|
|
141
|
+
body
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def connection_message(error)
|
|
146
|
+
"Could not reach Anypost: #{error.message}"
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
data/lib/anypost/page.rb
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anypost
|
|
4
|
+
# One page of a list result.
|
|
5
|
+
#
|
|
6
|
+
# Mirrors the wire envelope (`data`, `has_more`, `next_cursor`) and is
|
|
7
|
+
# enumerable: iterating walks every remaining page automatically, re-fetching
|
|
8
|
+
# with `after = next_cursor`.
|
|
9
|
+
#
|
|
10
|
+
# page = client.domains.list # one page
|
|
11
|
+
# page.data # just this page's items
|
|
12
|
+
#
|
|
13
|
+
# client.domains.list.each do |domain| # every domain, across all pages
|
|
14
|
+
# puts domain.name
|
|
15
|
+
# end
|
|
16
|
+
class Page
|
|
17
|
+
include Enumerable
|
|
18
|
+
|
|
19
|
+
# @return [Array<Response>] the items on this page
|
|
20
|
+
attr_reader :data
|
|
21
|
+
# @return [Boolean]
|
|
22
|
+
attr_reader :has_more
|
|
23
|
+
# @return [String, nil]
|
|
24
|
+
attr_reader :next_cursor
|
|
25
|
+
|
|
26
|
+
# @param response [Hash] the decoded page envelope
|
|
27
|
+
# @yieldparam cursor [String] fetches and returns the next {Page}
|
|
28
|
+
def initialize(response, &fetch_next)
|
|
29
|
+
raw = response.is_a?(Hash) ? response : {}
|
|
30
|
+
@data = (raw["data"] || []).map { |item| Response.wrap(item) }
|
|
31
|
+
@has_more = raw["has_more"] || false
|
|
32
|
+
cursor = raw["next_cursor"]
|
|
33
|
+
@next_cursor = cursor.is_a?(String) ? cursor : nil
|
|
34
|
+
@fetch_next = fetch_next
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Fetch the next page, or nil when there are no more.
|
|
38
|
+
# @return [Page, nil]
|
|
39
|
+
def next_page
|
|
40
|
+
return nil unless @has_more && @next_cursor
|
|
41
|
+
|
|
42
|
+
@fetch_next.call(@next_cursor)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def each
|
|
46
|
+
return enum_for(:each) unless block_given?
|
|
47
|
+
|
|
48
|
+
page = self
|
|
49
|
+
while page
|
|
50
|
+
page.data.each { |item| yield item }
|
|
51
|
+
page = page.next_page
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anypost
|
|
4
|
+
module Resources
|
|
5
|
+
# Operations on the `/api-keys` endpoints.
|
|
6
|
+
class ApiKeys < Base
|
|
7
|
+
# List the team's API keys, newest-first.
|
|
8
|
+
def list(params = {})
|
|
9
|
+
paginate("/api-keys", {limit: params[:limit], after: params[:after]})
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Issue a new API key.
|
|
13
|
+
#
|
|
14
|
+
# The plaintext secret is returned only in this response, as `key` — store
|
|
15
|
+
# it securely; it cannot be retrieved later.
|
|
16
|
+
def create(params)
|
|
17
|
+
request_object(:post, "/api-keys", body: params)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Retrieve a single API key's metadata. The secret is never returned.
|
|
21
|
+
def get(id)
|
|
22
|
+
request_object(:get, "/api-keys/#{encode(id)}")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Update a key's name, permissions, and restrictions. The secret is not
|
|
26
|
+
# rotated here. Changes may take up to 5 minutes to propagate.
|
|
27
|
+
def update(id, params)
|
|
28
|
+
request_object(:patch, "/api-keys/#{encode(id)}", body: params)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Delete a key. It may keep authenticating for up to 5 minutes (gateway cache).
|
|
32
|
+
def delete(id)
|
|
33
|
+
@http.request(:delete, "/api-keys/#{encode(id)}")
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
|
|
5
|
+
module Anypost
|
|
6
|
+
module Resources
|
|
7
|
+
# Shared base for the API resources: holds the transport, wraps decoded
|
|
8
|
+
# object responses as {Response} instances, and builds {Page}s.
|
|
9
|
+
#
|
|
10
|
+
# @api private
|
|
11
|
+
class Base
|
|
12
|
+
def initialize(http)
|
|
13
|
+
@http = http
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def request_object(method, path, **opts)
|
|
19
|
+
decoded = @http.request(method, path, **opts)
|
|
20
|
+
Response.new(decoded.is_a?(Hash) ? decoded : {})
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def paginate(path, query)
|
|
24
|
+
decoded = @http.request(:get, path, query: query)
|
|
25
|
+
Page.new(decoded.is_a?(Hash) ? decoded : {}) do |after|
|
|
26
|
+
paginate(path, query.merge(after: after))
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Percent-encode a path segment (encodes "/", "@", "*", etc.).
|
|
31
|
+
def encode(segment)
|
|
32
|
+
ERB::Util.url_encode(segment.to_s)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anypost
|
|
4
|
+
module Resources
|
|
5
|
+
# Operations on the `/domains` endpoints.
|
|
6
|
+
class Domains < Base
|
|
7
|
+
# List the team's domains, newest-first. Returns a {Page}; iterate it to
|
|
8
|
+
# walk every page, or follow `page.next_cursor` yourself.
|
|
9
|
+
def list(params = {})
|
|
10
|
+
paginate("/domains", {limit: params[:limit], after: params[:after]})
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Add a sending domain. The returned domain is `pending` until verified.
|
|
14
|
+
def create(params)
|
|
15
|
+
request_object(:post, "/domains", body: params)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Retrieve a single domain by id.
|
|
19
|
+
def get(id)
|
|
20
|
+
request_object(:get, "/domains/#{encode(id)}")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Update a domain's tracking configuration. The domain `name` is immutable.
|
|
24
|
+
def update(id, params)
|
|
25
|
+
request_object(:patch, "/domains/#{encode(id)}", body: params)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Permanently delete a domain and its DKIM keys.
|
|
29
|
+
def delete(id)
|
|
30
|
+
@http.request(:delete, "/domains/#{encode(id)}")
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Trigger a verification check.
|
|
35
|
+
#
|
|
36
|
+
# Always returns the current domain — read `status` and
|
|
37
|
+
# `verification_failure` to learn the outcome; a still-`pending` domain
|
|
38
|
+
# does not raise. Safe to poll while DNS propagates.
|
|
39
|
+
def verify(id)
|
|
40
|
+
request_object(:post, "/domains/#{encode(id)}/verify")
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anypost
|
|
4
|
+
module Resources
|
|
5
|
+
# Operations on the `/email` endpoints.
|
|
6
|
+
#
|
|
7
|
+
# Attachment `content` is the raw file bytes (e.g. from `File.binread`); the
|
|
8
|
+
# SDK base64-encodes it for transport. Do not pre-encode it.
|
|
9
|
+
class Email < Base
|
|
10
|
+
# Send a single message.
|
|
11
|
+
#
|
|
12
|
+
# All addresses in `to`/`cc`/`bcc` share one envelope. Returns the queued
|
|
13
|
+
# message id; raises an {Anypost::Error} subclass on failure.
|
|
14
|
+
def send(email, idempotency_key = nil)
|
|
15
|
+
request_object(:post, "/email",
|
|
16
|
+
body: encode_attachments(email), idempotent: true, idempotency_key: idempotency_key)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Send 1-100 independent messages in one request.
|
|
20
|
+
#
|
|
21
|
+
# A mixed-outcome batch (HTTP 207) returns normally — inspect each entry's
|
|
22
|
+
# `status` in `data`; it does not raise.
|
|
23
|
+
def send_batch(batch, idempotency_key = nil)
|
|
24
|
+
body = batch.dup
|
|
25
|
+
body[:defaults] = encode_attachments(batch[:defaults]) if batch[:defaults]
|
|
26
|
+
body[:emails] = Array(batch[:emails]).map { |email| encode_attachments(email) }
|
|
27
|
+
request_object(:post, "/email/batch",
|
|
28
|
+
body: body, idempotent: true, idempotency_key: idempotency_key)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def encode_attachments(message)
|
|
34
|
+
attachments = message[:attachments]
|
|
35
|
+
return message unless attachments.is_a?(Array)
|
|
36
|
+
|
|
37
|
+
message = message.dup
|
|
38
|
+
message[:attachments] = attachments.map do |attachment|
|
|
39
|
+
content = attachment[:content]
|
|
40
|
+
next attachment unless content.is_a?(String)
|
|
41
|
+
|
|
42
|
+
attachment.merge(content: [content].pack("m0"))
|
|
43
|
+
end
|
|
44
|
+
message
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anypost
|
|
4
|
+
module Resources
|
|
5
|
+
# Read access to the `/events` stream. List-only — events are not addressable by id.
|
|
6
|
+
class Events < Base
|
|
7
|
+
# Page through the team's events, newest-first.
|
|
8
|
+
#
|
|
9
|
+
# The window defaults to the last 24 hours and is clamped to the plan's
|
|
10
|
+
# retention. Filter with `start`, `end`, `event_type`, `recipient`,
|
|
11
|
+
# `email_id`, `message_id`, `domain`, `topic`, `campaign`, `template_id`,
|
|
12
|
+
# and `tags` (an array, matched with hasAny).
|
|
13
|
+
def list(params = {})
|
|
14
|
+
tags = params[:tags]
|
|
15
|
+
paginate("/events", {
|
|
16
|
+
limit: params[:limit],
|
|
17
|
+
after: params[:after],
|
|
18
|
+
start: params[:start],
|
|
19
|
+
end: params[:end],
|
|
20
|
+
event_type: params[:event_type],
|
|
21
|
+
recipient: params[:recipient],
|
|
22
|
+
email_id: params[:email_id],
|
|
23
|
+
message_id: params[:message_id],
|
|
24
|
+
domain: params[:domain],
|
|
25
|
+
topic: params[:topic],
|
|
26
|
+
campaign: params[:campaign],
|
|
27
|
+
template_id: params[:template_id],
|
|
28
|
+
# Sent comma-separated (tags=a,b); the API matches with hasAny.
|
|
29
|
+
tags: (tags.is_a?(Array) && !tags.empty?) ? tags.join(",") : nil
|
|
30
|
+
})
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anypost
|
|
4
|
+
module Resources
|
|
5
|
+
# Identity operations (`/whoami`).
|
|
6
|
+
class Identity < Base
|
|
7
|
+
# Identify the team and permission level behind the current API key.
|
|
8
|
+
def whoami
|
|
9
|
+
request_object(:get, "/whoami")
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anypost
|
|
4
|
+
module Resources
|
|
5
|
+
# Operations on the `/suppressions` endpoints. Entries key on `(email, topic)`.
|
|
6
|
+
class Suppressions < Base
|
|
7
|
+
# List the team's suppressions, newest-first. Expired rows are filtered out.
|
|
8
|
+
# Filter with `email_contains`, `topic`, `reason`, and `origin`.
|
|
9
|
+
def list(params = {})
|
|
10
|
+
paginate("/suppressions", {
|
|
11
|
+
limit: params[:limit],
|
|
12
|
+
after: params[:after],
|
|
13
|
+
email_contains: params[:email_contains],
|
|
14
|
+
topic: params[:topic],
|
|
15
|
+
reason: params[:reason],
|
|
16
|
+
origin: params[:origin]
|
|
17
|
+
})
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Add a manual suppression. Defaults to topic `*` (every topic). Raises
|
|
21
|
+
# validation_error if an active entry for the same `(email, topic)` exists.
|
|
22
|
+
def create(params)
|
|
23
|
+
request_object(:post, "/suppressions", body: params)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Retrieve the suppression for an `(email, topic)` pair. Use `*` as the
|
|
27
|
+
# topic for the global row. Raises not_found if the pair isn't suppressed.
|
|
28
|
+
def get(email, topic)
|
|
29
|
+
request_object(:get, "/suppressions/#{encode(email)}/#{encode(topic)}")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Remove the single `(email, topic)` row. Other topics are untouched.
|
|
33
|
+
def delete(email, topic)
|
|
34
|
+
@http.request(:delete, "/suppressions/#{encode(email)}/#{encode(topic)}")
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# List every suppression on file for an address, across all topics. Raises
|
|
39
|
+
# not_found if the address has no active suppressions.
|
|
40
|
+
#
|
|
41
|
+
# @return [Array<Response>]
|
|
42
|
+
def list_for_email(email)
|
|
43
|
+
decoded = @http.request(:get, "/suppressions/#{encode(email)}")
|
|
44
|
+
data = decoded.is_a?(Hash) ? decoded["data"] : nil
|
|
45
|
+
Array(data).map { |row| Response.wrap(row) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Remove an address from the suppression list across every topic.
|
|
49
|
+
def delete_for_email(email)
|
|
50
|
+
@http.request(:delete, "/suppressions/#{encode(email)}")
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anypost
|
|
4
|
+
module Resources
|
|
5
|
+
# Operations on the `/templates` endpoints, including the draft/publish flow.
|
|
6
|
+
class Templates < Base
|
|
7
|
+
# List the team's templates, newest-first.
|
|
8
|
+
def list(params = {})
|
|
9
|
+
paginate("/templates", {limit: params[:limit], after: params[:after]})
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Create a template. It starts unpublished — publish it before sending.
|
|
13
|
+
def create(params)
|
|
14
|
+
request_object(:post, "/templates", body: params)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Retrieve a template, including its published content.
|
|
18
|
+
def get(id)
|
|
19
|
+
request_object(:get, "/templates/#{encode(id)}")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Update a template's `name`. Body content lives on the draft.
|
|
23
|
+
def update(id, params)
|
|
24
|
+
request_object(:patch, "/templates/#{encode(id)}", body: params)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Permanently delete a template.
|
|
28
|
+
def delete(id)
|
|
29
|
+
@http.request(:delete, "/templates/#{encode(id)}")
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Copy a template. The copy starts unpublished with a draft seeded from the
|
|
34
|
+
# source's current editable content, and must be published before sending.
|
|
35
|
+
def duplicate(id, params = {})
|
|
36
|
+
request_object(:post, "/templates/#{encode(id)}/duplicate",
|
|
37
|
+
body: params.empty? ? nil : params)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Retrieve the template's unpublished draft. Raises not_found if none exists.
|
|
41
|
+
def get_draft(id)
|
|
42
|
+
request_object(:get, "/templates/#{encode(id)}/draft")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Create or update the template's draft. Idempotent upsert; published content untouched.
|
|
46
|
+
def update_draft(id, params)
|
|
47
|
+
request_object(:patch, "/templates/#{encode(id)}/draft", body: params)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Discard the template's draft without touching published content.
|
|
51
|
+
def delete_draft(id)
|
|
52
|
+
@http.request(:delete, "/templates/#{encode(id)}/draft")
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Promote the draft into the published slot, consuming the draft.
|
|
57
|
+
def publish(id)
|
|
58
|
+
request_object(:post, "/templates/#{encode(id)}/publish")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anypost
|
|
4
|
+
module Resources
|
|
5
|
+
# Operations on the `/webhooks` endpoints.
|
|
6
|
+
class Webhooks < Base
|
|
7
|
+
# List the team's webhooks, newest-first.
|
|
8
|
+
def list(params = {})
|
|
9
|
+
paginate("/webhooks", {limit: params[:limit], after: params[:after]})
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Create a webhook. The full `signing_secret` is on the response to this
|
|
13
|
+
# call only — store it now to verify future deliveries; later reads return
|
|
14
|
+
# only the prefix.
|
|
15
|
+
def create(params)
|
|
16
|
+
request_object(:post, "/webhooks", body: params)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Retrieve a webhook. The signing secret is never returned — only its prefix.
|
|
20
|
+
def get(id)
|
|
21
|
+
request_object(:get, "/webhooks/#{encode(id)}")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Update a webhook's name, URL, subscribed events, and status. This does not
|
|
25
|
+
# rotate the signing secret — use {#rotate_secret}.
|
|
26
|
+
def update(id, params)
|
|
27
|
+
request_object(:patch, "/webhooks/#{encode(id)}", body: params)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Permanently delete a webhook.
|
|
31
|
+
def delete(id)
|
|
32
|
+
@http.request(:delete, "/webhooks/#{encode(id)}")
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Send one synthetic `webhook.test` event and report the outcome. One-shot,
|
|
37
|
+
# not retried, and absent from delivery history. Returns the result even
|
|
38
|
+
# when the endpoint fails — read `delivered` and `status_code`.
|
|
39
|
+
def test(id)
|
|
40
|
+
request_object(:post, "/webhooks/#{encode(id)}/test")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Rotate the signing secret. The new secret is on this response only. The
|
|
44
|
+
# previous secret stays valid for a 24h grace window. Rotating again before
|
|
45
|
+
# the window ends raises webhook_rotation_in_progress (a {ConflictError}).
|
|
46
|
+
def rotate_secret(id)
|
|
47
|
+
request_object(:post, "/webhooks/#{encode(id)}/rotate-secret")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anypost
|
|
4
|
+
# An immutable view over a decoded JSON response object.
|
|
5
|
+
#
|
|
6
|
+
# Read fields with either method or bracket syntax — both return the same
|
|
7
|
+
# value, and nested objects come back as {Response} instances:
|
|
8
|
+
#
|
|
9
|
+
# email = client.email.send(...)
|
|
10
|
+
# email.id # "email_..."
|
|
11
|
+
# email[:id] # same
|
|
12
|
+
# email["id"] # same
|
|
13
|
+
#
|
|
14
|
+
# Lists of objects come back as plain Ruby arrays whose object elements are
|
|
15
|
+
# themselves {Response} instances. Call {#to_h} for the raw decoded hash.
|
|
16
|
+
class Response
|
|
17
|
+
# Wrap a decoded JSON value, turning object-shaped hashes into responses.
|
|
18
|
+
def self.wrap(value)
|
|
19
|
+
case value
|
|
20
|
+
when Hash then new(value)
|
|
21
|
+
when Array then value.map { |element| wrap(element) }
|
|
22
|
+
else value
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @param attributes [Hash] decoded JSON object (string keys)
|
|
27
|
+
def initialize(attributes)
|
|
28
|
+
@attributes = attributes
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def [](key)
|
|
32
|
+
Response.wrap(@attributes[key.to_s])
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def key?(key)
|
|
36
|
+
@attributes.key?(key.to_s)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The raw decoded response, with no {Response} wrapping at any depth.
|
|
40
|
+
# @return [Hash]
|
|
41
|
+
def to_h
|
|
42
|
+
@attributes
|
|
43
|
+
end
|
|
44
|
+
alias_method :to_hash, :to_h
|
|
45
|
+
|
|
46
|
+
def ==(other)
|
|
47
|
+
other.is_a?(Response) ? to_h == other.to_h : @attributes == other
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def inspect
|
|
51
|
+
"#<Anypost::Response #{@attributes.inspect}>"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def respond_to_missing?(name, include_private = false)
|
|
55
|
+
@attributes.key?(name.to_s) || super
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def method_missing(name, *args)
|
|
59
|
+
key = name.to_s
|
|
60
|
+
if @attributes.key?(key)
|
|
61
|
+
Response.wrap(@attributes[key])
|
|
62
|
+
else
|
|
63
|
+
super
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|