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 +7 -0
- data/CHANGELOG.md +26 -0
- data/LICENSE +21 -0
- data/README.md +325 -0
- data/lettermint.gemspec +35 -0
- data/lib/lettermint/client.rb +33 -0
- data/lib/lettermint/configuration.rb +15 -0
- data/lib/lettermint/email_message.rb +126 -0
- data/lib/lettermint/errors.rb +62 -0
- data/lib/lettermint/http_client.rb +102 -0
- data/lib/lettermint/types.rb +24 -0
- data/lib/lettermint/version.rb +5 -0
- data/lib/lettermint/webhook.rb +96 -0
- data/lib/lettermint.rb +27 -0
- metadata +73 -0
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
|
+
[](https://rubygems.org/gems/lettermint)
|
|
4
|
+
[](https://www.ruby-lang.org)
|
|
5
|
+
[](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
|
data/lettermint.gemspec
ADDED
|
@@ -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,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: []
|