agdirlabs-email 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.
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgdirLabs
4
+ module Email
5
+ # Immutable response objects. All are frozen, keyword-initialized Structs
6
+ # (`Data` needs Ruby 3.2; this gem supports 3.1), so they support `==`, `to_h`
7
+ # and pattern matching (`in {status: "delivered"}`).
8
+ module Model
9
+ # Defines a frozen keyword Struct.
10
+ # @api private
11
+ def self.define(*fields, &block)
12
+ Struct.new(*fields, keyword_init: true) do
13
+ define_method(:initialize) do |**attributes|
14
+ super(**attributes)
15
+ freeze
16
+ end
17
+ class_eval(&block) if block
18
+ end
19
+ end
20
+ end
21
+
22
+ # Result of {Resources::Emails#send}.
23
+ # @!attribute [r] id
24
+ # @return [String]
25
+ SendResult = Model.define(:id)
26
+
27
+ # Result of {Resources::Emails#send_batch}.
28
+ # @!attribute [r] data
29
+ # @return [Array<SendResult>] accepted emails, in input order
30
+ # @!attribute [r] errors
31
+ # @return [Array<BatchError>] only with `validation: :permissive`: emails that were not accepted
32
+ BatchResult = Model.define(:data, :errors)
33
+
34
+ # An email rejected by a permissive batch.
35
+ # @!attribute [r] index
36
+ # @return [Integer] position in the input array
37
+ # @!attribute [r] message
38
+ # @return [String]
39
+ BatchError = Model.define(:index, :message)
40
+
41
+ # Result of operations that only echo the object's id (cancel, verify, update, replay, delete).
42
+ # @!attribute [r] id
43
+ # @return [String]
44
+ # @!attribute [r] deleted
45
+ # @return [Boolean] true for delete operations
46
+ Result = Model.define(:id, :deleted) do
47
+ def deleted? = deleted == true
48
+ end
49
+
50
+ # An email and its delivery status, from {Resources::Emails#get}.
51
+ # @!attribute [r] status
52
+ # @return [String] most advanced state so far: queued, scheduled, sent, delivery_delayed,
53
+ # delivered, opened, clicked, bounced, failed, complained or canceled
54
+ # @!attribute [r] created_at
55
+ # @return [Time]
56
+ # @!attribute [r] scheduled_at
57
+ # @return [Time, nil]
58
+ EmailDetails = Model.define(:id, :from, :to, :cc, :bcc, :reply_to, :subject, :html, :text,
59
+ :created_at, :scheduled_at, :status)
60
+
61
+ # A DNS record to publish for a domain.
62
+ # @!attribute [r] purpose
63
+ # @return [String] what the record is for: DKIM, SPF or DMARC
64
+ # @!attribute [r] name
65
+ # @return [String] relative to the registrable domain, e.g. `agdir._domainkey` or `send`
66
+ # @!attribute [r] type
67
+ # @return [String] TXT, MX or CNAME
68
+ # @!attribute [r] priority
69
+ # @return [Integer, nil] MX records only
70
+ DnsRecord = Model.define(:purpose, :name, :type, :value, :ttl, :priority, :status)
71
+
72
+ # A sending domain.
73
+ # @!attribute [r] status
74
+ # @return [String] not_started, pending, verified, failed or temporary_failure
75
+ # @!attribute [r] records
76
+ # @return [Array<DnsRecord>, nil] DNS records; nil on items of {Resources::Domains#list}
77
+ Domain = Model.define(:id, :name, :status, :region, :created_at, :open_tracking, :click_tracking, :records) do
78
+ def verified? = status == "verified"
79
+ end
80
+
81
+ # A webhook endpoint.
82
+ # @!attribute [r] status
83
+ # @return [String] enabled or disabled
84
+ # @!attribute [r] events
85
+ # @return [Array<String>] subscribed event types, e.g. `email.delivered`
86
+ # @!attribute [r] signing_secret
87
+ # @return [String, nil] `whsec_…`; only from {Resources::Webhooks#get}
88
+ WebhookEndpoint = Model.define(:id, :endpoint, :status, :events, :created_at, :signing_secret) do
89
+ def enabled? = status == "enabled"
90
+ end
91
+
92
+ # A webhook id with its (new) signing secret, from webhook create and rotate.
93
+ WebhookSecret = Model.define(:id, :signing_secret)
94
+
95
+ # A message sent (or queued) to a webhook endpoint.
96
+ # @!attribute [r] status
97
+ # @return [String] pending, attempting, success or failed
98
+ # @!attribute [r] next_attempt_at
99
+ # @return [Time, nil] only from {Resources::WebhookDeliveries#get}
100
+ # @!attribute [r] payload
101
+ # @return [Hash, nil] the exact JSON body sent; only from {Resources::WebhookDeliveries#get}
102
+ WebhookDelivery = Model.define(:id, :type, :status, :created_at, :next_attempt_at, :payload)
103
+
104
+ # One HTTP attempt of a webhook delivery.
105
+ # @!attribute [r] http_status
106
+ # @return [Integer] 0 when no HTTP response was received
107
+ WebhookAttempt = Model.define(:id, :http_status, :response, :sent_at)
108
+
109
+ # An event received on your webhook endpoint, returned by {Webhook.verify}.
110
+ # @!attribute [r] type
111
+ # @return [String] e.g. `email.delivered`
112
+ # @!attribute [r] data
113
+ # @return [WebhookEventData]
114
+ WebhookEvent = Model.define(:type, :created_at, :data)
115
+
116
+ # The email an event is about.
117
+ # @!attribute [r] tags
118
+ # @return [Hash{String => String}]
119
+ # @!attribute [r] bounce
120
+ # @return [Bounce, nil] `email.bounced` only
121
+ # @!attribute [r] click
122
+ # @return [Click, nil] `email.clicked` only
123
+ # @!attribute [r] failed
124
+ # @return [Failure, nil] `email.failed` only
125
+ WebhookEventData = Model.define(:email_id, :message_id, :from, :to, :subject, :tags, :created_at,
126
+ :bounce, :click, :failed)
127
+
128
+ # Bounce details of an `email.bounced` event.
129
+ Bounce = Model.define(:type, :sub_type, :message)
130
+ # Click details of an `email.clicked` event.
131
+ Click = Model.define(:link, :ip_address, :user_agent, :timestamp)
132
+ # Failure details of an `email.failed` event.
133
+ Failure = Model.define(:reason)
134
+
135
+ # One page of a list endpoint. Enumerable over {#data}.
136
+ # @!attribute [r] data
137
+ # @return [Array]
138
+ # @!attribute [r] has_more
139
+ # @return [Boolean]
140
+ # @!attribute [r] next_cursor
141
+ # @return [String, nil] pass as `after:` to fetch the next page; nil on the last page
142
+ class Page
143
+ include Enumerable
144
+
145
+ attr_reader :data, :has_more, :next_cursor
146
+
147
+ def initialize(data:, has_more:, next_cursor:)
148
+ @data = data.freeze
149
+ @has_more = has_more
150
+ @next_cursor = next_cursor
151
+ freeze
152
+ end
153
+
154
+ def has_more? = has_more
155
+ def each(&) = data.each(&)
156
+ def size = data.size
157
+ alias length size
158
+ def empty? = data.empty?
159
+ def to_h = { data: data, has_more: has_more, next_cursor: next_cursor }
160
+
161
+ def ==(other)
162
+ other.is_a?(Page) && other.to_h == to_h
163
+ end
164
+ alias eql? ==
165
+ def hash = to_h.hash
166
+
167
+ def deconstruct_keys(_keys) = to_h
168
+
169
+ def inspect = "#<#{self.class.name} size=#{size} has_more=#{has_more} next_cursor=#{next_cursor.inspect}>"
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgdirLabs
4
+ module Email
5
+ # Registers the `:agdir_email` ActionMailer delivery method, configured with
6
+ # `config.action_mailer.agdir_email_settings = { api_key: … }`.
7
+ class Railtie < ::Rails::Railtie
8
+ # Must run before ActionMailer copies `config.action_mailer.*` onto ActionMailer::Base,
9
+ # so that `agdir_email_settings=` exists by then.
10
+ initializer "agdirlabs_email.add_delivery_method", before: "action_mailer.set_configs" do
11
+ ActiveSupport.on_load(:action_mailer) do
12
+ add_delivery_method :agdir_email, AgdirLabs::Email::DeliveryMethod
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgdirLabs
4
+ module Email
5
+ module Resources
6
+ # `client.domains`
7
+ class Domains
8
+ # @api private
9
+ def initialize(transport)
10
+ @transport = transport
11
+ end
12
+
13
+ # Adds a sending domain. Publish the returned `records` in DNS, then call {#verify}.
14
+ # @param name [String] e.g. `"acme.com"` or `"mail.acme.com"`
15
+ # @param region [String, nil]
16
+ # @return [Domain] with `records`
17
+ # @raise [Error]
18
+ def create(name:, region: nil)
19
+ Wire.domain(@transport.request("POST", "/domains", body: { name: name, region: region }.compact,
20
+ retryable: false))
21
+ end
22
+
23
+ # @param id [String]
24
+ # @return [Domain] with `records`
25
+ # @raise [Error]
26
+ def get(id)
27
+ Wire.domain(@transport.request("GET", "/domains/#{Resources.segment(id)}", retryable: true))
28
+ end
29
+
30
+ # One page of domains.
31
+ # @param limit [Integer, nil] 1–100
32
+ # @param after [String, nil] cursor: {Page#next_cursor} of the previous page
33
+ # @return [Page<Domain>] items without `records`
34
+ # @raise [Error]
35
+ def list(limit: nil, after: nil)
36
+ res = @transport.request("GET", "/domains", query: { limit: limit, after: after }, retryable: true)
37
+ Wire.page(res) { |d| Wire.domain(d.except("records")) }
38
+ end
39
+
40
+ # Every domain, fetching further pages lazily as you iterate.
41
+ # @return [Enumerator<Domain>]
42
+ def list_all(limit: nil, after: nil)
43
+ Resources.paginate(limit: limit, after: after) { |l, a| list(limit: l, after: a) }
44
+ end
45
+
46
+ # Starts a DNS check now instead of waiting for the next automatic one.
47
+ # @param id [String]
48
+ # @return [Result]
49
+ # @raise [Error]
50
+ def verify(id)
51
+ res = @transport.request("POST", "/domains/#{Resources.segment(id)}/verify", retryable: true)
52
+ Result.new(id: Wire.str(res["id"]), deleted: false)
53
+ end
54
+
55
+ # @param id [String]
56
+ # @return [Result] with `deleted: true`
57
+ # @raise [Error]
58
+ def delete(id)
59
+ res = @transport.request("DELETE", "/domains/#{Resources.segment(id)}", retryable: false)
60
+ Result.new(id: Wire.str(res["id"]), deleted: true)
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgdirLabs
4
+ module Email
5
+ # API resources, reached through a {Client}.
6
+ module Resources
7
+ # Percent-encodes one path segment.
8
+ # @api private
9
+ def self.segment(value)
10
+ value.to_s.b.gsub(/[^A-Za-z0-9\-._~]/n) { |c| format("%%%02X", c.ord) }
11
+ end
12
+
13
+ # Walks every page of a list endpoint lazily.
14
+ # @api private
15
+ def self.paginate(limit:, after:)
16
+ Enumerator.new do |yielder|
17
+ cursor = after
18
+ loop do
19
+ page = yield(limit, cursor)
20
+ page.each { |item| yielder << item }
21
+ break unless page.next_cursor
22
+
23
+ cursor = page.next_cursor
24
+ end
25
+ end
26
+ end
27
+
28
+ # `client.emails`
29
+ class Emails
30
+ # Most emails one {#send_batch} call accepts.
31
+ MAX_BATCH_SIZE = 100
32
+
33
+ # @api private
34
+ def initialize(transport)
35
+ @transport = transport
36
+ end
37
+
38
+ # Accepts one email for delivery (or for `scheduled_at`).
39
+ #
40
+ # Note: this shadows `Object#send` on this object only; use `public_send`/`__send__` for dynamic dispatch.
41
+ #
42
+ # @param from [String] `"Name <addr@example.com>"` or a bare address
43
+ # @param to [String, Array<String>]
44
+ # @param subject [String]
45
+ # @param html [String, nil] at least one of `html` and `text` is required
46
+ # @param text [String, nil]
47
+ # @param cc [String, Array<String>, nil]
48
+ # @param bcc [String, Array<String>, nil]
49
+ # @param reply_to [String, Array<String>, nil]
50
+ # @param headers [Hash{String => String}, nil] custom headers, e.g. `{"X-Entity-Ref-ID" => "42"}`
51
+ # @param tags [Array<Hash>, Hash, nil] `[{name: "kind", value: "welcome"}]` or `{kind: "welcome"}`;
52
+ # ASCII letters, digits, `_` and `-` only. Echoed back in webhook events.
53
+ # @param scheduled_at [Time, String, nil] deliver later (a Time, or ISO 8601 with offset)
54
+ # @param idempotency_key [String, nil] the same key with the same body returns the original result;
55
+ # generated automatically when retries are enabled
56
+ # @return [SendResult]
57
+ # @raise [Error]
58
+ def send(idempotency_key: nil, **email)
59
+ key = idempotency_key(idempotency_key)
60
+ res = @transport.request("POST", "/emails", body: Wire.send_email_body(email),
61
+ headers: { "Idempotency-Key" => key }, retryable: !key.nil?)
62
+ SendResult.new(id: Wire.str(res["id"]))
63
+ end
64
+
65
+ # Accepts up to 100 emails in one request.
66
+ #
67
+ # @param emails [Array<Hash>] each with the keyword arguments of {#send} (minus `idempotency_key`)
68
+ # @param validation [Symbol, String] `:strict` (default): one invalid email rejects the whole batch;
69
+ # `:permissive`: valid emails are accepted and invalid ones are reported in {BatchResult#errors}
70
+ # @param idempotency_key [String, nil]
71
+ # @return [BatchResult]
72
+ # @raise [Error]
73
+ def send_batch(emails, validation: :strict, idempotency_key: nil)
74
+ mode = validation.to_s
75
+ raise ArgumentError, "validation must be :strict or :permissive" unless %w[strict permissive].include?(mode)
76
+
77
+ key = idempotency_key(idempotency_key)
78
+ res = @transport.request(
79
+ "POST", "/emails/batch",
80
+ body: emails.map { |e| Wire.send_email_body(e) },
81
+ headers: { "Idempotency-Key" => key, "x-batch-validation" => mode == "permissive" ? mode : nil },
82
+ retryable: !key.nil?
83
+ )
84
+ BatchResult.new(
85
+ data: (res["data"] || []).map { |d| SendResult.new(id: Wire.str(d["id"])) }.freeze,
86
+ errors: (res["errors"] || []).map { |e| BatchError.new(index: e["index"], message: Wire.str(e["message"])) }
87
+ .freeze
88
+ )
89
+ end
90
+
91
+ # Reads an email and its current delivery status. Requires a full-access key.
92
+ # @param id [String]
93
+ # @return [EmailDetails]
94
+ # @raise [Error]
95
+ def get(id)
96
+ Wire.email(@transport.request("GET", "/emails/#{Resources.segment(id)}", retryable: true))
97
+ end
98
+
99
+ # Cancels a scheduled email that has not been sent yet.
100
+ # @param id [String]
101
+ # @return [Result]
102
+ # @raise [Error]
103
+ def cancel(id)
104
+ res = @transport.request("POST", "/emails/#{Resources.segment(id)}/cancel", retryable: true)
105
+ Result.new(id: Wire.str(res["id"]), deleted: false)
106
+ end
107
+
108
+ private
109
+
110
+ def idempotency_key(given)
111
+ return given.to_s unless given.nil? || given.to_s.empty?
112
+
113
+ # Without a key a retried send could be delivered twice, so only generate one when we may retry.
114
+ @transport.max_retries.positive? ? SecureRandom.uuid : nil
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgdirLabs
4
+ module Email
5
+ module Resources
6
+ # `client.webhooks.deliveries`: messages sent (or queued) to one webhook endpoint, with their HTTP attempts.
7
+ class WebhookDeliveries
8
+ # @api private
9
+ def initialize(transport)
10
+ @transport = transport
11
+ end
12
+
13
+ # @param webhook_id [String]
14
+ # @param limit [Integer, nil] 1–100
15
+ # @param after [String, nil]
16
+ # @return [Page<WebhookDelivery>]
17
+ # @raise [Error]
18
+ def list(webhook_id, limit: nil, after: nil)
19
+ res = @transport.request("GET", "/webhooks/#{Resources.segment(webhook_id)}/events",
20
+ query: { limit: limit, after: after }, retryable: true)
21
+ Wire.page(res) { |e| Wire.delivery(e) }
22
+ end
23
+
24
+ # @return [Enumerator<WebhookDelivery>]
25
+ def list_all(webhook_id, limit: nil, after: nil)
26
+ Resources.paginate(limit: limit, after: after) { |l, a| list(webhook_id, limit: l, after: a) }
27
+ end
28
+
29
+ # One delivery with its `payload` and `next_attempt_at`.
30
+ # @return [WebhookDelivery]
31
+ # @raise [Error]
32
+ def get(webhook_id, delivery_id)
33
+ Wire.delivery(@transport.request("GET", path(webhook_id, delivery_id), retryable: true))
34
+ end
35
+
36
+ # HTTP attempts for one delivery, newest first.
37
+ # @return [Page<WebhookAttempt>]
38
+ # @raise [Error]
39
+ def attempts(webhook_id, delivery_id, limit: nil, after: nil)
40
+ res = @transport.request("GET", "#{path(webhook_id, delivery_id)}/attempts",
41
+ query: { limit: limit, after: after }, retryable: true)
42
+ Wire.page(res) { |a| Wire.attempt(a) }
43
+ end
44
+
45
+ # Sends the identical message again, once, right now (also to disabled endpoints).
46
+ # @return [Result]
47
+ # @raise [Error]
48
+ def replay(webhook_id, delivery_id)
49
+ res = @transport.request("POST", "#{path(webhook_id, delivery_id)}/replay", retryable: false)
50
+ Result.new(id: Wire.str(res["id"]), deleted: false)
51
+ end
52
+
53
+ private
54
+
55
+ def path(webhook_id, delivery_id)
56
+ "/webhooks/#{Resources.segment(webhook_id)}/events/#{Resources.segment(delivery_id)}"
57
+ end
58
+ end
59
+
60
+ # `client.webhooks`
61
+ class Webhooks
62
+ # Event types a webhook can subscribe to.
63
+ EVENT_TYPES = %w[
64
+ email.sent email.scheduled email.delivered email.delivery_delayed email.complained
65
+ email.bounced email.opened email.clicked email.failed
66
+ ].freeze
67
+
68
+ # @return [WebhookDeliveries]
69
+ attr_reader :deliveries
70
+
71
+ # @api private
72
+ def initialize(transport)
73
+ @transport = transport
74
+ @deliveries = WebhookDeliveries.new(transport)
75
+ end
76
+
77
+ # Registers an endpoint. Keep the returned `signing_secret` to verify incoming events.
78
+ # @param endpoint [String] public `https://` URL
79
+ # @param events [Array<String>] see {EVENT_TYPES}
80
+ # @return [WebhookSecret]
81
+ # @raise [Error]
82
+ def create(endpoint:, events:)
83
+ res = @transport.request("POST", "/webhooks", body: { endpoint: endpoint, events: Array(events) },
84
+ retryable: false)
85
+ WebhookSecret.new(id: Wire.str(res["id"]), signing_secret: Wire.str(res["signing_secret"]))
86
+ end
87
+
88
+ # @return [WebhookEndpoint] including `signing_secret`
89
+ # @raise [Error]
90
+ def get(id)
91
+ Wire.webhook(@transport.request("GET", "/webhooks/#{Resources.segment(id)}", retryable: true))
92
+ end
93
+
94
+ # @return [Page<WebhookEndpoint>]
95
+ # @raise [Error]
96
+ def list(limit: nil, after: nil)
97
+ res = @transport.request("GET", "/webhooks", query: { limit: limit, after: after }, retryable: true)
98
+ Wire.page(res) { |w| Wire.webhook(w) }
99
+ end
100
+
101
+ # @return [Enumerator<WebhookEndpoint>]
102
+ def list_all(limit: nil, after: nil)
103
+ Resources.paginate(limit: limit, after: after) { |l, a| list(limit: l, after: a) }
104
+ end
105
+
106
+ # @param endpoint [String, nil]
107
+ # @param events [Array<String>, nil]
108
+ # @param status [String, Symbol, nil] `enabled` or `disabled`
109
+ # @return [Result]
110
+ # @raise [Error]
111
+ def update(id, endpoint: nil, events: nil, status: nil)
112
+ body = { endpoint: endpoint, events: events && Array(events), status: status&.to_s }.compact
113
+ res = @transport.request("PATCH", "/webhooks/#{Resources.segment(id)}", body: body, retryable: true)
114
+ Result.new(id: Wire.str(res["id"]), deleted: false)
115
+ end
116
+
117
+ # @return [Result] with `deleted: true`
118
+ # @raise [Error]
119
+ def delete(id)
120
+ res = @transport.request("DELETE", "/webhooks/#{Resources.segment(id)}", retryable: false)
121
+ Result.new(id: Wire.str(res["id"]), deleted: true)
122
+ end
123
+
124
+ # Issues a new signing secret; the old one stops working immediately.
125
+ # @return [WebhookSecret]
126
+ # @raise [Error]
127
+ def rotate_secret(id)
128
+ res = @transport.request("POST", "/webhooks/#{Resources.segment(id)}/signing-secret/rotate",
129
+ retryable: false)
130
+ WebhookSecret.new(id: Wire.str(res["id"]), signing_secret: Wire.str(res["signing_secret"]))
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgdirLabs
4
+ module Email
5
+ # A raw HTTP response, as returned by an HTTP client.
6
+ # @!attribute [r] status
7
+ # @return [Integer]
8
+ # @!attribute [r] headers
9
+ # @return [Hash{String => String}] lower-case header names
10
+ # @!attribute [r] body
11
+ # @return [String]
12
+ HttpResponse = Model.define(:status, :headers, :body)
13
+
14
+ # The default HTTP client: Net::HTTP, one connection per request, honouring the
15
+ # standard `https_proxy` / `http_proxy` / `no_proxy` environment variables.
16
+ #
17
+ # A custom client is any object with the same `call` signature (see {Client#initialize}).
18
+ class NetHttpClient
19
+ # @param method [String] GET, POST, PATCH or DELETE
20
+ # @param url [URI::HTTP]
21
+ # @param headers [Hash{String => String}]
22
+ # @param body [String, nil]
23
+ # @param timeout [Numeric] seconds, applied to connect, write and read
24
+ # @return [HttpResponse]
25
+ def call(method:, url:, headers:, body:, timeout:)
26
+ http = Net::HTTP.new(url.host, url.port)
27
+ http.use_ssl = url.scheme == "https"
28
+ http.open_timeout = timeout
29
+ http.read_timeout = timeout
30
+ http.write_timeout = timeout
31
+ request = Net::HTTPGenericRequest.new(method, !body.nil?, true, url.request_uri, headers)
32
+ request.body = body if body
33
+ response = http.start { |conn| conn.request(request) }
34
+ HttpResponse.new(
35
+ status: response.code.to_i,
36
+ headers: response.each_header.to_h { |k, v| [k.downcase, v] },
37
+ body: response.body.to_s
38
+ )
39
+ end
40
+ end
41
+
42
+ # Sends API requests, retrying rate limits, server errors and network failures when safe.
43
+ # @api private
44
+ class Transport
45
+ RETRY_STATUSES = [408, 500, 502, 503, 504].freeze
46
+ TIMEOUT_ERRORS = [Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, ::Timeout::Error].freeze
47
+ NETWORK_ERRORS = [SocketError, SystemCallError, IOError, EOFError, OpenSSL::SSL::SSLError,
48
+ Net::HTTPBadResponse, Net::ProtocolError].freeze
49
+
50
+ attr_reader :base_url, :max_retries, :timeout
51
+
52
+ def initialize(api_key:, base_url:, max_retries:, timeout:, http_client:)
53
+ @api_key = api_key
54
+ @base_url = base_url
55
+ @max_retries = max_retries
56
+ @timeout = timeout
57
+ @http_client = http_client
58
+ end
59
+
60
+ # @param retryable [Boolean] safe to repeat: GETs, and POSTs that carry an idempotency key
61
+ # @return [Hash] the parsed JSON response
62
+ def request(method, path, retryable:, query: nil, body: nil, headers: {})
63
+ url = build_url(path, query)
64
+ request_headers = {
65
+ "Authorization" => "Bearer #{@api_key}",
66
+ "User-Agent" => USER_AGENT,
67
+ "Accept" => "application/json"
68
+ }
69
+ request_headers["Content-Type"] = "application/json" unless body.nil?
70
+ headers.each { |k, v| request_headers[k] = v.to_s unless v.nil? }
71
+ payload = body.nil? ? nil : JSON.generate(body)
72
+
73
+ attempt = 0
74
+ loop do
75
+ can_retry = attempt < @max_retries
76
+ begin
77
+ response = @http_client.call(method: method, url: url, headers: request_headers, body: payload,
78
+ timeout: @timeout)
79
+ rescue *TIMEOUT_ERRORS, *NETWORK_ERRORS => e
80
+ # A timed-out POST may have been accepted: only retry it when it is idempotent.
81
+ if can_retry && retryable
82
+ pause(backoff(attempt))
83
+ attempt += 1
84
+ next
85
+ end
86
+ raise network_error(e)
87
+ end
88
+
89
+ return parse(response.body) if response.status.between?(200, 299)
90
+
91
+ error = to_error(response)
92
+ # Rate limits are refused before any work is done, so they are always safe to retry.
93
+ retry_it = can_retry && (error.code == "rate_limit_exceeded" ||
94
+ (retryable && RETRY_STATUSES.include?(response.status)))
95
+ raise error unless retry_it
96
+
97
+ pause(error.retry_after || backoff(attempt))
98
+ attempt += 1
99
+ end
100
+ end
101
+
102
+ private
103
+
104
+ def build_url(path, query)
105
+ url = URI.parse(@base_url.sub(%r{/+\z}, "") + path)
106
+ params = (query || {}).compact
107
+ url.query = URI.encode_www_form(params) unless params.empty?
108
+ url
109
+ end
110
+
111
+ def parse(body)
112
+ return {} if body.nil? || body.strip.empty?
113
+
114
+ JSON.parse(body)
115
+ end
116
+
117
+ def network_error(cause)
118
+ if TIMEOUT_ERRORS.any? { |k| cause.is_a?(k) }
119
+ TimeoutError.new("Request timed out after #{@timeout} s", code: "timeout")
120
+ else
121
+ ConnectionError.new("Could not reach the Agdir Email API (#{cause.class}: #{cause.message})",
122
+ code: "network_error")
123
+ end
124
+ end
125
+
126
+ def to_error(response)
127
+ json = begin
128
+ parsed = JSON.parse(response.body.to_s)
129
+ parsed.is_a?(Hash) ? parsed : {}
130
+ rescue JSON::ParserError
131
+ {} # Not JSON (e.g. a proxy error page) — fall back to the status line.
132
+ end
133
+ message = json["message"].is_a?(String) ? json["message"] : "Request failed with HTTP #{response.status}"
134
+ code = json["name"].is_a?(String) ? json["name"] : default_code(response.status)
135
+ Error.build(message, code: code, status: response.status,
136
+ retry_after: retry_after(response.headers["retry-after"]))
137
+ end
138
+
139
+ def default_code(status) = status >= 500 ? "internal_server_error" : "unknown_error"
140
+
141
+ def retry_after(raw)
142
+ return nil if raw.nil? || raw.strip.empty?
143
+
144
+ seconds = Float(raw, exception: false)
145
+ return [seconds, 0.0].max if seconds
146
+
147
+ [Time.httpdate(raw) - Time.now, 0.0].max
148
+ rescue ArgumentError
149
+ nil
150
+ end
151
+
152
+ # Exponential backoff with jitter: ~0.5 s, 1 s, 2 s … capped at 8 s.
153
+ def backoff(attempt)
154
+ base = [8.0, 0.5 * (2**attempt)].min
155
+ (base / 2) + (rand * (base / 2))
156
+ end
157
+
158
+ def pause(seconds) = sleep(seconds)
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AgdirLabs
4
+ module Email
5
+ VERSION = "0.1.0"
6
+
7
+ # Sent as the `User-Agent` header.
8
+ USER_AGENT = "agdirlabs-email-ruby/#{VERSION}".freeze
9
+ end
10
+ end