usend 1.3.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 +17 -0
- data/README.md +107 -0
- data/lib/usend/client.rb +51 -0
- data/lib/usend/error.rb +19 -0
- data/lib/usend/http.rb +94 -0
- data/lib/usend/resources.rb +623 -0
- data/lib/usend/smtp.rb +36 -0
- data/lib/usend/version.rb +5 -0
- data/lib/usend/webhook.rb +38 -0
- data/lib/usend.rb +9 -0
- metadata +55 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 5b268a5e6ce690133139c0bbd3825ba7063df55be6ccf0f0a7145423affb134a
|
|
4
|
+
data.tar.gz: f831022189dedb8270399b49791cd55f237233bc065a05dc23ebe3e122ff4e4f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 635efa4c472902029d1f59e52f1d85325a8b5ceda309117bf066e085afd75675bcac385d8404e86f27838595fa55fbbc607eb7db521c9c7ba2e6a05c256c0487
|
|
7
|
+
data.tar.gz: 4ce45b1683cef56f8b892dfd88290d27fee4f893d4c512b2d1dde7e94fe22d4fc483f528350f64dd1d5c160f99a69f5f1a92e80d3156bd943c65d0af9f038bd0
|
data/LICENSE
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 uSend
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
|
|
6
|
+
documentation files (the "Software"), to deal in the Software without restriction, including without limitation
|
|
7
|
+
the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and
|
|
8
|
+
to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of
|
|
11
|
+
the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
|
|
14
|
+
THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
15
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
|
|
16
|
+
CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
|
17
|
+
IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# usend for Ruby
|
|
2
|
+
|
|
3
|
+
The official Ruby SDK for [usend](https://usend.et), the email API for developers. Standard library only
|
|
4
|
+
(`net/http`, `json`), Ruby 3.0 or newer.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
gem install usend
|
|
8
|
+
# or in your Gemfile: gem "usend"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
require "usend"
|
|
15
|
+
|
|
16
|
+
usend = Usend::Client.new(api_key: ENV["USEND_API_KEY"])
|
|
17
|
+
|
|
18
|
+
email = usend.emails.send_email(
|
|
19
|
+
from: "Acme <hello@acme.com>",
|
|
20
|
+
to: "ann@example.com",
|
|
21
|
+
subject: "Your login code",
|
|
22
|
+
html: "<p>Your code is <b>482913</b></p>"
|
|
23
|
+
)
|
|
24
|
+
puts email["id"]
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`emails.send` is an alias of `emails.send_email`. Methods return the API's JSON (Hash or Array with string
|
|
28
|
+
keys) and raise `Usend::Error` on failure.
|
|
29
|
+
|
|
30
|
+
- The key defaults to `USEND_API_KEY`; the base URL to `USEND_BASE_URL` or `https://api.usend.et`.
|
|
31
|
+
- Options: `timeout:` (seconds, default 30), `max_retries:` (default 2).
|
|
32
|
+
- Resources: `emails`, `events` (custom events), `automations`, `logs`, `received` (inbound mail), `inboxes` (shared mailboxes), `domains`, `api_keys`, `templates`,
|
|
33
|
+
`contacts`, `contact_properties`, `contact_imports`, `segments`, `topics`, `audiences`, `broadcasts`, `webhooks`, `suppressions`, `tools`, `oauth`.
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
usend.emails.batch([{ from: "...", to: "a@x.com", subject: "Hi", html: "..." }])
|
|
37
|
+
usend.emails.get("email-id")
|
|
38
|
+
usend.emails.list(limit: 20)
|
|
39
|
+
usend.emails.update("email-id", scheduled_at: "2030-01-01T09:00:00Z")
|
|
40
|
+
usend.emails.cancel("email-id")
|
|
41
|
+
usend.emails.events("email-id")
|
|
42
|
+
usend.domains.create(name: "acme.com")
|
|
43
|
+
usend.domains.verify("domain-id")
|
|
44
|
+
usend.templates.publish("welcome")
|
|
45
|
+
usend.contacts.create(email: "ann@example.com", first_name: "Ann")
|
|
46
|
+
usend.broadcasts.send_broadcast("broadcast-id")
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Pass an idempotency key to make a send safe to repeat:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
usend.emails.send_email({ from: "...", to: "...", subject: "...", html: "..." }, idempotency_key: "order-1042")
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Errors and retries
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
begin
|
|
59
|
+
usend.emails.send_email(from: "nobody@unverified.com", to: "ann@example.com", subject: "Hi", html: "x")
|
|
60
|
+
rescue Usend::Error => e
|
|
61
|
+
e.status_code # 403 (nil for network failures)
|
|
62
|
+
e.name # "validation_error", "rate_limit_exceeded", ...
|
|
63
|
+
e.message
|
|
64
|
+
end
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Requests are retried up to `max_retries` times with backoff on `429` (honoring `Retry-After`), `5xx` and network
|
|
68
|
+
errors. A `POST` is only retried after a `5xx` or network error when it carries an `idempotency_key`, so an email
|
|
69
|
+
is never sent twice by accident.
|
|
70
|
+
|
|
71
|
+
## SMTP
|
|
72
|
+
|
|
73
|
+
Send through SMTP instead; the API key is the password. Port 465 is implicit TLS, 587 is STARTTLS.
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
s = Usend.smtp_settings(api_key: ENV["USEND_API_KEY"]) # smtp.usend.et:465
|
|
77
|
+
s = Usend.smtp_settings(api_key: ENV["USEND_API_KEY"], port: 587) # STARTTLS
|
|
78
|
+
# or usend.smtp_settings(port: 587)
|
|
79
|
+
|
|
80
|
+
# s => { address:, port:, user_name: "usend", password:, authentication: :plain, tls:, enable_starttls_auto: }
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
For a self-hosted platform pass `host:`, set `USEND_SMTP_HOST`, or construct the client with
|
|
84
|
+
`base_url: "https://api.example.com"` (gives `smtp.example.com`).
|
|
85
|
+
|
|
86
|
+
## Webhooks
|
|
87
|
+
|
|
88
|
+
Verify a delivery ([Standard Webhooks](https://www.standardwebhooks.com)) using the **raw** request body:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
event = Usend::Webhook.verify(
|
|
92
|
+
payload: request.raw_post,
|
|
93
|
+
headers: request.headers, # any Hash-like with webhook-id, webhook-timestamp, webhook-signature
|
|
94
|
+
secret: ENV["USEND_WEBHOOK_SECRET"] # whsec_...
|
|
95
|
+
)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
It returns the parsed event, or raises `Usend::Error` (`name: "invalid_webhook"`). The default tolerance is 5
|
|
99
|
+
minutes (`tolerance_seconds:`).
|
|
100
|
+
|
|
101
|
+
## Tests
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
ruby -Ilib -Itest test/usend_test.rb
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Docs: https://usend.et/docs
|
data/lib/usend/client.rb
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Usend
|
|
4
|
+
# Client for the usend API.
|
|
5
|
+
#
|
|
6
|
+
# usend = Usend::Client.new(api_key: ENV["USEND_API_KEY"])
|
|
7
|
+
# usend.emails.send_email(from: "Acme <hi@acme.com>", to: "ann@example.com", subject: "Hi", html: "<p>Hi</p>")
|
|
8
|
+
class Client
|
|
9
|
+
attr_reader :api_key, :base_url, :emails, :events, :automations, :logs, :received, :inboxes, :domains, :api_keys, :templates,
|
|
10
|
+
:contacts, :contact_properties, :contact_imports, :segments, :topics, :short_links, :audiences, :broadcasts, :webhooks, :suppressions, :tools, :oauth
|
|
11
|
+
|
|
12
|
+
# api_key defaults to ENV["USEND_API_KEY"]; base_url to ENV["USEND_BASE_URL"] or https://api.usend.et.
|
|
13
|
+
# max_retries applies to 429 and 5xx responses and network errors (a POST is only retried after
|
|
14
|
+
# a 5xx or network error when it carries an idempotency key).
|
|
15
|
+
def initialize(api_key: nil, base_url: nil, timeout: 30, max_retries: 2)
|
|
16
|
+
@api_key = api_key || ENV["USEND_API_KEY"]
|
|
17
|
+
if @api_key.nil? || @api_key.empty?
|
|
18
|
+
raise Error.new('Missing API key. Pass api_key: or set USEND_API_KEY.', name: "missing_api_key")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
@base_url = (base_url || ENV["USEND_BASE_URL"] || DEFAULT_BASE_URL).sub(%r{/+\z}, "")
|
|
22
|
+
http = HTTP.new(api_key: @api_key, base_url: @base_url, timeout: timeout, max_retries: max_retries)
|
|
23
|
+
@emails = Emails.new(http)
|
|
24
|
+
@events = Events.new(http)
|
|
25
|
+
@logs = Logs.new(http)
|
|
26
|
+
@received = Received.new(http)
|
|
27
|
+
@inboxes = Inboxes.new(http)
|
|
28
|
+
@automations = Automations.new(http)
|
|
29
|
+
@contact_properties = ContactProperties.new(http)
|
|
30
|
+
@contact_imports = ContactImports.new(http)
|
|
31
|
+
@tools = Tools.new(http)
|
|
32
|
+
@oauth = OAuth.new(http)
|
|
33
|
+
@domains = Domains.new(http)
|
|
34
|
+
@api_keys = ApiKeys.new(http)
|
|
35
|
+
@templates = Templates.new(http)
|
|
36
|
+
@contacts = Contacts.new(http)
|
|
37
|
+
@segments = Segments.new(http)
|
|
38
|
+
@topics = Topics.new(http)
|
|
39
|
+
@short_links = ShortLinks.new(http)
|
|
40
|
+
@audiences = Audiences.new(http)
|
|
41
|
+
@broadcasts = Broadcasts.new(http)
|
|
42
|
+
@webhooks = Webhooks.new(http)
|
|
43
|
+
@suppressions = Suppressions.new(http)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# SMTP settings for this key (see Usend.smtp_settings).
|
|
47
|
+
def smtp_settings(port: 465, host: nil)
|
|
48
|
+
Usend.smtp_settings(api_key: @api_key, port: port, host: host, base_url: @base_url)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/usend/error.rb
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Usend
|
|
4
|
+
# An error from the usend API ({ statusCode, name, message }). Network failures have
|
|
5
|
+
# status_code nil and name "application_error".
|
|
6
|
+
class Error < StandardError
|
|
7
|
+
attr_reader :status_code, :name
|
|
8
|
+
|
|
9
|
+
def initialize(message = "usend error", name: "application_error", status_code: nil)
|
|
10
|
+
super(message)
|
|
11
|
+
@name = name
|
|
12
|
+
@status_code = status_code
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def inspect
|
|
16
|
+
"#<Usend::Error status_code=#{status_code.inspect} name=#{name.inspect} message=#{message.inspect}>"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
data/lib/usend/http.rb
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module Usend
|
|
9
|
+
DEFAULT_BASE_URL = "https://api.usend.et"
|
|
10
|
+
|
|
11
|
+
# Minimal HTTP layer: JSON in and out, retries on 429 and 5xx with backoff.
|
|
12
|
+
class HTTP
|
|
13
|
+
RETRY_STATUSES = [429, 500, 502, 503, 504].freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :base_url
|
|
16
|
+
|
|
17
|
+
def initialize(api_key:, base_url:, timeout:, max_retries:)
|
|
18
|
+
@api_key = api_key
|
|
19
|
+
@base_url = base_url
|
|
20
|
+
@timeout = timeout
|
|
21
|
+
@max_retries = max_retries
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Returns the parsed JSON body (Hash/Array), or {} when empty. Raises Usend::Error.
|
|
25
|
+
def request(method, path, body: nil, headers: {}, auth: true)
|
|
26
|
+
uri = URI.parse(@base_url + path)
|
|
27
|
+
attempt = 0
|
|
28
|
+
loop do
|
|
29
|
+
attempt += 1
|
|
30
|
+
begin
|
|
31
|
+
res = perform(uri, method, body, headers, auth)
|
|
32
|
+
rescue SystemCallError, SocketError, IOError, Timeout::Error, OpenSSL::SSL::SSLError, Net::ProtocolError => e
|
|
33
|
+
# A POST without an idempotency key may already have been processed: never repeat it.
|
|
34
|
+
unless retry_safe?(method, headers) && attempt <= @max_retries
|
|
35
|
+
raise Error.new(e.message.to_s.empty? ? e.class.name : e.message)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
sleep(backoff(attempt, nil))
|
|
39
|
+
next
|
|
40
|
+
end
|
|
41
|
+
status = res.code.to_i
|
|
42
|
+
return(res.body.nil? || res.body.empty? ? {} : JSON.parse(res.body)) if status >= 200 && status < 300
|
|
43
|
+
|
|
44
|
+
if RETRY_STATUSES.include?(status) && attempt <= @max_retries && (status == 429 || retry_safe?(method, headers))
|
|
45
|
+
sleep(backoff(attempt, res["retry-after"]))
|
|
46
|
+
next
|
|
47
|
+
end
|
|
48
|
+
raise api_error(res, status)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def perform(uri, method, body, headers, auth)
|
|
55
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
56
|
+
http.use_ssl = uri.scheme == "https"
|
|
57
|
+
http.open_timeout = @timeout
|
|
58
|
+
http.read_timeout = @timeout
|
|
59
|
+
http.write_timeout = @timeout
|
|
60
|
+
req = Net::HTTPGenericRequest.new(method.to_s.upcase, !body.nil?, true, uri.request_uri)
|
|
61
|
+
req["Authorization"] = "Bearer #{@api_key}" if auth
|
|
62
|
+
req["User-Agent"] = "usend-ruby/#{VERSION}"
|
|
63
|
+
req["Accept"] = "application/json"
|
|
64
|
+
headers.each { |k, v| req[k] = v }
|
|
65
|
+
unless body.nil?
|
|
66
|
+
req["Content-Type"] = "application/json"
|
|
67
|
+
req.body = JSON.generate(body)
|
|
68
|
+
end
|
|
69
|
+
http.start { |h| h.request(req) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def retry_safe?(method, headers)
|
|
73
|
+
method.to_s.upcase != "POST" || headers.key?("Idempotency-Key")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def backoff(attempt, retry_after)
|
|
77
|
+
if retry_after && retry_after.to_s.match?(/\A\d+(\.\d+)?\z/)
|
|
78
|
+
[retry_after.to_f, 30.0].min
|
|
79
|
+
else
|
|
80
|
+
[0.5 * (2**(attempt - 1)), 8.0].min
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def api_error(res, status)
|
|
85
|
+
err = begin
|
|
86
|
+
JSON.parse(res.body.to_s)
|
|
87
|
+
rescue JSON::ParserError
|
|
88
|
+
{}
|
|
89
|
+
end
|
|
90
|
+
err = {} unless err.is_a?(Hash)
|
|
91
|
+
Error.new(err["message"] || "HTTP #{status}", name: err["name"] || "application_error", status_code: status)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Usend
|
|
7
|
+
# Base for resource objects. Every method returns the API's parsed JSON (Hash or Array)
|
|
8
|
+
# and raises Usend::Error on failure.
|
|
9
|
+
class Resource
|
|
10
|
+
def initialize(http)
|
|
11
|
+
@http = http
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def req(method, path, body: nil, headers: {}, auth: true)
|
|
17
|
+
@http.request(method, path, body: body, headers: headers, auth: auth)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def esc(value)
|
|
21
|
+
URI.encode_www_form_component(value.to_s).gsub("+", "%20")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def qs(params)
|
|
25
|
+
pairs = (params || {}).reject { |_, v| v.nil? }
|
|
26
|
+
pairs.empty? ? "" : "?#{URI.encode_www_form(pairs)}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def idem(key)
|
|
30
|
+
key ? { "Idempotency-Key" => key } : {}
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Standard create/list/get/update/remove on one path.
|
|
35
|
+
class Crud < Resource
|
|
36
|
+
def create(body)
|
|
37
|
+
req("POST", self.class::PATH, body: body)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def list(params = {})
|
|
41
|
+
req("GET", "#{self.class::PATH}#{qs(params)}")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def get(id)
|
|
45
|
+
req("GET", "#{self.class::PATH}/#{esc(id)}")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def update(id, body)
|
|
49
|
+
req("PATCH", "#{self.class::PATH}/#{esc(id)}", body: body)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def remove(id)
|
|
53
|
+
req("DELETE", "#{self.class::PATH}/#{esc(id)}")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
class Emails < Resource
|
|
58
|
+
# idempotency_key: makes the send safe to retry.
|
|
59
|
+
#
|
|
60
|
+
# Takes the email as a Hash or as keywords: send_email(from: "...", to: "...", subject: "...", html: "...").
|
|
61
|
+
def send_email(email = nil, idempotency_key: nil, **fields)
|
|
62
|
+
email = fields if email.nil?
|
|
63
|
+
req("POST", "/emails", body: email, headers: idem(idempotency_key))
|
|
64
|
+
end
|
|
65
|
+
alias send send_email
|
|
66
|
+
|
|
67
|
+
# validation: "strict" (default) or "permissive".
|
|
68
|
+
def batch(emails, idempotency_key: nil, validation: nil)
|
|
69
|
+
headers = idem(idempotency_key)
|
|
70
|
+
headers["x-batch-validation"] = validation if validation
|
|
71
|
+
req("POST", "/emails/batch", body: emails, headers: headers)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def get(id)
|
|
75
|
+
req("GET", "/emails/#{esc(id)}")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def list(params = {})
|
|
79
|
+
req("GET", "/emails#{qs(params)}")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Reschedules a scheduled email.
|
|
83
|
+
def update(id, scheduled_at:)
|
|
84
|
+
req("PATCH", "/emails/#{esc(id)}", body: { scheduled_at: scheduled_at })
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def cancel(id)
|
|
88
|
+
req("POST", "/emails/#{esc(id)}/cancel")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def events(id)
|
|
92
|
+
req("GET", "/emails/#{esc(id)}/events")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def attachments(id)
|
|
96
|
+
req("GET", "/emails/#{esc(id)}/attachments")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def attachment(id, attachment_id)
|
|
100
|
+
req("GET", "/emails/#{esc(id)}/attachments/#{esc(attachment_id)}")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Aggregate sending metrics (start_date, end_date, metrics, dimensions, granularity, ...).
|
|
104
|
+
def metrics(params = {})
|
|
105
|
+
req("GET", "/emails/metrics#{qs(params)}")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Creates a public read-only link; expires_in like "10m", "2 hours", "1 day" (max 48h).
|
|
109
|
+
def share(id, expires_in: nil)
|
|
110
|
+
req("POST", "/emails/#{esc(id)}/share", body: expires_in ? { expires_in: expires_in } : {})
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Sends the content to the signed-in user's own address (dashboard sessions and OAuth tokens).
|
|
114
|
+
def test(body)
|
|
115
|
+
req("POST", "/emails/test", body: body)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Deliverability findings for the stored message.
|
|
119
|
+
def insights(id)
|
|
120
|
+
req("GET", "/emails/#{esc(id)}/insights")
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Custom events that trigger automations (not email delivery events: see Emails#events).
|
|
125
|
+
class Events < Crud
|
|
126
|
+
PATH = "/events"
|
|
127
|
+
|
|
128
|
+
# Fires an event for a contact: { event:, contact_id: | email:, payload: }. The API answers 202.
|
|
129
|
+
def send_event(body)
|
|
130
|
+
req("POST", "/events/send", body: body)
|
|
131
|
+
end
|
|
132
|
+
alias send send_event
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
class Automations < Crud
|
|
136
|
+
PATH = "/automations"
|
|
137
|
+
|
|
138
|
+
def duplicate(id)
|
|
139
|
+
req("POST", "/automations/#{esc(id)}/duplicate")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def stop(id)
|
|
143
|
+
req("POST", "/automations/#{esc(id)}/stop")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def runs(id, params = {})
|
|
147
|
+
req("GET", "/automations/#{esc(id)}/runs#{qs(params)}")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def run(id, run_id)
|
|
151
|
+
req("GET", "/automations/#{esc(id)}/runs/#{esc(run_id)}")
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
class ContactProperties < Crud
|
|
156
|
+
PATH = "/contact-properties"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Bulk contact imports (POST /contacts/imports) and their progress.
|
|
160
|
+
class ContactImports < Resource
|
|
161
|
+
def create(body)
|
|
162
|
+
req("POST", "/contacts/imports", body: body)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def list(params = {})
|
|
166
|
+
req("GET", "/contacts/imports#{qs(params)}")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def get(id)
|
|
170
|
+
req("GET", "/contacts/imports/#{esc(id)}")
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Deliverability tools.
|
|
175
|
+
class Tools < Resource
|
|
176
|
+
def dmarc_check(domain)
|
|
177
|
+
req("POST", "/tools/dmarc-check", body: { domain: domain })
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# API request logs: GET /logs.
|
|
182
|
+
class Logs < Resource
|
|
183
|
+
def list(params = {})
|
|
184
|
+
req("GET", "/logs#{qs(params)}")
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def get(id)
|
|
188
|
+
req("GET", "/logs/#{esc(id)}")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def user_agents
|
|
192
|
+
req("GET", "/logs/user-agents")
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Received (inbound) emails.
|
|
197
|
+
class Received < Resource
|
|
198
|
+
def list(params = {})
|
|
199
|
+
req("GET", "/inbound/emails#{qs(params)}")
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def get(id)
|
|
203
|
+
req("GET", "/inbound/emails/#{esc(id)}")
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def raw(id)
|
|
207
|
+
req("GET", "/inbound/emails/#{esc(id)}/raw")
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def attachment(id, attachment_id)
|
|
211
|
+
req("GET", "/inbound/emails/#{esc(id)}/attachments/#{esc(attachment_id)}")
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Shared mailboxes with threads, labels, drafts and replies (full-access keys only).
|
|
216
|
+
class Inboxes < Crud
|
|
217
|
+
PATH = "/inboxes"
|
|
218
|
+
|
|
219
|
+
def create_label(inbox_id, body)
|
|
220
|
+
req("POST", "/inboxes/#{esc(inbox_id)}/labels", body: body)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def labels(inbox_id)
|
|
224
|
+
req("GET", "/inboxes/#{esc(inbox_id)}/labels")
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def label(inbox_id, label_id)
|
|
228
|
+
req("GET", "/inboxes/#{esc(inbox_id)}/labels/#{esc(label_id)}")
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def update_label(inbox_id, label_id, body)
|
|
232
|
+
req("PATCH", "/inboxes/#{esc(inbox_id)}/labels/#{esc(label_id)}", body: body)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def remove_label(inbox_id, label_id)
|
|
236
|
+
req("DELETE", "/inboxes/#{esc(inbox_id)}/labels/#{esc(label_id)}")
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# params: folder, label, assignee, unread, q, limit, after, before
|
|
240
|
+
def threads(inbox_id, params = {})
|
|
241
|
+
req("GET", "/inboxes/#{esc(inbox_id)}/threads#{qs(params)}")
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def thread(inbox_id, thread_id)
|
|
245
|
+
req("GET", "/inboxes/#{esc(inbox_id)}/threads/#{esc(thread_id)}")
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# body: folder, labels, assignee_user_id, read
|
|
249
|
+
def update_thread(inbox_id, thread_id, body)
|
|
250
|
+
req("PATCH", "/inboxes/#{esc(inbox_id)}/threads/#{esc(thread_id)}", body: body)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def remove_thread(inbox_id, thread_id)
|
|
254
|
+
req("DELETE", "/inboxes/#{esc(inbox_id)}/threads/#{esc(thread_id)}")
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def thread_emails(inbox_id, thread_id, params = {})
|
|
258
|
+
req("GET", "/inboxes/#{esc(inbox_id)}/threads/#{esc(thread_id)}/emails#{qs(params)}")
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def thread_email(inbox_id, thread_id, email_id)
|
|
262
|
+
req("GET", "/inboxes/#{esc(inbox_id)}/threads/#{esc(thread_id)}/emails/#{esc(email_id)}")
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def reply(inbox_id, thread_id, email_id, body)
|
|
266
|
+
req("POST", "/inboxes/#{esc(inbox_id)}/threads/#{esc(thread_id)}/emails/#{esc(email_id)}/reply", body: body)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def forward(inbox_id, thread_id, email_id, body)
|
|
270
|
+
req("POST", "/inboxes/#{esc(inbox_id)}/threads/#{esc(thread_id)}/emails/#{esc(email_id)}/forward", body: body)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def create_draft(inbox_id, body)
|
|
274
|
+
req("POST", "/inboxes/#{esc(inbox_id)}/drafts", body: body)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def drafts(inbox_id, params = {})
|
|
278
|
+
req("GET", "/inboxes/#{esc(inbox_id)}/drafts#{qs(params)}")
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def draft(inbox_id, draft_id)
|
|
282
|
+
req("GET", "/inboxes/#{esc(inbox_id)}/drafts/#{esc(draft_id)}")
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def update_draft(inbox_id, draft_id, body)
|
|
286
|
+
req("PATCH", "/inboxes/#{esc(inbox_id)}/drafts/#{esc(draft_id)}", body: body)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def remove_draft(inbox_id, draft_id)
|
|
290
|
+
req("DELETE", "/inboxes/#{esc(inbox_id)}/drafts/#{esc(draft_id)}")
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def send_draft(inbox_id, draft_id)
|
|
294
|
+
req("POST", "/inboxes/#{esc(inbox_id)}/drafts/#{esc(draft_id)}/send")
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
class Domains < Crud
|
|
299
|
+
PATH = "/domains"
|
|
300
|
+
|
|
301
|
+
# Domain claim. body: { name: }
|
|
302
|
+
def create_claim(body)
|
|
303
|
+
req("POST", "/domains/claim", body: body)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def claim(id)
|
|
307
|
+
req("GET", "/domains/claim/#{esc(id)}")
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def verify_claim(id)
|
|
311
|
+
req("POST", "/domains/claim/#{esc(id)}/verify")
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def insights(id)
|
|
315
|
+
req("GET", "/domains/#{esc(id)}/insights")
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def verify(id)
|
|
319
|
+
req("POST", "/domains/#{esc(id)}/verify")
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def auto_configure(id, api_token:, provider: "cloudflare")
|
|
323
|
+
req("POST", "/domains/#{esc(id)}/auto-configure", body: { provider: provider, api_token: api_token })
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
class ApiKeys < Resource
|
|
328
|
+
def create(body)
|
|
329
|
+
req("POST", "/api-keys", body: body)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def update(id, body)
|
|
333
|
+
req("PATCH", "/api-keys/#{esc(id)}", body: body)
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def list
|
|
337
|
+
req("GET", "/api-keys")
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def remove(id)
|
|
341
|
+
req("DELETE", "/api-keys/#{esc(id)}")
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
class Templates < Crud
|
|
346
|
+
PATH = "/templates"
|
|
347
|
+
|
|
348
|
+
def publish(id_or_alias)
|
|
349
|
+
req("POST", "/templates/#{esc(id_or_alias)}/publish")
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def duplicate(id_or_alias)
|
|
353
|
+
req("POST", "/templates/#{esc(id_or_alias)}/duplicate")
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def versions(id_or_alias)
|
|
357
|
+
req("GET", "/templates/#{esc(id_or_alias)}/versions")
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def version(id_or_alias, version_id)
|
|
361
|
+
req("GET", "/templates/#{esc(id_or_alias)}/versions/#{esc(version_id)}")
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def restore_version(id_or_alias, version_id)
|
|
365
|
+
req("POST", "/templates/#{esc(id_or_alias)}/versions/#{esc(version_id)}/restore")
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
class Contacts < Crud
|
|
370
|
+
PATH = "/contacts"
|
|
371
|
+
|
|
372
|
+
def import(csv)
|
|
373
|
+
req("POST", "/contacts/import", body: { csv: csv })
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def stats
|
|
377
|
+
req("GET", "/contacts/stats")
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def segments(id_or_email)
|
|
381
|
+
req("GET", "/contacts/#{esc(id_or_email)}/segments")
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def add_to_segment(id_or_email, segment_id)
|
|
385
|
+
req("POST", "/contacts/#{esc(id_or_email)}/segments/#{esc(segment_id)}")
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def remove_from_segment(id_or_email, segment_id)
|
|
389
|
+
req("DELETE", "/contacts/#{esc(id_or_email)}/segments/#{esc(segment_id)}")
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def topics(id_or_email)
|
|
393
|
+
req("GET", "/contacts/#{esc(id_or_email)}/topics")
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def update_topics(id_or_email, body)
|
|
397
|
+
req("PATCH", "/contacts/#{esc(id_or_email)}/topics", body: body)
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
class Segments < Resource
|
|
402
|
+
def create(body)
|
|
403
|
+
req("POST", "/segments", body: body)
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def list
|
|
407
|
+
req("GET", "/segments")
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def get(id)
|
|
411
|
+
req("GET", "/segments/#{esc(id)}")
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def update(id, body)
|
|
415
|
+
req("PATCH", "/segments/#{esc(id)}", body: body)
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def remove(id)
|
|
419
|
+
req("DELETE", "/segments/#{esc(id)}")
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# params: limit, after, before, subscription ("subscribed" | "unsubscribed")
|
|
423
|
+
def contacts(id, params = {})
|
|
424
|
+
req("GET", "/segments/#{esc(id)}/contacts#{qs(params)}")
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def metrics(id)
|
|
428
|
+
req("GET", "/segments/#{esc(id)}/metrics")
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
class Topics < Crud
|
|
433
|
+
PATH = "/topics"
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Short links on the shared tracking host that redirect to a target URL and count clicks.
|
|
437
|
+
#
|
|
438
|
+
# usend.short_links.create(target_url: "https://acme.com/sale", code: "sale")
|
|
439
|
+
#
|
|
440
|
+
# update(id, ...) takes target_url:, title:, disabled:, expires_at: (nil clears the expiry).
|
|
441
|
+
class ShortLinks < Crud
|
|
442
|
+
PATH = "/short-links"
|
|
443
|
+
|
|
444
|
+
# Click statistics for the last `days` days (1 to 365, default 30).
|
|
445
|
+
def stats(id, days: nil)
|
|
446
|
+
req("GET", "/short-links/#{esc(id)}/stats#{qs(days: days)}")
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# Audiences (v1 name for segments) and their contacts.
|
|
451
|
+
class Audiences < Resource
|
|
452
|
+
def create(body)
|
|
453
|
+
req("POST", "/audiences", body: body)
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def list
|
|
457
|
+
req("GET", "/audiences")
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def get(id)
|
|
461
|
+
req("GET", "/audiences/#{esc(id)}")
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def remove(id)
|
|
465
|
+
req("DELETE", "/audiences/#{esc(id)}")
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def create_contact(audience_id, body)
|
|
469
|
+
req("POST", "/audiences/#{esc(audience_id)}/contacts", body: body)
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def list_contacts(audience_id, params = {})
|
|
473
|
+
req("GET", "/audiences/#{esc(audience_id)}/contacts#{qs(params)}")
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def get_contact(audience_id, id_or_email)
|
|
477
|
+
req("GET", "/audiences/#{esc(audience_id)}/contacts/#{esc(id_or_email)}")
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def update_contact(audience_id, id_or_email, body)
|
|
481
|
+
req("PATCH", "/audiences/#{esc(audience_id)}/contacts/#{esc(id_or_email)}", body: body)
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def remove_contact(audience_id, id_or_email)
|
|
485
|
+
req("DELETE", "/audiences/#{esc(audience_id)}/contacts/#{esc(id_or_email)}")
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def import_contacts(audience_id, csv)
|
|
489
|
+
req("POST", "/audiences/#{esc(audience_id)}/contacts/import", body: { csv: csv })
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
class Broadcasts < Crud
|
|
494
|
+
PATH = "/broadcasts"
|
|
495
|
+
|
|
496
|
+
def send_broadcast(id, scheduled_at: nil)
|
|
497
|
+
req("POST", "/broadcasts/#{esc(id)}/send", body: scheduled_at ? { scheduled_at: scheduled_at } : {})
|
|
498
|
+
end
|
|
499
|
+
alias send send_broadcast
|
|
500
|
+
|
|
501
|
+
def test(id, to)
|
|
502
|
+
req("POST", "/broadcasts/#{esc(id)}/test", body: { to: to })
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
def cancel(id)
|
|
506
|
+
req("POST", "/broadcasts/#{esc(id)}/cancel")
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
def duplicate(id)
|
|
510
|
+
req("POST", "/broadcasts/#{esc(id)}/duplicate")
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# type is required: sent, delivered, opened, clicked, bounced, complained, unsubscribed or suppressed.
|
|
514
|
+
# Rows are { id, contact_id, email, count?, bounce_type?, clicked_links? }. Other params: limit, after,
|
|
515
|
+
# before, email, bounce_type.
|
|
516
|
+
def recipients(id, type:, **params)
|
|
517
|
+
req("GET", "/broadcasts/#{esc(id)}/recipients#{qs(params.merge(type: type))}")
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
def clicked_links(id)
|
|
521
|
+
req("GET", "/broadcasts/#{esc(id)}/clicked-links")
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
def performance(id)
|
|
525
|
+
req("GET", "/broadcasts/#{esc(id)}/performance")
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
class Webhooks < Crud
|
|
530
|
+
PATH = "/webhooks"
|
|
531
|
+
|
|
532
|
+
def deliveries(id, params = {})
|
|
533
|
+
req("GET", "/webhooks/#{esc(id)}/deliveries#{qs(params)}")
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
def replay(id, delivery_id)
|
|
537
|
+
req("POST", "/webhooks/#{esc(id)}/deliveries/#{esc(delivery_id)}/replay")
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
# Returns the new signing secret; the old one stops signing at once.
|
|
541
|
+
def rotate_secret(id)
|
|
542
|
+
req("POST", "/webhooks/#{esc(id)}/rotate-secret")
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
# Webhook events across endpoints.
|
|
546
|
+
def events(params = {})
|
|
547
|
+
req("GET", "/webhooks/events#{qs(params)}")
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def event(event_id)
|
|
551
|
+
req("GET", "/webhooks/events/#{esc(event_id)}")
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
def replay_event(event_id)
|
|
555
|
+
req("POST", "/webhooks/events/#{esc(event_id)}/replay")
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
def event_attempts(event_id)
|
|
559
|
+
req("GET", "/webhooks/events/#{esc(event_id)}/attempts")
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
# See Usend::Webhook.verify.
|
|
563
|
+
def verify(**opts)
|
|
564
|
+
Usend::Webhook.verify(**opts)
|
|
565
|
+
end
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
class Suppressions < Resource
|
|
569
|
+
def list(params = {})
|
|
570
|
+
req("GET", "/suppressions#{qs(params)}")
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def create(body)
|
|
574
|
+
req("POST", "/suppressions", body: body)
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
def get(id_or_email)
|
|
578
|
+
req("GET", "/suppressions/#{esc(id_or_email)}")
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
# emails: up to 100 addresses.
|
|
582
|
+
def batch_create(emails)
|
|
583
|
+
req("POST", "/suppressions/batch", body: { emails: emails })
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def batch_remove(emails)
|
|
587
|
+
req("DELETE", "/suppressions/batch", body: { emails: emails })
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
def remove(id)
|
|
591
|
+
req("DELETE", "/suppressions/#{esc(id)}")
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
# OAuth 2.0 for apps acting on a customer's org (PKCE required). These endpoints take no API key.
|
|
596
|
+
class OAuth < Resource
|
|
597
|
+
def metadata
|
|
598
|
+
req("GET", "/.well-known/oauth-authorization-server", auth: false)
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# { client_name:, redirect_uris: [...] }
|
|
602
|
+
def register(body)
|
|
603
|
+
req("POST", "/oauth/register", body: body, auth: false)
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
# grant_type "authorization_code" (client_id, code, code_verifier, redirect_uri)
|
|
607
|
+
# or "refresh_token" (client_id, refresh_token)
|
|
608
|
+
def token(body)
|
|
609
|
+
req("POST", "/oauth/token", body: body, auth: false)
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
def revoke(token, client_id: nil)
|
|
613
|
+
req("POST", "/oauth/revoke", body: { token: token, client_id: client_id }.compact, auth: false)
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
# The URL to send the user's browser to.
|
|
617
|
+
def authorize_url(client_id:, redirect_uri:, scope:, code_challenge:, state: nil)
|
|
618
|
+
q = { response_type: "code", client_id: client_id, redirect_uri: redirect_uri, scope: scope,
|
|
619
|
+
code_challenge: code_challenge, code_challenge_method: "S256", state: state }.compact
|
|
620
|
+
"#{@http.base_url}/oauth/authorize?#{URI.encode_www_form(q)}"
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
end
|
data/lib/usend/smtp.rb
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Usend
|
|
6
|
+
DEFAULT_SMTP_HOST = "smtp.usend.et"
|
|
7
|
+
|
|
8
|
+
# SMTP settings for sending through usend; the API key is the password. Port 465 is implicit
|
|
9
|
+
# TLS (tls: true), 587 is STARTTLS (enable_starttls_auto: true). The host is smtp.usend.et; for a
|
|
10
|
+
# self-hosted platform pass host:, set USEND_SMTP_HOST, or pass base_url: ("https://api.x.com"
|
|
11
|
+
# gives "smtp.x.com"). The keys match ActionMailer's smtp_settings.
|
|
12
|
+
def self.smtp_settings(api_key: nil, port: 465, host: nil, base_url: nil)
|
|
13
|
+
key = api_key || ENV["USEND_API_KEY"]
|
|
14
|
+
raise Error.new("Missing API key. Pass api_key: or set USEND_API_KEY.", name: "missing_api_key") if key.nil? || key.empty?
|
|
15
|
+
raise Error.new("usend accepts SMTP on port 465 (TLS) or 587 (STARTTLS).", name: "invalid_port") unless [465, 587].include?(port)
|
|
16
|
+
|
|
17
|
+
{
|
|
18
|
+
address: host || ENV["USEND_SMTP_HOST"] || smtp_host_for(base_url),
|
|
19
|
+
port: port,
|
|
20
|
+
user_name: "usend",
|
|
21
|
+
password: key,
|
|
22
|
+
authentication: :plain,
|
|
23
|
+
tls: port == 465,
|
|
24
|
+
enable_starttls_auto: port == 587
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.smtp_host_for(base_url)
|
|
29
|
+
return DEFAULT_SMTP_HOST if base_url.nil? || base_url.empty?
|
|
30
|
+
|
|
31
|
+
hostname = URI.parse(base_url).host.to_s
|
|
32
|
+
hostname.start_with?("api.") ? "smtp.#{hostname[4..]}" : DEFAULT_SMTP_HOST
|
|
33
|
+
rescue URI::InvalidURIError
|
|
34
|
+
DEFAULT_SMTP_HOST
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "json"
|
|
5
|
+
require "openssl"
|
|
6
|
+
|
|
7
|
+
module Usend
|
|
8
|
+
# Standard Webhooks verification.
|
|
9
|
+
module Webhook
|
|
10
|
+
# Verifies a delivery and returns the parsed event (a Hash); raises Usend::Error if invalid.
|
|
11
|
+
# payload must be the raw request body exactly as received; headers is a Hash (any key case;
|
|
12
|
+
# Rack-style "HTTP_WEBHOOK_ID" keys also work); secret is the endpoint's whsec_... secret.
|
|
13
|
+
def self.verify(payload:, headers:, secret:, tolerance_seconds: 300)
|
|
14
|
+
h = headers.each_with_object({}) { |(k, v), m| m[k.to_s.downcase.sub(/\Ahttp_/, "").tr("_", "-")] = v }
|
|
15
|
+
id = h["webhook-id"]
|
|
16
|
+
ts = h["webhook-timestamp"]
|
|
17
|
+
sigs = h["webhook-signature"]
|
|
18
|
+
raise invalid("Missing webhook-id, webhook-timestamp or webhook-signature header") unless id && ts && sigs
|
|
19
|
+
raise invalid("Invalid webhook timestamp") unless ts.to_s.match?(/\A\d+\z/)
|
|
20
|
+
raise invalid("Webhook timestamp is too old or too new") if (Time.now.to_i - ts.to_i).abs > tolerance_seconds
|
|
21
|
+
|
|
22
|
+
key = Base64.decode64(secret.to_s.sub(/\Awhsec_/, ""))
|
|
23
|
+
expected = Base64.strict_encode64(OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{ts}.#{payload}"))
|
|
24
|
+
ok = sigs.to_s.split(" ").any? do |part|
|
|
25
|
+
version, sig = part.split(",", 2)
|
|
26
|
+
version == "v1" && sig && sig.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(sig, expected)
|
|
27
|
+
end
|
|
28
|
+
raise invalid("Invalid webhook signature") unless ok
|
|
29
|
+
|
|
30
|
+
JSON.parse(payload)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.invalid(msg)
|
|
34
|
+
Error.new(msg, name: "invalid_webhook")
|
|
35
|
+
end
|
|
36
|
+
private_class_method :invalid
|
|
37
|
+
end
|
|
38
|
+
end
|
data/lib/usend.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: usend
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.3.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- usend
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-10-23 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: Send email, manage domains, templates, contacts, broadcasts and webhooks
|
|
14
|
+
with usend. Standard library only.
|
|
15
|
+
email:
|
|
16
|
+
executables: []
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- LICENSE
|
|
21
|
+
- README.md
|
|
22
|
+
- lib/usend.rb
|
|
23
|
+
- lib/usend/client.rb
|
|
24
|
+
- lib/usend/error.rb
|
|
25
|
+
- lib/usend/http.rb
|
|
26
|
+
- lib/usend/resources.rb
|
|
27
|
+
- lib/usend/smtp.rb
|
|
28
|
+
- lib/usend/version.rb
|
|
29
|
+
- lib/usend/webhook.rb
|
|
30
|
+
homepage: https://usend.et
|
|
31
|
+
licenses:
|
|
32
|
+
- MIT
|
|
33
|
+
metadata:
|
|
34
|
+
source_code_uri: https://github.com/usend/usend-ruby
|
|
35
|
+
documentation_uri: https://usend.et/docs
|
|
36
|
+
post_install_message:
|
|
37
|
+
rdoc_options: []
|
|
38
|
+
require_paths:
|
|
39
|
+
- lib
|
|
40
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
41
|
+
requirements:
|
|
42
|
+
- - ">="
|
|
43
|
+
- !ruby/object:Gem::Version
|
|
44
|
+
version: '3.0'
|
|
45
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
46
|
+
requirements:
|
|
47
|
+
- - ">="
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '0'
|
|
50
|
+
requirements: []
|
|
51
|
+
rubygems_version: 3.5.22
|
|
52
|
+
signing_key:
|
|
53
|
+
specification_version: 4
|
|
54
|
+
summary: Official Ruby SDK for usend, the email API for developers
|
|
55
|
+
test_files: []
|