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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE +21 -0
- data/README.md +284 -0
- data/lib/agdirlabs/email/client.rb +65 -0
- data/lib/agdirlabs/email/delivery_method.rb +143 -0
- data/lib/agdirlabs/email/errors.rb +88 -0
- data/lib/agdirlabs/email/models.rb +172 -0
- data/lib/agdirlabs/email/railtie.rb +17 -0
- data/lib/agdirlabs/email/resources/domains.rb +65 -0
- data/lib/agdirlabs/email/resources/emails.rb +119 -0
- data/lib/agdirlabs/email/resources/webhooks.rb +135 -0
- data/lib/agdirlabs/email/transport.rb +161 -0
- data/lib/agdirlabs/email/version.rb +10 -0
- data/lib/agdirlabs/email/webhook.rb +92 -0
- data/lib/agdirlabs/email/wire.rb +164 -0
- data/lib/agdirlabs/email.rb +30 -0
- data/sig/agdirlabs/email.rbs +318 -0
- metadata +64 -0
|
@@ -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
|