sendlayer 1.0.0 → 1.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 +4 -4
- data/.github/workflows/publish.yml +11 -2
- data/CHANGELOG.md +69 -0
- data/Gemfile.lock +1 -1
- data/README.md +76 -16
- data/lib/sendlayer/client.rb +153 -65
- data/lib/sendlayer/emails.rb +15 -11
- data/lib/sendlayer/exceptions.rb +27 -9
- data/lib/sendlayer/version.rb +1 -1
- data/lib/sendlayer/webhooks.rb +4 -4
- data/lib/sendlayer.rb +9 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1cc77f877b3aa02ffaed910ed8c3aa638e5bffb1cbbfc7079ad1081a5ab24a6a
|
|
4
|
+
data.tar.gz: ea8244c28c69a02113564abdf64dbae9628bd2e5ee64b4ca9d405721787c1859
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f204b10315d62fc5144119634c027eaac56c04be9cac92a850631ff67ea83dffe7c1d905bf4cf3d2b38f3ddfcbe56f0b610bbbf99a6ad83edda3577fd8c9b934
|
|
7
|
+
data.tar.gz: 1f4039edd2ade871eb665c10d0a4332e785ced3d92ce180220c83e337f0290316ce2c7229e97926fb95fb6e6e26b5036615b23c41ac446c59405ee3a329a30d5
|
|
@@ -4,6 +4,13 @@ on:
|
|
|
4
4
|
push:
|
|
5
5
|
branches:
|
|
6
6
|
- main
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
inputs:
|
|
9
|
+
skip_rubygems:
|
|
10
|
+
description: 'Skip RubyGems publishing'
|
|
11
|
+
required: false
|
|
12
|
+
default: 'true'
|
|
13
|
+
type: boolean
|
|
7
14
|
|
|
8
15
|
permissions:
|
|
9
16
|
contents: write
|
|
@@ -34,6 +41,7 @@ jobs:
|
|
|
34
41
|
publish:
|
|
35
42
|
needs: test
|
|
36
43
|
runs-on: ubuntu-latest
|
|
44
|
+
if: ${{ !inputs.skip_rubygems || github.event_name == 'push' }}
|
|
37
45
|
|
|
38
46
|
permissions:
|
|
39
47
|
contents: write
|
|
@@ -54,7 +62,8 @@ jobs:
|
|
|
54
62
|
uses: rubygems/release-gem@v1
|
|
55
63
|
|
|
56
64
|
github-release:
|
|
57
|
-
needs: publish
|
|
65
|
+
needs: [test, publish]
|
|
66
|
+
if: always() && (needs.publish.result == 'skipped' || needs.publish.result == 'success')
|
|
58
67
|
runs-on: ubuntu-latest
|
|
59
68
|
steps:
|
|
60
69
|
- name: Checkout code
|
|
@@ -75,7 +84,7 @@ jobs:
|
|
|
75
84
|
- name: Get version from VERSION file
|
|
76
85
|
id: get_version
|
|
77
86
|
run: |
|
|
78
|
-
VERSION=$(
|
|
87
|
+
VERSION=$(ruby -e "require './lib/sendlayer/version'; puts SendLayer::VERSION.strip")
|
|
79
88
|
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
|
80
89
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
|
81
90
|
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
## [1.1.0](https://github.com/sendlayer/sendlayer-ruby/compare/v1.0.0...v1.1.0) (2026-08-25)
|
|
6
|
+
|
|
7
|
+
Brings the Ruby SDK to parity with the PHP, Python and Node.js 1.1.0 releases.
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* Typed exceptions are no longer flattened to the base class. `make_request`
|
|
12
|
+
called `handle_response` *inside* its own `begin`/`rescue`, so the
|
|
13
|
+
`SendLayerAuthenticationError` (and every other typed error) raised there was
|
|
14
|
+
caught by the broad `rescue => e` and re-raised as
|
|
15
|
+
`SendLayerError.new("Network error: ...")`. Rescuing
|
|
16
|
+
`SendLayerAuthenticationError` could never match. Only the transport call is
|
|
17
|
+
guarded now.
|
|
18
|
+
* `Emails#send` no longer discards the plain-text body when both `text` and
|
|
19
|
+
`html` are supplied. The payload used an `if`/`else` that could only ever emit
|
|
20
|
+
one of the two fields. Both `HTMLContent` and `PlainContent` are now sent, and
|
|
21
|
+
`ContentType` is reported as `HTML` whenever an HTML body is present.
|
|
22
|
+
* API error messages are no longer discarded. The client read
|
|
23
|
+
`error_data['Error']`, but SendLayer returns errors as
|
|
24
|
+
`{"Errors": [{"Code": ..., "Message": ...}]}`, so every error surfaced a
|
|
25
|
+
hardcoded default instead of the API's own text. The 401 branch did not read
|
|
26
|
+
the response body at all. Multiple messages are joined with `; `.
|
|
27
|
+
* Non-JSON error bodies no longer raise `JSON::ParserError` out of the SDK. An
|
|
28
|
+
HTML error page from a proxy now falls back to the HTTP reason phrase.
|
|
29
|
+
* Undecodable *success* bodies now raise `SendLayerError` instead of
|
|
30
|
+
`JSON::ParserError`, which is not a `SendLayerError` and so escaped callers
|
|
31
|
+
rescuing the base type.
|
|
32
|
+
* Connections now time out. `read_timeout` was set but `open_timeout` was not,
|
|
33
|
+
so a connection that never completed its handshake could hang past the
|
|
34
|
+
configured timeout.
|
|
35
|
+
* Content consisting of an empty string is now rejected by validation, which
|
|
36
|
+
previously checked only for `nil` and would send an email with no body.
|
|
37
|
+
|
|
38
|
+
### Features
|
|
39
|
+
|
|
40
|
+
* Every exception now carries `status_code`, `response` and `errors`, along with
|
|
41
|
+
a `codes` helper for branching on SendLayer's numeric error codes. These
|
|
42
|
+
previously existed only on `SendLayerAPIError`, so reading them after
|
|
43
|
+
rescuing `SendLayerError` raised `NoMethodError` for every other type.
|
|
44
|
+
* Added a `:headers` option for extra request headers. They are applied before
|
|
45
|
+
`Authorization`, so a caller cannot accidentally drop authentication.
|
|
46
|
+
* `Client::ERROR_MAP` replaces the hand-rolled `case` chain, so status-to-error
|
|
47
|
+
mapping is declared in one place.
|
|
48
|
+
|
|
49
|
+
### Documentation
|
|
50
|
+
|
|
51
|
+
* Documented the exception attributes, the full exception table, the
|
|
52
|
+
`SendLayerAPIError` message prefix, and the 30-second default timeout.
|
|
53
|
+
* Added a Configuration section and an explicit HTML-with-plain-text-fallback
|
|
54
|
+
example.
|
|
55
|
+
|
|
56
|
+
### Breaking Changes
|
|
57
|
+
|
|
58
|
+
* `500` maps to `SendLayerInternalServerError`, but other 5xx statuses
|
|
59
|
+
(502, 503, 504) now raise `SendLayerAPIError` rather than
|
|
60
|
+
`SendLayerInternalServerError`. This matches the Python and Node.js SDKs.
|
|
61
|
+
Rescue `SendLayerError` to catch both.
|
|
62
|
+
* Passing `text: ''` or `html: ''` as the only content now raises
|
|
63
|
+
`SendLayerValidationError`.
|
|
64
|
+
* `SendLayerError#message` is no longer backed by an `attr_reader`. It is
|
|
65
|
+
inherited from `StandardError`, so `e.message` behaves as before.
|
|
66
|
+
|
|
67
|
+
## 1.0.0
|
|
68
|
+
|
|
69
|
+
* Initial public release.
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
The official Ruby SDK for interacting with the SendLayer API, providing a simple and intuitive interface for sending emails, managing webhooks, and retrieving email events.
|
|
12
12
|
|
|
13
|
-
[](./LICENSE)
|
|
13
|
+
[](./LICENSE) [](https://github.com/SendLayer/sendlayer-ruby/actions/workflows/publish.yml)
|
|
14
14
|
|
|
15
15
|
## Installation
|
|
16
16
|
|
|
@@ -83,6 +83,17 @@ response = sendlayer.emails.send(
|
|
|
83
83
|
html: '<h1>Welcome!</h1><p>Welcome to our platform!</p>'
|
|
84
84
|
)
|
|
85
85
|
|
|
86
|
+
# HTML with a plain-text fallback -- supply both and both parts are sent.
|
|
87
|
+
# ContentType is reported as HTML, and clients that cannot render HTML fall
|
|
88
|
+
# back to the plain-text part.
|
|
89
|
+
response = sendlayer.emails.send(
|
|
90
|
+
from: 'sender@example.com',
|
|
91
|
+
to: 'recipient@example.com',
|
|
92
|
+
subject: 'Welcome!',
|
|
93
|
+
html: '<h1>Welcome!</h1><p>Welcome to our platform!</p>',
|
|
94
|
+
text: 'Welcome! Welcome to our platform!'
|
|
95
|
+
)
|
|
96
|
+
|
|
86
97
|
# Complex email with multiple recipients and attachments
|
|
87
98
|
response = sendlayer.emails.send(
|
|
88
99
|
from: { email: 'sender@example.com', name: 'Sender' },
|
|
@@ -153,7 +164,16 @@ sendlayer.webhooks.delete(123)
|
|
|
153
164
|
|
|
154
165
|
## Error Handling
|
|
155
166
|
|
|
156
|
-
|
|
167
|
+
Every SendLayer exception carries the same attributes, so you can read them
|
|
168
|
+
without first checking which subclass you rescued:
|
|
169
|
+
|
|
170
|
+
| Attribute | Description |
|
|
171
|
+
|---|---|
|
|
172
|
+
| `message` | Human-readable message, taken from the API's own error text when available |
|
|
173
|
+
| `status_code` | HTTP status of the response, or `nil` for local errors (timeouts, connection failures, validation) |
|
|
174
|
+
| `response` | Decoded response body, or `{}` when unavailable |
|
|
175
|
+
| `errors` | Raw SendLayer `Errors` entries, each with the API's numeric `Code` and `Message`; empty for local errors |
|
|
176
|
+
| `codes` | Just the numeric codes from `errors`, for branching |
|
|
157
177
|
|
|
158
178
|
```ruby
|
|
159
179
|
require 'sendlayer'
|
|
@@ -165,28 +185,68 @@ begin
|
|
|
165
185
|
subject: 'Test Email',
|
|
166
186
|
text: 'This is a test email'
|
|
167
187
|
)
|
|
168
|
-
rescue SendLayer::
|
|
169
|
-
puts "API
|
|
188
|
+
rescue SendLayer::SendLayerAuthenticationError => e
|
|
189
|
+
puts "Check your API key: #{e.message}"
|
|
170
190
|
rescue SendLayer::SendLayerValidationError => e
|
|
171
191
|
puts "Validation error: #{e.message}"
|
|
172
|
-
rescue SendLayer::
|
|
173
|
-
puts "
|
|
192
|
+
rescue SendLayer::SendLayerRateLimitError => e
|
|
193
|
+
puts "Slow down: #{e.message}"
|
|
194
|
+
rescue SendLayer::SendLayerError => e
|
|
195
|
+
# Base type -- also catches timeouts, connection errors and any API error
|
|
196
|
+
# without a more specific type.
|
|
197
|
+
puts "SendLayer error: #{e.message} (status: #{e.status_code.inspect})"
|
|
198
|
+
puts "Codes: #{e.codes.inspect}"
|
|
199
|
+
end
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Branch on SendLayer's numeric error codes with `codes`:
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
174
205
|
rescue SendLayer::SendLayerError => e
|
|
175
|
-
puts
|
|
176
|
-
rescue => e
|
|
177
|
-
puts "Unexpected error: #{e.message}"
|
|
206
|
+
puts 'That sender domain is not authorised.' if e.codes.include?(14)
|
|
178
207
|
end
|
|
179
208
|
```
|
|
180
209
|
|
|
210
|
+
See the [error code reference](https://developers.sendlayer.com/api-reference/error-codes)
|
|
211
|
+
for the full list.
|
|
212
|
+
|
|
181
213
|
## Exception Types
|
|
182
214
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
215
|
+
| Exception | Raised for |
|
|
216
|
+
|---|---|
|
|
217
|
+
| `SendLayer::SendLayerError` | Base type for everything below, and for local errors: timeouts, connection failures and undecodable responses |
|
|
218
|
+
| `SendLayer::SendLayerValidationError` | Invalid parameters (raised locally), and HTTP 400 / 422 |
|
|
219
|
+
| `SendLayer::SendLayerAuthenticationError` | HTTP 401 -- invalid API key |
|
|
220
|
+
| `SendLayer::SendLayerNotFoundError` | HTTP 404 |
|
|
221
|
+
| `SendLayer::SendLayerRateLimitError` | HTTP 429 |
|
|
222
|
+
| `SendLayer::SendLayerInternalServerError` | HTTP 500 |
|
|
223
|
+
| `SendLayer::SendLayerAPIError` | Any other error status, including 5xx other than 500 |
|
|
224
|
+
|
|
225
|
+
`SendLayerAPIError` is the only type whose `message` is prefixed -- it reads
|
|
226
|
+
`API Error <status>: <message>`. Every other type carries the API's message
|
|
227
|
+
unchanged.
|
|
228
|
+
|
|
229
|
+
Requests time out after 30 seconds by default and raise `SendLayerError`. A
|
|
230
|
+
`Net::HTTP` exception is never surfaced to the caller.
|
|
231
|
+
|
|
232
|
+
## Configuration
|
|
233
|
+
|
|
234
|
+
Pass an options hash as the second argument:
|
|
235
|
+
|
|
236
|
+
```ruby
|
|
237
|
+
sendlayer = SendLayer::SendLayer.new('your-api-key', {
|
|
238
|
+
timeout: 60, # seconds to wait for the API (default 30)
|
|
239
|
+
attachment_url_timeout: 45_000, # ms to wait when fetching a remote attachment (default 30000)
|
|
240
|
+
headers: { 'X-Request-Id' => 'abc123' } # extra headers sent with every request
|
|
241
|
+
})
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
| Option | Default | Description |
|
|
245
|
+
|---|---|---|
|
|
246
|
+
| `:timeout` | `30` | Seconds to wait for the API before raising `SendLayerError` |
|
|
247
|
+
| `:attachment_url_timeout` | `30000` | Milliseconds to wait when downloading an attachment from a URL |
|
|
248
|
+
| `:headers` | `{}` | Extra request headers. Cannot override `Authorization` |
|
|
249
|
+
| `:base_url` | SendLayer API v1 | Override the API base URL |
|
|
190
250
|
|
|
191
251
|
## Supported Events
|
|
192
252
|
|
data/lib/sendlayer/client.rb
CHANGED
|
@@ -3,96 +3,184 @@ require 'uri'
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'timeout'
|
|
5
5
|
|
|
6
|
+
require_relative 'version'
|
|
7
|
+
require_relative 'exceptions'
|
|
8
|
+
|
|
6
9
|
module SendLayer
|
|
7
10
|
class Client
|
|
8
|
-
BASE_URL = 'https://console.sendlayer.com/api/v1/'
|
|
9
|
-
|
|
11
|
+
BASE_URL = 'https://console.sendlayer.com/api/v1/'.freeze
|
|
12
|
+
|
|
13
|
+
# Seconds to wait for the API before giving up.
|
|
14
|
+
DEFAULT_TIMEOUT = 30
|
|
15
|
+
|
|
16
|
+
# HTTP status => [error class, fallback message used when the API response
|
|
17
|
+
# carries no message of its own].
|
|
18
|
+
ERROR_MAP = {
|
|
19
|
+
400 => [SendLayerValidationError, 'Invalid request parameters'],
|
|
20
|
+
401 => [SendLayerAuthenticationError, 'Invalid API key'],
|
|
21
|
+
404 => [SendLayerNotFoundError, 'Resource not found'],
|
|
22
|
+
422 => [SendLayerValidationError, 'Unprocessable Entity'],
|
|
23
|
+
429 => [SendLayerRateLimitError, 'Rate limit exceeded'],
|
|
24
|
+
500 => [SendLayerInternalServerError, 'Internal server error']
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
10
27
|
attr_reader :api_key, :base_url, :timeout, :attachment_url_timeout
|
|
11
28
|
|
|
12
29
|
def initialize(api_key, options = {})
|
|
13
30
|
@api_key = api_key
|
|
14
31
|
@base_url = options[:base_url] || BASE_URL
|
|
15
|
-
@timeout = options[:timeout] ||
|
|
16
|
-
@attachment_url_timeout = options[:attachment_url_timeout] ||
|
|
32
|
+
@timeout = options[:timeout] || DEFAULT_TIMEOUT
|
|
33
|
+
@attachment_url_timeout = options[:attachment_url_timeout] || 30_000
|
|
34
|
+
@headers = options[:headers] || {}
|
|
17
35
|
end
|
|
18
36
|
|
|
37
|
+
# Make a request to the SendLayer API.
|
|
38
|
+
#
|
|
39
|
+
# Always returns a Hash/Array or raises a SendLayerError -- a Net::HTTP
|
|
40
|
+
# exception is never surfaced to the caller.
|
|
19
41
|
def make_request(method, endpoint, data = nil, params = {})
|
|
20
42
|
uri = URI("#{@base_url}#{endpoint}")
|
|
21
|
-
|
|
22
|
-
# Add query parameters
|
|
23
|
-
unless params.empty?
|
|
24
|
-
uri.query = URI.encode_www_form(params)
|
|
25
|
-
end
|
|
43
|
+
uri.query = URI.encode_www_form(params) unless params.empty?
|
|
26
44
|
|
|
27
45
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
28
|
-
http
|
|
46
|
+
# Derived from the URI rather than forced on, so an http:// base_url
|
|
47
|
+
# (a local test server, say) is actually usable.
|
|
48
|
+
http.use_ssl = uri.scheme == 'https'
|
|
29
49
|
http.read_timeout = @timeout
|
|
50
|
+
# A connection that never completes its handshake would otherwise hang
|
|
51
|
+
# past read_timeout, which only covers waiting for the response body.
|
|
52
|
+
http.open_timeout = @timeout
|
|
30
53
|
|
|
31
|
-
|
|
32
|
-
when 'GET'
|
|
33
|
-
request = Net::HTTP::Get.new(uri)
|
|
34
|
-
when 'POST'
|
|
35
|
-
request = Net::HTTP::Post.new(uri)
|
|
36
|
-
request.body = data.to_json if data
|
|
37
|
-
request['Content-Type'] = 'application/json'
|
|
38
|
-
when 'DELETE'
|
|
39
|
-
request = Net::HTTP::Delete.new(uri)
|
|
40
|
-
else
|
|
41
|
-
raise SendLayerError.new("Unsupported HTTP method: #{method}")
|
|
42
|
-
end
|
|
54
|
+
request = build_request(method, uri, data)
|
|
43
55
|
|
|
44
|
-
|
|
45
|
-
|
|
56
|
+
# Only the transport call is guarded. handle_response runs outside this
|
|
57
|
+
# block so the typed error it raises is not caught and re-wrapped as a
|
|
58
|
+
# generic "Connection error" -- which previously flattened every API
|
|
59
|
+
# error to the base SendLayerError.
|
|
60
|
+
response =
|
|
61
|
+
begin
|
|
62
|
+
http.request(request)
|
|
63
|
+
rescue Timeout::Error
|
|
64
|
+
raise SendLayerError.new("Request timed out after #{@timeout}s")
|
|
65
|
+
rescue StandardError => e
|
|
66
|
+
raise SendLayerError.new("Connection error: #{e.message}")
|
|
67
|
+
end
|
|
46
68
|
|
|
47
|
-
|
|
48
|
-
response = http.request(request)
|
|
49
|
-
handle_response(response)
|
|
50
|
-
rescue Timeout::Error
|
|
51
|
-
raise SendLayerError.new("Request timeout after #{@timeout} seconds")
|
|
52
|
-
rescue => e
|
|
53
|
-
raise SendLayerError.new("Network error: #{e.message}")
|
|
54
|
-
end
|
|
69
|
+
handle_response(response)
|
|
55
70
|
end
|
|
56
71
|
|
|
57
72
|
private
|
|
58
73
|
|
|
74
|
+
def build_request(method, uri, data)
|
|
75
|
+
request =
|
|
76
|
+
case method.to_s.upcase
|
|
77
|
+
when 'GET' then Net::HTTP::Get.new(uri)
|
|
78
|
+
when 'DELETE' then Net::HTTP::Delete.new(uri)
|
|
79
|
+
when 'POST'
|
|
80
|
+
req = Net::HTTP::Post.new(uri)
|
|
81
|
+
req.body = data.to_json if data
|
|
82
|
+
req['Content-Type'] = 'application/json'
|
|
83
|
+
req
|
|
84
|
+
else
|
|
85
|
+
raise SendLayerError.new("Unsupported HTTP method: #{method}")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
request['User-Agent'] = "SendLayer-Ruby/#{::SendLayer::VERSION}"
|
|
89
|
+
@headers.each { |key, value| request[key.to_s] = value }
|
|
90
|
+
# Applied after the caller's headers so those cannot drop authentication.
|
|
91
|
+
request['Authorization'] = "Bearer #{@api_key}"
|
|
92
|
+
request
|
|
93
|
+
end
|
|
94
|
+
|
|
59
95
|
def handle_response(response)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
96
|
+
status = response.code.to_i
|
|
97
|
+
return parse_success(response, status) if status.between?(200, 299)
|
|
98
|
+
|
|
99
|
+
raise build_error(response, status)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def parse_success(response, status)
|
|
103
|
+
# Successful responses with no body (e.g. 204 No Content) decode to {}
|
|
104
|
+
# rather than raising out of JSON.parse.
|
|
105
|
+
body = response.body
|
|
106
|
+
return {} if body.nil? || body.empty?
|
|
107
|
+
|
|
108
|
+
data =
|
|
109
|
+
begin
|
|
110
|
+
JSON.parse(body)
|
|
111
|
+
rescue JSON::ParserError
|
|
112
|
+
raise SendLayerError.new('Invalid JSON response from API', status)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
data.is_a?(Hash) || data.is_a?(Array) ? data : {}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Map an error response onto the appropriate SendLayer error.
|
|
119
|
+
def build_error(response, status)
|
|
120
|
+
data = parse_body(response)
|
|
121
|
+
errors = extract_errors(data)
|
|
122
|
+
error_class, default = ERROR_MAP[status]
|
|
123
|
+
|
|
124
|
+
unless error_class
|
|
125
|
+
default = status.between?(500, 599) ? 'Server error' : 'API request failed'
|
|
126
|
+
return SendLayerAPIError.new(extract_message(data, default), status, data, errors)
|
|
88
127
|
end
|
|
128
|
+
|
|
129
|
+
error_class.new(extract_message(data, default), status, data, errors)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Decode an error response body.
|
|
133
|
+
#
|
|
134
|
+
# Falls back to the HTTP reason phrase when the body isn't JSON, so an HTML
|
|
135
|
+
# error page from a proxy still produces a usable message instead of raising
|
|
136
|
+
# a JSON::ParserError out of the SDK.
|
|
137
|
+
def parse_body(response)
|
|
138
|
+
data =
|
|
139
|
+
begin
|
|
140
|
+
response.body.nil? || response.body.empty? ? nil : JSON.parse(response.body)
|
|
141
|
+
rescue JSON::ParserError
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
return data if data.is_a?(Hash)
|
|
146
|
+
|
|
147
|
+
{ 'Error' => reason_phrase(response) }
|
|
89
148
|
end
|
|
90
149
|
|
|
91
|
-
def
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
150
|
+
def reason_phrase(response)
|
|
151
|
+
phrase = response.message
|
|
152
|
+
phrase.nil? || phrase.empty? ? 'Unknown error' : phrase
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Normalize the SendLayer +Errors+ array from a decoded body.
|
|
156
|
+
#
|
|
157
|
+
# SendLayer returns errors as:
|
|
158
|
+
#
|
|
159
|
+
# {"Errors": [{"Code": 14, "Message": "..."}]}
|
|
160
|
+
#
|
|
161
|
+
# Returns an empty array when absent or malformed.
|
|
162
|
+
def extract_errors(data)
|
|
163
|
+
raw = data['Errors']
|
|
164
|
+
return [] unless raw.is_a?(Array)
|
|
165
|
+
|
|
166
|
+
raw.select { |entry| entry.is_a?(Hash) }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Build a message from the API response, preferring its own text.
|
|
170
|
+
#
|
|
171
|
+
# Joins multiple +Errors+ messages with "; ", then falls back to the
|
|
172
|
+
# singular +Error+ key, then to +default+.
|
|
173
|
+
def extract_message(data, default)
|
|
174
|
+
parts = extract_errors(data).filter_map do |entry|
|
|
175
|
+
message = entry['Message']
|
|
176
|
+
message.to_s unless message.nil? || message.to_s.empty?
|
|
177
|
+
end
|
|
178
|
+
return parts.join('; ') unless parts.empty?
|
|
179
|
+
|
|
180
|
+
error = data['Error']
|
|
181
|
+
return error if error.is_a?(String) && !error.empty?
|
|
182
|
+
|
|
183
|
+
default
|
|
96
184
|
end
|
|
97
185
|
end
|
|
98
186
|
end
|
data/lib/sendlayer/emails.rb
CHANGED
|
@@ -19,10 +19,15 @@ module SendLayer
|
|
|
19
19
|
|
|
20
20
|
def send(from:, to:, subject:, text: nil, html: nil, cc: nil, bcc: nil, reply_to: nil,
|
|
21
21
|
attachments: nil, headers: nil, tags: nil)
|
|
22
|
-
|
|
22
|
+
|
|
23
|
+
# Empty strings are treated as absent, so a caller passing text: '' gets a
|
|
24
|
+
# validation error rather than an email with no body.
|
|
25
|
+
has_html = !html.nil? && html != ''
|
|
26
|
+
has_text = !text.nil? && text != ''
|
|
27
|
+
|
|
23
28
|
# Validate required parameters
|
|
24
|
-
raise SendLayerValidationError.new("Either 'text' or 'html' content must be provided")
|
|
25
|
-
|
|
29
|
+
raise SendLayerValidationError.new("Either 'text' or 'html' content must be provided") unless has_html || has_text
|
|
30
|
+
|
|
26
31
|
# Prepare email data
|
|
27
32
|
email_data = {
|
|
28
33
|
From: normalize_recipient(from, 'sender'),
|
|
@@ -30,13 +35,12 @@ module SendLayer
|
|
|
30
35
|
Subject: subject
|
|
31
36
|
}
|
|
32
37
|
|
|
33
|
-
if
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
end
|
|
38
|
+
# Both parts are sent when both are supplied. The previous if/else could
|
|
39
|
+
# only ever emit one of them, which silently dropped the plain-text part.
|
|
40
|
+
# HTML wins for the declared content type whenever an HTML body is present.
|
|
41
|
+
email_data[:ContentType] = has_html ? 'HTML' : 'Text'
|
|
42
|
+
email_data[:HTMLContent] = html if has_html
|
|
43
|
+
email_data[:PlainContent] = text if has_text
|
|
40
44
|
email_data[:CC] = normalize_recipients(cc) if cc
|
|
41
45
|
email_data[:BCC] = normalize_recipients(bcc) if bcc
|
|
42
46
|
email_data[:ReplyTo] = normalize_recipients(reply_to, 'reply_to') if reply_to
|
|
@@ -77,7 +81,7 @@ module SendLayer
|
|
|
77
81
|
|
|
78
82
|
def normalize_recipients(recipients, role = 'recipient')
|
|
79
83
|
return nil if recipients.nil?
|
|
80
|
-
|
|
84
|
+
|
|
81
85
|
case recipients
|
|
82
86
|
when String
|
|
83
87
|
[normalize_recipient(recipients, role)]
|
data/lib/sendlayer/exceptions.rb
CHANGED
|
@@ -1,20 +1,38 @@
|
|
|
1
1
|
module SendLayer
|
|
2
|
+
# Base error for the SendLayer SDK.
|
|
3
|
+
#
|
|
4
|
+
# Every SendLayer error carries the same attributes, so callers can read them
|
|
5
|
+
# without first checking which subclass they rescued:
|
|
6
|
+
#
|
|
7
|
+
# - +status_code+ HTTP status of the response, or nil for local errors
|
|
8
|
+
# - +response+ decoded response body, or {} when unavailable
|
|
9
|
+
# - +errors+ raw SendLayer +Errors+ entries, each with the API's numeric
|
|
10
|
+
# +Code+ and +Message+; empty for local errors and for
|
|
11
|
+
# responses that aren't in that shape
|
|
2
12
|
class SendLayerError < StandardError
|
|
3
|
-
attr_reader :
|
|
13
|
+
attr_reader :status_code, :response, :errors
|
|
4
14
|
|
|
5
|
-
def initialize(message)
|
|
6
|
-
@
|
|
15
|
+
def initialize(message = '', status_code = nil, response = nil, errors = nil)
|
|
16
|
+
@status_code = status_code
|
|
17
|
+
@response = response.nil? ? {} : response
|
|
18
|
+
@errors = errors.nil? ? [] : errors
|
|
7
19
|
super(message)
|
|
8
20
|
end
|
|
21
|
+
|
|
22
|
+
# Numeric SendLayer error codes carried by this error, for branching:
|
|
23
|
+
# <tt>if err.codes.include?(14)</tt>.
|
|
24
|
+
#
|
|
25
|
+
# See https://developers.sendlayer.com/api-reference/error-codes
|
|
26
|
+
def codes
|
|
27
|
+
@errors.filter_map { |entry| entry['Code'] if entry.is_a?(Hash) && entry.key?('Code') }
|
|
28
|
+
end
|
|
9
29
|
end
|
|
10
30
|
|
|
31
|
+
# Raised for API errors not covered by a more specific type.
|
|
11
32
|
class SendLayerAPIError < SendLayerError
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
@status_code = status_code
|
|
16
|
-
@response = response
|
|
17
|
-
super(message)
|
|
33
|
+
def initialize(message, status_code = nil, response = nil, errors = nil)
|
|
34
|
+
# Historical string form keeps the status prefix.
|
|
35
|
+
super("API Error #{status_code}: #{message}", status_code, response, errors)
|
|
18
36
|
end
|
|
19
37
|
end
|
|
20
38
|
|
data/lib/sendlayer/version.rb
CHANGED
data/lib/sendlayer/webhooks.rb
CHANGED
|
@@ -8,7 +8,7 @@ module SendLayer
|
|
|
8
8
|
|
|
9
9
|
def create(url:, event:)
|
|
10
10
|
validate_webhook_params(url, event)
|
|
11
|
-
|
|
11
|
+
|
|
12
12
|
webhook_data = {
|
|
13
13
|
WebhookURL: url,
|
|
14
14
|
Event: event
|
|
@@ -21,9 +21,9 @@ module SendLayer
|
|
|
21
21
|
@client.make_request('GET', 'webhooks')
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
def delete(webhook_id)
|
|
25
25
|
raise SendLayerValidationError.new("Webhook ID is required") if webhook_id.nil?
|
|
26
|
-
|
|
26
|
+
|
|
27
27
|
unless webhook_id.is_a?(Integer) && webhook_id > 0
|
|
28
28
|
raise SendLayerValidationError.new("Webhook ID must be a positive integer")
|
|
29
29
|
end
|
|
@@ -36,7 +36,7 @@ module SendLayer
|
|
|
36
36
|
def validate_webhook_params(url, event)
|
|
37
37
|
raise SendLayerValidationError.new("URL is required") if url.nil? || url.empty?
|
|
38
38
|
raise SendLayerValidationError.new("Event is required") if event.nil? || event.empty?
|
|
39
|
-
|
|
39
|
+
|
|
40
40
|
unless VALID_EVENTS.include?(event)
|
|
41
41
|
raise SendLayerValidationError.new("Invalid event: #{event}. Valid event types include: #{VALID_EVENTS.join(', ')}")
|
|
42
42
|
end
|
data/lib/sendlayer.rb
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
require_relative 'sendlayer/version'
|
|
2
|
+
# Loaded before the client: Client::ERROR_MAP resolves these classes at
|
|
3
|
+
# class-definition time, so they must already exist.
|
|
4
|
+
require_relative 'sendlayer/exceptions'
|
|
2
5
|
require_relative 'sendlayer/client'
|
|
3
6
|
require_relative 'sendlayer/emails'
|
|
4
7
|
require_relative 'sendlayer/webhooks'
|
|
5
8
|
require_relative 'sendlayer/events'
|
|
6
|
-
require_relative 'sendlayer/exceptions'
|
|
7
9
|
|
|
8
10
|
module SendLayer
|
|
9
11
|
class SendLayer
|
|
10
12
|
attr_reader :emails, :webhooks, :events
|
|
11
13
|
|
|
14
|
+
# Initialize the SDK.
|
|
15
|
+
#
|
|
16
|
+
# @param api_key [String] Your SendLayer API key.
|
|
17
|
+
# @param options [Hash] Optional settings forwarded to the underlying
|
|
18
|
+
# client: +:timeout+ (seconds, default 30), +:attachment_url_timeout+
|
|
19
|
+
# (milliseconds), +:headers+ (extra request headers) and +:base_url+.
|
|
12
20
|
def initialize(api_key, options = {})
|
|
13
21
|
@client = Client.new(api_key, options)
|
|
14
22
|
@emails = Emails.new(@client)
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: sendlayer
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- SendLayer
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2026-08-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: mime-types
|
|
@@ -121,6 +121,7 @@ files:
|
|
|
121
121
|
- ".github/workflows/publish.yml"
|
|
122
122
|
- ".gitignore"
|
|
123
123
|
- ".rubocop.yml"
|
|
124
|
+
- CHANGELOG.md
|
|
124
125
|
- Gemfile
|
|
125
126
|
- Gemfile.lock
|
|
126
127
|
- LICENSE
|