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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fcbeb3e895d3e5a69471af13feb2c2f85b02c6d8babda4e1417266caa848228d
4
- data.tar.gz: e82bfac8473f5db810f7d465055b05ca0a92009eafc3c81204a97d0c143c9d00
3
+ metadata.gz: 1cc77f877b3aa02ffaed910ed8c3aa638e5bffb1cbbfc7079ad1081a5ab24a6a
4
+ data.tar.gz: ea8244c28c69a02113564abdf64dbae9628bd2e5ee64b4ca9d405721787c1859
5
5
  SHA512:
6
- metadata.gz: aa169ec23cc27a318feac68e0de833b5c89b9e142406d5bcbe3c365b4961c2d487c4f470a05754f330aacc160dc1a66d3c6c6d95f8cf5aef63bfa30f58945ed5
7
- data.tar.gz: fa2bdd5529ed366c76b1da611240eb4d026b7dfbec0776537fecd260a1c906ed4d3144f0f6e20314c2d07709fed281b848873f435d36de94d922e41f8170f7f1
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=$(cat lib/sendlayer/version.rb | grep "VERSION = " | sed "s/VERSION = '\(.*\)'/\1/")
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
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- sendlayer (1.0.0)
4
+ sendlayer (1.1.0)
5
5
  mime-types (~> 3.4)
6
6
 
7
7
  GEM
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
- [![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
13
+ [![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE) [![Publish Ruby Gem](https://github.com/SendLayer/sendlayer-ruby/actions/workflows/publish.yml/badge.svg)](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
- The SDK provides specific exception types for different error scenarios:
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::SendLayerAPIError => e
169
- puts "API error: #{e.message} (Status: #{e.status_code})"
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::SendLayerAuthenticationError => e
173
- puts "Authentication error: #{e.message}"
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 "SendLayer error: #{e.message}"
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
- - `SendLayer::SendLayerError`: Base exception for all SendLayer errors
184
- - `SendLayer::SendLayerAPIError`: API-specific errors with status code and response data
185
- - `SendLayer::SendLayerAuthenticationError`: Invalid API key or authentication issues
186
- - `SendLayer::SendLayerValidationError`: Invalid parameters or validation errors
187
- - `SendLayer::SendLayerNotFoundError`: Resource not found (404 errors)
188
- - `SendLayer::SendLayerRateLimitError`: Rate limit exceeded (429 errors)
189
- - `SendLayer::SendLayerInternalServerError`: Server errors (5xx errors)
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
 
@@ -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] || 30
16
- @attachment_url_timeout = options[:attachment_url_timeout] || 30000
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.use_ssl = true
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
- case method.upcase
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
- request['Authorization'] = "Bearer #{@api_key}"
45
- request['User-Agent'] = "SendLayer-Ruby/#{::SendLayer::VERSION}"
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
- begin
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
- case response.code.to_i
61
- when 200..299
62
- return JSON.parse(response.body) if response.body && !response.body.empty?
63
- return {}
64
- when 401
65
- raise SendLayerAuthenticationError.new("Invalid API key")
66
- when 400
67
- error_data = parse_error_response(response)
68
- raise SendLayerValidationError.new(error_data['Error'] || 'Invalid request parameters')
69
- when 404
70
- error_data = parse_error_response(response)
71
- raise SendLayerNotFoundError.new(error_data['Error'] || 'Resource not found')
72
- when 422
73
- error_data = parse_error_response(response)
74
- raise SendLayerValidationError.new(error_data['Error'] || 'Validation failed')
75
- when 429
76
- error_data = parse_error_response(response)
77
- raise SendLayerRateLimitError.new(error_data['Error'] || 'Rate limit exceeded')
78
- when 500..599
79
- error_data = parse_error_response(response)
80
- raise SendLayerInternalServerError.new(error_data['Error'] || 'Internal server error')
81
- else
82
- error_data = parse_error_response(response)
83
- raise SendLayerAPIError.new(
84
- error_data['Error'] || "HTTP #{response.code} error",
85
- response.code.to_i,
86
- response.body
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 parse_error_response(response)
92
- return {} unless response.body
93
- JSON.parse(response.body)
94
- rescue JSON::ParserError
95
- { 'Error' => response.body }
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
@@ -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") if text.nil? && html.nil?
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 html
34
- email_data[:ContentType] = "HTML"
35
- email_data[:HTMLContent] = html
36
- else
37
- email_data[:ContentType] = "Text"
38
- email_data[:PlainContent] = text
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)]
@@ -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 :message
13
+ attr_reader :status_code, :response, :errors
4
14
 
5
- def initialize(message)
6
- @message = message
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
- attr_reader :status_code, :response
13
-
14
- def initialize(message, status_code = nil, response = nil)
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
 
@@ -1,3 +1,3 @@
1
1
  module SendLayer
2
- VERSION = '1.0.0'
2
+ VERSION = '1.1.0'
3
3
  end
@@ -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
- def delete(webhook_id)
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.0.0
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: 2025-10-09 00:00:00.000000000 Z
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