lettermint 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: eaf2c7d68c2a67e9bdcfff25733031fd17e23645ae6d5c2cc73100177ecd8865
4
+ data.tar.gz: 7ecfec4398f01936474380abb69c595e9220d1f90e3e990b9261e426e72c1772
5
+ SHA512:
6
+ metadata.gz: fa95bb5a225b54f848de6b62e0e334072207373ab84bbd0a56ba5babc23b47f6b60d4bbdf30097884c6df28c64209ae7956e4c9b551e7b38a735740555a43e09
7
+ data.tar.gz: a4e41473d58b93ced91c3219d6074d0eaf6382b61174758cc62a091c8ca59762fc75e1a1e9acd4de5372e5c86083cd3a34767c88d7561ab31a7d0ff7b5c4ad4f
data/CHANGELOG.md ADDED
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-02-18
9
+
10
+ ### Added
11
+
12
+ - Email sending via `Client#email` with fluent builder interface
13
+ - Support for to, cc, bcc, reply-to, attachments, custom headers, metadata, tags, and routing
14
+ - `deliver` method (avoids `Object#send` collision)
15
+ - Idempotency key support for safe retries
16
+ - `SendEmailResponse` and `EmailAttachment` frozen value objects (`Data.define`)
17
+ - HMAC-SHA256 webhook signature verification via `Webhook`
18
+ - Timestamp tolerance checking (default 300s)
19
+ - Instance and class-level verification methods
20
+ - Typed error hierarchy: `HttpRequestError`, `ValidationError`, `ClientError`, `TimeoutError`, `WebhookVerificationError`, `InvalidSignatureError`, `TimestampToleranceError`, `WebhookJsonDecodeError`
21
+ - Faraday-based HTTP transport with `x-lettermint-token` authentication
22
+ - Block-style client configuration
23
+ - Full RSpec test suite (86 examples)
24
+ - RuboCop configuration
25
+
26
+ [0.1.0]: https://github.com/onetimesecret/lettermint-ruby/releases/tag/v0.1.0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Onetime Secret
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,325 @@
1
+ # Lettermint Ruby SDK
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/lettermint?style=flat-square)](https://rubygems.org/gems/lettermint)
4
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2-red?style=flat-square)](https://www.ruby-lang.org)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://github.com/onetimesecret/lettermint-ruby/blob/main/LICENSE)
6
+
7
+ Unofficial Ruby SDK for the [Lettermint](https://lettermint.co) transactional email API. Based on the official [Python SDK](https://github.com/lettermint/lettermint-python).
8
+
9
+ ## Installation
10
+
11
+ Add to your Gemfile:
12
+
13
+ ```ruby
14
+ gem 'lettermint'
15
+ ```
16
+
17
+ Or install directly:
18
+
19
+ ```bash
20
+ gem install lettermint
21
+ ```
22
+
23
+ ## Quick Start
24
+
25
+ ### Sending Emails
26
+
27
+ ```ruby
28
+ require 'lettermint'
29
+
30
+ client = Lettermint::Client.new(api_token: 'your-api-token')
31
+
32
+ response = client.email
33
+ .from('sender@example.com')
34
+ .to('recipient@example.com')
35
+ .subject('Hello from Ruby')
36
+ .html('<h1>Welcome!</h1>')
37
+ .text('Welcome!')
38
+ .deliver
39
+
40
+ puts response.message_id
41
+ ```
42
+
43
+ ## Email Options
44
+
45
+ ### Multiple Recipients
46
+
47
+ ```ruby
48
+ client.email
49
+ .from('sender@example.com')
50
+ .to('recipient1@example.com', 'recipient2@example.com')
51
+ .subject('Hello')
52
+ .deliver
53
+ ```
54
+
55
+ ### CC and BCC
56
+
57
+ ```ruby
58
+ client.email
59
+ .from('sender@example.com')
60
+ .to('recipient@example.com')
61
+ .cc('cc1@example.com', 'cc2@example.com')
62
+ .bcc('bcc@example.com')
63
+ .subject('Hello')
64
+ .deliver
65
+ ```
66
+
67
+ ### Reply-To
68
+
69
+ ```ruby
70
+ client.email
71
+ .from('sender@example.com')
72
+ .to('recipient@example.com')
73
+ .reply_to('reply@example.com')
74
+ .subject('Hello')
75
+ .deliver
76
+ ```
77
+
78
+ ### RFC 5322 Addresses
79
+
80
+ ```ruby
81
+ client.email
82
+ .from('John Doe <john@example.com>')
83
+ .to('Jane Doe <jane@example.com>')
84
+ .subject('Hello')
85
+ .deliver
86
+ ```
87
+
88
+ ### Attachments
89
+
90
+ ```ruby
91
+ require 'base64'
92
+
93
+ content = Base64.strict_encode64(File.binread('document.pdf'))
94
+
95
+ # Regular attachment
96
+ client.email
97
+ .from('sender@example.com')
98
+ .to('recipient@example.com')
99
+ .subject('Your Document')
100
+ .attach('document.pdf', content)
101
+ .deliver
102
+
103
+ # Inline attachment (for embedding in HTML)
104
+ client.email
105
+ .from('sender@example.com')
106
+ .to('recipient@example.com')
107
+ .subject('Welcome')
108
+ .html('<img src="cid:logo@example.com">')
109
+ .attach('logo.png', logo_content, content_id: 'logo@example.com')
110
+ .deliver
111
+ ```
112
+
113
+ You can also use the `EmailAttachment` value object:
114
+
115
+ ```ruby
116
+ attachment = Lettermint::EmailAttachment.new(
117
+ filename: 'document.pdf',
118
+ content: content,
119
+ content_id: nil
120
+ )
121
+
122
+ client.email
123
+ .from('sender@example.com')
124
+ .to('recipient@example.com')
125
+ .attach(attachment)
126
+ .deliver
127
+ ```
128
+
129
+ ### Custom Headers
130
+
131
+ ```ruby
132
+ client.email
133
+ .from('sender@example.com')
134
+ .to('recipient@example.com')
135
+ .subject('Hello')
136
+ .headers({ 'X-Custom-Header' => 'value' })
137
+ .deliver
138
+ ```
139
+
140
+ ### Metadata and Tags
141
+
142
+ ```ruby
143
+ client.email
144
+ .from('sender@example.com')
145
+ .to('recipient@example.com')
146
+ .subject('Hello')
147
+ .metadata({ campaign_id: '123', user_id: '456' })
148
+ .tag('welcome-campaign')
149
+ .deliver
150
+ ```
151
+
152
+ ### Routing
153
+
154
+ ```ruby
155
+ client.email
156
+ .from('sender@example.com')
157
+ .to('recipient@example.com')
158
+ .subject('Hello')
159
+ .route('my-route')
160
+ .deliver
161
+ ```
162
+
163
+ ### Idempotency Key
164
+
165
+ Prevent duplicate sends when retrying failed requests:
166
+
167
+ ```ruby
168
+ client.email
169
+ .from('sender@example.com')
170
+ .to('recipient@example.com')
171
+ .subject('Hello')
172
+ .idempotency_key('unique-request-id')
173
+ .deliver
174
+ ```
175
+
176
+ ## Webhook Verification
177
+
178
+ Verify webhook signatures to ensure authenticity:
179
+
180
+ ```ruby
181
+ require 'lettermint'
182
+
183
+ webhook = Lettermint::Webhook.new(secret: 'your-webhook-secret')
184
+
185
+ # Verify using headers (recommended)
186
+ payload = webhook.verify_headers(request.headers, request.body)
187
+
188
+ # Or verify using the signature directly
189
+ payload = webhook.verify(
190
+ request.body,
191
+ request.headers['X-Lettermint-Signature']
192
+ )
193
+
194
+ puts payload['event']
195
+ ```
196
+
197
+ ### Class Method
198
+
199
+ For one-off verification:
200
+
201
+ ```ruby
202
+ payload = Lettermint::Webhook.verify_signature(
203
+ request.body,
204
+ request.headers['X-Lettermint-Signature'],
205
+ secret: 'your-webhook-secret'
206
+ )
207
+ ```
208
+
209
+ ### Custom Tolerance
210
+
211
+ Adjust the timestamp tolerance (default: 300 seconds):
212
+
213
+ ```ruby
214
+ webhook = Lettermint::Webhook.new(secret: 'your-webhook-secret', tolerance: 600)
215
+ ```
216
+
217
+ ### Replay Protection
218
+
219
+ The webhook verifier validates timestamp freshness and HMAC integrity but does not
220
+ track previously seen deliveries. Within the tolerance window (default 300 seconds),
221
+ a captured request could be replayed. Your application should deduplicate incoming
222
+ webhooks using the `x-lettermint-delivery` header value as an idempotency key -- for
223
+ example, by recording processed delivery IDs in a database or cache and rejecting
224
+ duplicates.
225
+
226
+ ## Error Handling
227
+
228
+ ```ruby
229
+ require 'lettermint'
230
+
231
+ client = Lettermint::Client.new(api_token: 'your-api-token')
232
+
233
+ begin
234
+ response = client.email
235
+ .from('sender@example.com')
236
+ .to('recipient@example.com')
237
+ .subject('Hello')
238
+ .deliver
239
+ rescue Lettermint::AuthenticationError => e
240
+ # 401/403 errors (invalid or revoked token)
241
+ puts "Auth error #{e.status_code}: #{e.message}"
242
+ rescue Lettermint::RateLimitError => e
243
+ # 429 errors
244
+ puts "Rate limited, retry after: #{e.retry_after}s"
245
+ rescue Lettermint::ValidationError => e
246
+ # 422 errors (e.g., daily limit exceeded)
247
+ puts "Validation error: #{e.error_type}"
248
+ puts "Response: #{e.response_body}"
249
+ rescue Lettermint::ClientError => e
250
+ # 400 errors
251
+ puts "Client error: #{e.message}"
252
+ rescue Lettermint::TimeoutError => e
253
+ # Request timeout
254
+ puts "Timeout: #{e.message}"
255
+ rescue Lettermint::HttpRequestError => e
256
+ # Other HTTP errors
257
+ puts "HTTP error #{e.status_code}: #{e.message}"
258
+ end
259
+ ```
260
+
261
+ ### Webhook Errors
262
+
263
+ ```ruby
264
+ begin
265
+ payload = webhook.verify_headers(headers, body)
266
+ rescue Lettermint::InvalidSignatureError
267
+ puts 'Invalid signature - request may be forged'
268
+ rescue Lettermint::TimestampToleranceError
269
+ puts 'Timestamp too old - possible replay attack'
270
+ rescue Lettermint::WebhookJsonDecodeError => e
271
+ puts "Invalid JSON in payload: #{e.original_exception}"
272
+ rescue Lettermint::WebhookVerificationError => e
273
+ puts "Verification failed: #{e.message}"
274
+ end
275
+ ```
276
+
277
+ ## Configuration
278
+
279
+ ### Global Configuration
280
+
281
+ Set defaults once at application boot (e.g., in a Rails initializer):
282
+
283
+ ```ruby
284
+ Lettermint.configure do |config|
285
+ config.base_url = 'https://custom.api.com/v1'
286
+ config.timeout = 60
287
+ end
288
+ ```
289
+
290
+ All clients created afterward inherit these defaults:
291
+
292
+ ```ruby
293
+ client = Lettermint::Client.new(api_token: 'your-api-token')
294
+ # Uses the global base_url and timeout
295
+ ```
296
+
297
+ ### Per-Client Overrides
298
+
299
+ Explicit keyword arguments take precedence over global configuration:
300
+
301
+ ```ruby
302
+ client = Lettermint::Client.new(
303
+ api_token: 'your-api-token',
304
+ base_url: 'https://other.api.com/v1',
305
+ timeout: 10
306
+ )
307
+ ```
308
+
309
+ ### Block Configuration
310
+
311
+ ```ruby
312
+ client = Lettermint::Client.new(api_token: 'your-api-token') do |config|
313
+ config.base_url = 'https://custom.api.com/v1'
314
+ config.timeout = 60
315
+ end
316
+ ```
317
+
318
+ ## Requirements
319
+
320
+ - Ruby >= 3.2
321
+ - [Faraday](https://github.com/lostisland/faraday) ~> 2.0
322
+
323
+ ## License
324
+
325
+ MIT
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/lettermint/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'lettermint'
7
+ spec.version = Lettermint::VERSION
8
+ spec.authors = ['Delano']
9
+ spec.email = ['gems@onetimesecret.com']
10
+
11
+ spec.summary = 'Ruby SDK for the Lettermint transactional email API'
12
+ spec.description = 'Send transactional emails and verify webhooks with the Lettermint API. ' \
13
+ 'Provides a fluent builder interface, typed responses, and HMAC-SHA256 webhook verification.'
14
+ spec.homepage = 'https://github.com/onetimesecret/lettermint-ruby'
15
+ spec.license = 'MIT'
16
+ spec.required_ruby_version = '>= 3.2.0'
17
+
18
+ spec.metadata['homepage_uri'] = spec.homepage
19
+ spec.metadata['source_code_uri'] = spec.homepage
20
+ spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md"
21
+ spec.metadata['rubygems_mfa_required'] = 'true'
22
+ spec.metadata['allowed_push_host'] = 'https://rubygems.org'
23
+
24
+ spec.files = Dir.chdir(__dir__) do
25
+ `git ls-files -z`.split("\x0").reject do |f|
26
+ (File.expand_path(f) == __FILE__) ||
27
+ f.start_with?('spec/', 'examples/', '.git', '.rubocop', 'Rakefile', 'Gemfile',
28
+ 'CLAUDE', '.pre-commit', '.rspec')
29
+ end
30
+ end
31
+
32
+ spec.require_paths = ['lib']
33
+
34
+ spec.add_dependency 'faraday', '~> 2.0'
35
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lettermint
4
+ class Client
5
+ attr_reader :configuration
6
+
7
+ def initialize(api_token:, base_url: nil, timeout: nil)
8
+ validate_api_token!(api_token)
9
+
10
+ @configuration = Configuration.new
11
+ @configuration.base_url = base_url || Lettermint.configuration.base_url
12
+ @configuration.timeout = timeout || Lettermint.configuration.timeout
13
+
14
+ yield @configuration if block_given?
15
+
16
+ @http_client = HttpClient.new(
17
+ api_token: api_token,
18
+ base_url: @configuration.base_url,
19
+ timeout: @configuration.timeout
20
+ )
21
+ end
22
+
23
+ def email
24
+ EmailMessage.new(http_client: @http_client)
25
+ end
26
+
27
+ private
28
+
29
+ def validate_api_token!(token)
30
+ raise ArgumentError, 'API token cannot be empty' if token.nil? || token.to_s.strip.empty?
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lettermint
4
+ class Configuration
5
+ DEFAULT_BASE_URL = 'https://api.lettermint.co/v1'
6
+ DEFAULT_TIMEOUT = 30
7
+
8
+ attr_accessor :base_url, :timeout
9
+
10
+ def initialize
11
+ @base_url = DEFAULT_BASE_URL
12
+ @timeout = DEFAULT_TIMEOUT
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lettermint
4
+ class EmailMessage
5
+ def initialize(http_client:)
6
+ @http_client = http_client
7
+ reset
8
+ end
9
+
10
+ def from(email)
11
+ @payload[:from] = email
12
+ self
13
+ end
14
+ alias from_addr from
15
+
16
+ def to(*emails)
17
+ @payload[:to] = emails.flatten
18
+ self
19
+ end
20
+
21
+ def subject(str)
22
+ @payload[:subject] = str
23
+ self
24
+ end
25
+
26
+ def html(str)
27
+ @payload[:html] = str if str
28
+ self
29
+ end
30
+
31
+ def text(str)
32
+ @payload[:text] = str if str
33
+ self
34
+ end
35
+
36
+ def cc(*emails)
37
+ @payload[:cc] = emails.flatten
38
+ self
39
+ end
40
+
41
+ def bcc(*emails)
42
+ @payload[:bcc] = emails.flatten
43
+ self
44
+ end
45
+
46
+ def reply_to(*emails)
47
+ @payload[:reply_to] = emails.flatten
48
+ self
49
+ end
50
+
51
+ def route(str)
52
+ @payload[:route] = str
53
+ self
54
+ end
55
+
56
+ def tag(str)
57
+ @payload[:tag] = str
58
+ self
59
+ end
60
+
61
+ def headers(hash)
62
+ @payload[:headers] = hash
63
+ self
64
+ end
65
+
66
+ def metadata(hash)
67
+ @payload[:metadata] = hash
68
+ self
69
+ end
70
+
71
+ def attach(filename_or_attachment, content = nil, content_id: nil)
72
+ attachment = case filename_or_attachment
73
+ when EmailAttachment
74
+ filename_or_attachment.to_h
75
+ else
76
+ h = { filename: filename_or_attachment, content: content }
77
+ h[:content_id] = content_id if content_id
78
+ h
79
+ end
80
+ @payload[:attachments] ||= []
81
+ @payload[:attachments] << attachment
82
+ self
83
+ end
84
+
85
+ def idempotency_key(key)
86
+ @idempotency_key = key
87
+ self
88
+ end
89
+
90
+ def deliver
91
+ validate_required_fields
92
+
93
+ request_headers = {}
94
+ request_headers['Idempotency-Key'] = @idempotency_key if @idempotency_key
95
+
96
+ result = @http_client.post(
97
+ path: '/send',
98
+ data: @payload,
99
+ headers: request_headers.empty? ? nil : request_headers
100
+ )
101
+ SendEmailResponse.from_hash(result)
102
+ ensure
103
+ reset
104
+ end
105
+ alias deliver! deliver
106
+
107
+ private
108
+
109
+ def validate_required_fields
110
+ missing = %i[from subject].select { |f| blank?(f) }
111
+ missing << :to if @payload[:to].nil? || @payload[:to].none? { |e| e.is_a?(String) && !e.strip.empty? }
112
+ return if missing.empty?
113
+
114
+ raise ArgumentError, "Missing required field(s): #{missing.join(', ')}"
115
+ end
116
+
117
+ def blank?(field)
118
+ @payload[field].nil? || @payload[field].to_s.strip.empty?
119
+ end
120
+
121
+ def reset
122
+ @payload = {}
123
+ @idempotency_key = nil
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lettermint
4
+ class Error < StandardError; end
5
+
6
+ class HttpRequestError < Error
7
+ attr_reader :status_code, :response_body
8
+
9
+ def initialize(message:, status_code:, response_body: nil)
10
+ @status_code = status_code
11
+ @response_body = response_body
12
+ super(message)
13
+ end
14
+ end
15
+
16
+ class ValidationError < HttpRequestError
17
+ attr_reader :error_type
18
+
19
+ def initialize(message:, error_type:, response_body: nil)
20
+ @error_type = error_type
21
+ super(message: message, status_code: 422, response_body: response_body)
22
+ end
23
+ end
24
+
25
+ class ClientError < HttpRequestError
26
+ def initialize(message:, response_body: nil)
27
+ super(message: message, status_code: 400, response_body: response_body)
28
+ end
29
+ end
30
+
31
+ class AuthenticationError < HttpRequestError
32
+ def initialize(message:, status_code:, response_body: nil)
33
+ super
34
+ end
35
+ end
36
+
37
+ class RateLimitError < HttpRequestError
38
+ attr_reader :retry_after
39
+
40
+ def initialize(message:, retry_after: nil, response_body: nil)
41
+ @retry_after = retry_after
42
+ super(message: message, status_code: 429, response_body: response_body)
43
+ end
44
+ end
45
+
46
+ class TimeoutError < Error; end
47
+
48
+ class WebhookVerificationError < Error; end
49
+
50
+ class InvalidSignatureError < WebhookVerificationError; end
51
+
52
+ class TimestampToleranceError < WebhookVerificationError; end
53
+
54
+ class WebhookJsonDecodeError < WebhookVerificationError
55
+ attr_reader :original_exception
56
+
57
+ def initialize(message, original_exception: nil)
58
+ @original_exception = original_exception
59
+ super(message)
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+
5
+ module Lettermint
6
+ class HttpClient
7
+ def initialize(api_token:, base_url:, timeout:)
8
+ normalized_url = "#{base_url.chomp('/')}/"
9
+ @connection = Faraday.new(url: normalized_url) do |f|
10
+ f.request :json
11
+ f.response :json
12
+ f.options.timeout = timeout
13
+ f.options.open_timeout = timeout
14
+ f.headers = {
15
+ 'Content-Type' => 'application/json',
16
+ 'Accept' => 'application/json',
17
+ 'x-lettermint-token' => api_token,
18
+ 'User-Agent' => "Lettermint/#{Lettermint::VERSION} (Ruby; ruby #{RUBY_VERSION})"
19
+ }
20
+ end
21
+ end
22
+
23
+ def get(path:, params: nil, headers: nil)
24
+ with_error_handling do
25
+ @connection.get(path.delete_prefix('/')) do |req|
26
+ req.params = params if params
27
+ req.headers.update(headers) if headers
28
+ end
29
+ end
30
+ end
31
+
32
+ def post(path:, data: nil, headers: nil)
33
+ with_error_handling do
34
+ @connection.post(path.delete_prefix('/')) do |req|
35
+ req.body = data if data
36
+ req.headers.update(headers) if headers
37
+ end
38
+ end
39
+ end
40
+
41
+ def put(path:, data: nil, headers: nil)
42
+ with_error_handling do
43
+ @connection.put(path.delete_prefix('/')) do |req|
44
+ req.body = data if data
45
+ req.headers.update(headers) if headers
46
+ end
47
+ end
48
+ end
49
+
50
+ def delete(path:, headers: nil)
51
+ with_error_handling do
52
+ @connection.delete(path.delete_prefix('/')) do |req|
53
+ req.headers.update(headers) if headers
54
+ end
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def with_error_handling
61
+ response = yield
62
+ handle_response(response)
63
+ rescue Faraday::TimeoutError, Timeout::Error
64
+ raise Lettermint::TimeoutError, "Request timeout after #{@connection.options.timeout}s"
65
+ rescue Faraday::ConnectionFailed => e
66
+ raise Lettermint::Error, e.message
67
+ end
68
+
69
+ def handle_response(response)
70
+ return response.body if response.success?
71
+
72
+ body = response.body.is_a?(Hash) ? response.body : nil
73
+ raise_api_error(response.status, body, response.headers)
74
+ end
75
+
76
+ def raise_api_error(status, body, headers)
77
+ raise build_error(status, body, headers)
78
+ end
79
+
80
+ def build_error(status, body, headers) # rubocop:disable Metrics
81
+ msg = ->(key, fallback) { body&.dig(key) || fallback }
82
+
83
+ case status
84
+ when 401, 403
85
+ AuthenticationError.new(message: msg['message', "HTTP #{status}"],
86
+ status_code: status, response_body: body)
87
+ when 400
88
+ ClientError.new(message: msg['error', 'Unknown client error'], response_body: body)
89
+ when 422
90
+ ValidationError.new(message: msg['message', 'Validation error'],
91
+ error_type: msg['error', 'ValidationError'], response_body: body)
92
+ when 429
93
+ retry_after = headers && headers['Retry-After'] && Integer(headers['Retry-After'], exception: false)
94
+ RateLimitError.new(message: msg['message', 'Rate limit exceeded'],
95
+ retry_after: retry_after, response_body: body)
96
+ else
97
+ HttpRequestError.new(message: msg['message', "HTTP #{status}"],
98
+ status_code: status, response_body: body)
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lettermint
4
+ SendEmailResponse = Data.define(:message_id, :status) do
5
+ def self.from_hash(hash)
6
+ raise Lettermint::Error, 'Empty response body from API' if hash.nil?
7
+ raise Lettermint::Error, "Unexpected response type: #{hash.class}" unless hash.is_a?(Hash)
8
+
9
+ new(message_id: hash['message_id'], status: hash['status'])
10
+ end
11
+ end
12
+
13
+ EmailAttachment = Data.define(:filename, :content, :content_id) do
14
+ def initialize(filename:, content:, content_id: nil)
15
+ super
16
+ end
17
+
18
+ def to_h
19
+ h = { filename: filename, content: content }
20
+ h[:content_id] = content_id if content_id
21
+ h
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lettermint
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'openssl'
5
+
6
+ module Lettermint
7
+ class Webhook
8
+ DEFAULT_TOLERANCE = 300
9
+
10
+ SIGNATURE_HEADER = 'x-lettermint-signature'
11
+ DELIVERY_HEADER = 'x-lettermint-delivery'
12
+
13
+ def initialize(secret:, tolerance: DEFAULT_TOLERANCE)
14
+ raise ArgumentError, 'Webhook secret cannot be empty' if secret.nil? || secret.empty?
15
+
16
+ @secret = secret
17
+ @tolerance = tolerance
18
+ end
19
+
20
+ def verify(payload, signature, timestamp: nil)
21
+ ts, sig_hash = parse_signature(signature)
22
+
23
+ raise WebhookVerificationError, 'Timestamp mismatch between header and signature' if timestamp && timestamp != ts
24
+
25
+ validate_timestamp(ts)
26
+ validate_signature(payload, ts, sig_hash)
27
+ parse_payload(payload)
28
+ end
29
+
30
+ def verify_headers(headers, payload)
31
+ normalized = headers.transform_keys(&:downcase)
32
+
33
+ signature = normalized[SIGNATURE_HEADER]
34
+ delivery = normalized[DELIVERY_HEADER]
35
+
36
+ raise WebhookVerificationError, "Missing #{SIGNATURE_HEADER} header" unless signature
37
+ raise WebhookVerificationError, "Missing #{DELIVERY_HEADER} header" unless delivery
38
+
39
+ ts = begin
40
+ Integer(delivery)
41
+ rescue ArgumentError, TypeError
42
+ raise WebhookVerificationError, "Invalid #{DELIVERY_HEADER} header value"
43
+ end
44
+
45
+ verify(payload, signature, timestamp: ts)
46
+ end
47
+
48
+ def self.verify_signature(payload, signature, secret:, timestamp: nil, tolerance: DEFAULT_TOLERANCE)
49
+ new(secret: secret, tolerance: tolerance).verify(payload, signature, timestamp: timestamp)
50
+ end
51
+
52
+ private
53
+
54
+ def parse_signature(signature)
55
+ raise WebhookVerificationError, 'Invalid signature format' if signature.nil?
56
+
57
+ parts = split_signature_parts(signature)
58
+ sig_ts = Integer(parts['t'])
59
+ sig_hash = parts['v1']
60
+ raise WebhookVerificationError, 'Invalid signature format' unless sig_hash
61
+
62
+ [sig_ts, sig_hash]
63
+ rescue ArgumentError, TypeError
64
+ raise WebhookVerificationError, 'Invalid signature format'
65
+ end
66
+
67
+ def split_signature_parts(signature)
68
+ signature.split(',').each_with_object({}) do |part, hash|
69
+ key, value = part.split('=', 2)
70
+ hash[key.strip] = value&.strip if key && value
71
+ end
72
+ end
73
+
74
+ def validate_timestamp(sig_ts)
75
+ difference = (Time.now.to_i - sig_ts).abs
76
+ return if difference <= @tolerance
77
+
78
+ raise TimestampToleranceError, 'Webhook timestamp is too old or too far in the future'
79
+ end
80
+
81
+ def validate_signature(payload, sig_ts, expected_hash)
82
+ signed_content = "#{sig_ts}.#{payload}"
83
+ computed = OpenSSL::HMAC.hexdigest('SHA256', @secret, signed_content)
84
+
85
+ return if OpenSSL.secure_compare(computed, expected_hash)
86
+
87
+ raise InvalidSignatureError, 'Signature verification failed'
88
+ end
89
+
90
+ def parse_payload(payload)
91
+ JSON.parse(payload)
92
+ rescue JSON::ParserError => e
93
+ raise WebhookJsonDecodeError.new('Failed to parse webhook payload as JSON', original_exception: e)
94
+ end
95
+ end
96
+ end
data/lib/lettermint.rb ADDED
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lettermint/version'
4
+ require_relative 'lettermint/errors'
5
+ require_relative 'lettermint/types'
6
+ require_relative 'lettermint/configuration'
7
+ require_relative 'lettermint/http_client'
8
+ require_relative 'lettermint/email_message'
9
+ require_relative 'lettermint/webhook'
10
+ require_relative 'lettermint/client'
11
+
12
+ module Lettermint
13
+ class << self
14
+ def configure
15
+ yield configuration if block_given?
16
+ configuration
17
+ end
18
+
19
+ def configuration
20
+ @configuration ||= Configuration.new
21
+ end
22
+
23
+ def reset_configuration!
24
+ @configuration = nil
25
+ end
26
+ end
27
+ end
metadata ADDED
@@ -0,0 +1,73 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lettermint
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Delano
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: faraday
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.0'
26
+ description: Send transactional emails and verify webhooks with the Lettermint API.
27
+ Provides a fluent builder interface, typed responses, and HMAC-SHA256 webhook verification.
28
+ email:
29
+ - gems@onetimesecret.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - CHANGELOG.md
35
+ - LICENSE
36
+ - README.md
37
+ - lettermint.gemspec
38
+ - lib/lettermint.rb
39
+ - lib/lettermint/client.rb
40
+ - lib/lettermint/configuration.rb
41
+ - lib/lettermint/email_message.rb
42
+ - lib/lettermint/errors.rb
43
+ - lib/lettermint/http_client.rb
44
+ - lib/lettermint/types.rb
45
+ - lib/lettermint/version.rb
46
+ - lib/lettermint/webhook.rb
47
+ homepage: https://github.com/onetimesecret/lettermint-ruby
48
+ licenses:
49
+ - MIT
50
+ metadata:
51
+ homepage_uri: https://github.com/onetimesecret/lettermint-ruby
52
+ source_code_uri: https://github.com/onetimesecret/lettermint-ruby
53
+ changelog_uri: https://github.com/onetimesecret/lettermint-ruby/blob/main/CHANGELOG.md
54
+ rubygems_mfa_required: 'true'
55
+ allowed_push_host: https://rubygems.org
56
+ rdoc_options: []
57
+ require_paths:
58
+ - lib
59
+ required_ruby_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: 3.2.0
64
+ required_rubygems_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ requirements: []
70
+ rubygems_version: 3.6.9
71
+ specification_version: 4
72
+ summary: Ruby SDK for the Lettermint transactional email API
73
+ test_files: []