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.
@@ -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
@@ -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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anypost
4
+ # The single source of truth for the gem version. Bump this, tag the commit
5
+ # `vX.Y.Z`, and push — the release workflow builds and pushes to RubyGems.
6
+ VERSION = "1.0.0"
7
+ end