letterapp 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/LICENSE +21 -0
- data/README.md +93 -0
- data/lib/letterapp/client.rb +260 -0
- data/lib/letterapp/version.rb +5 -0
- data/lib/letterapp.rb +19 -0
- metadata +48 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f34428952a2c04f306b6202d26e5fed5537a347aef6b5682d893eacd2b5d148c
|
|
4
|
+
data.tar.gz: f21c70172c12e83daf530971113ae379c385b18a2101189546c97cd1c978d70e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 04155060eef72dc6631a404abf9ea37327a7584642d576f52e43245c1d95729c31a60728589fd750839ddd8c5279b5d8e3bc7bfafc8316042f33fbde0ff0021b
|
|
7
|
+
data.tar.gz: a91ef9c7620135df31e75a05b4164b78e17feae3630f89b14f17a92e36e87a0d0dde7fd9ceb57abf5b38ae3746f28f9dd1f7b0f34111399531121d7c4a60ccd0
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 letter.app
|
|
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,93 @@
|
|
|
1
|
+
# letterapp (Ruby)
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/letterapp)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
|
|
6
|
+
Official Ruby client for **[letter.app](https://letter.app)** - onboarding
|
|
7
|
+
email drip campaigns for product teams.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bundle add letterapp
|
|
11
|
+
# or: gem install letterapp
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Requires Ruby **3.0+**. Zero runtime dependencies (standard library only).
|
|
15
|
+
|
|
16
|
+
## Quick start
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
require "letterapp"
|
|
20
|
+
|
|
21
|
+
letter = Letterapp::Client.new(api_key: ENV["LETTER_API_KEY"]) # Dashboard -> Settings -> API keys
|
|
22
|
+
|
|
23
|
+
# Tell Letter who your user is (call where users sign up or log in).
|
|
24
|
+
letter.identify(
|
|
25
|
+
user_id: "user_123",
|
|
26
|
+
email: "alice@example.com",
|
|
27
|
+
traits: { name: "Alice", plan: "free" }
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Report something they did.
|
|
31
|
+
letter.track(user_id: "user_123", event: "Signed Up", properties: { source: "web" })
|
|
32
|
+
|
|
33
|
+
# Required before the process exits so no events are lost.
|
|
34
|
+
letter.close
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Serverless (Lambda, Cloud Functions)
|
|
38
|
+
|
|
39
|
+
There is no background time to flush in a serverless handler, so set
|
|
40
|
+
`flush_at: 1` and `flush` at the end of each invocation:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
letter = Letterapp::Client.new(api_key: ENV["LETTER_API_KEY"], flush_at: 1)
|
|
44
|
+
|
|
45
|
+
def handler(event:, context:)
|
|
46
|
+
letter.track(user_id: "user_123", event: "Checkout Started")
|
|
47
|
+
letter.flush
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## What it does
|
|
52
|
+
|
|
53
|
+
- **Auto-batching** - calls are queued and flushed every 100ms or 50 events by
|
|
54
|
+
a background thread.
|
|
55
|
+
- **Retries** - `429` waits `Retry-After`; `5xx` and network errors back off
|
|
56
|
+
exponentially with jitter, up to `max_retries` (default 3).
|
|
57
|
+
- **Idempotent** - every call gets a UUID `message_id` so retries are
|
|
58
|
+
deduplicated server-side.
|
|
59
|
+
- **No dependencies** - HTTP over the standard library `net/http`.
|
|
60
|
+
|
|
61
|
+
## API
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
Letterapp::Client.new(
|
|
65
|
+
api_key:,
|
|
66
|
+
base_url: "https://api.letter.app", # only set for self-hosted / local
|
|
67
|
+
flush_at: 50, # 1 for serverless
|
|
68
|
+
flush_interval: 0.1, # seconds
|
|
69
|
+
max_retries: 3,
|
|
70
|
+
open_timeout: 10,
|
|
71
|
+
read_timeout: 10,
|
|
72
|
+
on_error: nil # ->(error) for background errors
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
letter.identify(user_id:, email: nil, traits: nil, timezone: nil, timestamp: nil, message_id: nil)
|
|
76
|
+
letter.group(user_id:, account_id:, name: nil, traits: nil, timestamp: nil, message_id: nil)
|
|
77
|
+
letter.track(user_id:, event:, properties: nil, timestamp: nil, message_id: nil)
|
|
78
|
+
letter.flush # send queued calls now, block until done
|
|
79
|
+
letter.close # flush + stop the background thread (also runs at exit)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Configuration errors and non-retryable API responses raise `Letterapp::Error`
|
|
83
|
+
(with `#status` and `#body`). Background transport errors are passed to
|
|
84
|
+
`on_error` instead, since they cannot be raised to the caller.
|
|
85
|
+
|
|
86
|
+
## Full documentation
|
|
87
|
+
|
|
88
|
+
- **SDK reference:** <https://letter.app/docs/ruby-sdk>
|
|
89
|
+
- **Ingestion API:** <https://letter.app/docs/api>
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
|
|
93
|
+
MIT - see [LICENSE](./LICENSE).
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "time"
|
|
7
|
+
require "uri"
|
|
8
|
+
|
|
9
|
+
require_relative "version"
|
|
10
|
+
|
|
11
|
+
module Letterapp
|
|
12
|
+
DEFAULT_BASE_URL = "https://api.letter.app"
|
|
13
|
+
|
|
14
|
+
# Raised for client misconfiguration and non-retryable API errors.
|
|
15
|
+
class Error < StandardError
|
|
16
|
+
attr_reader :status, :body
|
|
17
|
+
|
|
18
|
+
def initialize(message, status: nil, body: nil)
|
|
19
|
+
super(message)
|
|
20
|
+
@status = status
|
|
21
|
+
@body = body
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# A Letter ingestion client.
|
|
26
|
+
#
|
|
27
|
+
# Long-running server (default): identify / group / track enqueue calls that a
|
|
28
|
+
# background thread auto-batches and flushes every 100ms or 50 events. Call
|
|
29
|
+
# +close+ before the process exits.
|
|
30
|
+
#
|
|
31
|
+
# Serverless: pass flush_at: 1 and call +flush+ at the end of each invocation.
|
|
32
|
+
class Client
|
|
33
|
+
# @param api_key [String] API key (Dashboard -> Settings -> API keys).
|
|
34
|
+
# @param base_url [String] API origin. Defaults to https://api.letter.app.
|
|
35
|
+
# @param flush_at [Integer] Flush after this many queued events (1 = serverless).
|
|
36
|
+
# @param flush_interval [Float] Seconds between background flushes.
|
|
37
|
+
# @param max_retries [Integer] Max retry attempts per request.
|
|
38
|
+
# @param on_error [#call] Callback for background transport errors.
|
|
39
|
+
def initialize(api_key:, base_url: DEFAULT_BASE_URL, flush_at: 50,
|
|
40
|
+
flush_interval: 0.1, max_retries: 3, open_timeout: 10,
|
|
41
|
+
read_timeout: 10, on_error: nil)
|
|
42
|
+
raise Error, "api_key is required" if api_key.nil? || api_key.to_s.empty?
|
|
43
|
+
|
|
44
|
+
@api_key = api_key
|
|
45
|
+
@base_url = (base_url || DEFAULT_BASE_URL).sub(%r{/+\z}, "")
|
|
46
|
+
@flush_at = [1, flush_at.to_i].max
|
|
47
|
+
@flush_interval = flush_interval.to_f
|
|
48
|
+
@max_retries = max_retries.to_i
|
|
49
|
+
@open_timeout = open_timeout
|
|
50
|
+
@read_timeout = read_timeout
|
|
51
|
+
@on_error = on_error || ->(err) { warn("[letter] #{err.message}") }
|
|
52
|
+
|
|
53
|
+
@uri = URI.parse(@base_url)
|
|
54
|
+
@queue = []
|
|
55
|
+
@mutex = Mutex.new
|
|
56
|
+
@cond = ConditionVariable.new
|
|
57
|
+
@send_mutex = Mutex.new
|
|
58
|
+
@closed = false
|
|
59
|
+
|
|
60
|
+
@thread = Thread.new { loop_run }
|
|
61
|
+
at_exit { close }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Queue an identify call (creates or updates a contact).
|
|
65
|
+
def identify(user_id:, email: nil, traits: nil, timezone: nil,
|
|
66
|
+
timestamp: nil, message_id: nil)
|
|
67
|
+
enqueue(serialize_identify(user_id, email, traits, timezone, timestamp, message_id))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Queue a group call (associates a contact with an account).
|
|
71
|
+
def group(user_id:, account_id:, name: nil, traits: nil,
|
|
72
|
+
timestamp: nil, message_id: nil)
|
|
73
|
+
enqueue(serialize_group(user_id, account_id, name, traits, timestamp, message_id))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Queue a track call (records an event).
|
|
77
|
+
def track(user_id:, event:, properties: nil, timestamp: nil, message_id: nil)
|
|
78
|
+
enqueue(serialize_track(user_id, event, properties, timestamp, message_id))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Send everything currently queued and block until it completes.
|
|
82
|
+
def flush
|
|
83
|
+
loop do
|
|
84
|
+
batch = take_batch
|
|
85
|
+
break if batch.nil?
|
|
86
|
+
|
|
87
|
+
@send_mutex.synchronize { send_batch(batch) }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Flush, stop the background thread, and block until drained.
|
|
92
|
+
def close
|
|
93
|
+
@mutex.synchronize do
|
|
94
|
+
return if @closed
|
|
95
|
+
|
|
96
|
+
@closed = true
|
|
97
|
+
@cond.broadcast
|
|
98
|
+
end
|
|
99
|
+
@thread&.join(@read_timeout * (@max_retries + 2))
|
|
100
|
+
flush
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def enqueue(item)
|
|
106
|
+
@mutex.synchronize do
|
|
107
|
+
if @closed
|
|
108
|
+
@on_error.call(Error.new("Letter client is closed."))
|
|
109
|
+
return
|
|
110
|
+
end
|
|
111
|
+
@queue << item
|
|
112
|
+
@cond.signal
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def take_batch
|
|
117
|
+
@mutex.synchronize do
|
|
118
|
+
return nil if @queue.empty?
|
|
119
|
+
|
|
120
|
+
@queue.shift([@queue.length, 100].min)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def loop_run
|
|
125
|
+
loop do
|
|
126
|
+
@mutex.synchronize do
|
|
127
|
+
@cond.wait(@mutex) while @queue.empty? && !@closed
|
|
128
|
+
return if @closed && @queue.empty?
|
|
129
|
+
|
|
130
|
+
# Below the size threshold: wait briefly to batch more before sending.
|
|
131
|
+
@cond.wait(@mutex, @flush_interval) if @queue.length < @flush_at && !@closed
|
|
132
|
+
end
|
|
133
|
+
flush
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def send_batch(batch)
|
|
138
|
+
request("/v1/batch", { "batch" => batch })
|
|
139
|
+
rescue StandardError => e
|
|
140
|
+
@on_error.call(e.is_a?(Error) ? e : Error.new(e.message))
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def request(path, body)
|
|
144
|
+
data = JSON.generate(body)
|
|
145
|
+
last_err = nil
|
|
146
|
+
|
|
147
|
+
(0..@max_retries).each do |attempt|
|
|
148
|
+
begin
|
|
149
|
+
res = post(path, data)
|
|
150
|
+
code = res.code.to_i
|
|
151
|
+
return if code >= 200 && code < 300
|
|
152
|
+
|
|
153
|
+
if code == 429 && attempt < @max_retries
|
|
154
|
+
sleep(retry_after(res))
|
|
155
|
+
next
|
|
156
|
+
end
|
|
157
|
+
if code >= 500 && attempt < @max_retries
|
|
158
|
+
sleep(backoff(attempt))
|
|
159
|
+
next
|
|
160
|
+
end
|
|
161
|
+
raise Error.new(
|
|
162
|
+
"Request failed with #{code}: #{res.body}", status: code, body: res.body
|
|
163
|
+
)
|
|
164
|
+
rescue Timeout::Error, IOError, SystemCallError, SocketError => e
|
|
165
|
+
last_err = Error.new("Network error: #{e.message}")
|
|
166
|
+
raise last_err if attempt >= @max_retries
|
|
167
|
+
|
|
168
|
+
sleep(backoff(attempt))
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
raise(last_err || Error.new("Request failed for unknown reason"))
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def post(path, data)
|
|
176
|
+
http = Net::HTTP.new(@uri.host, @uri.port)
|
|
177
|
+
http.use_ssl = (@uri.scheme == "https")
|
|
178
|
+
http.open_timeout = @open_timeout
|
|
179
|
+
http.read_timeout = @read_timeout
|
|
180
|
+
|
|
181
|
+
req = Net::HTTP::Post.new(path)
|
|
182
|
+
req["Content-Type"] = "application/json"
|
|
183
|
+
req["Authorization"] = "Bearer #{@api_key}"
|
|
184
|
+
req["User-Agent"] = "letterapp-ruby/#{Letterapp::VERSION}"
|
|
185
|
+
req.body = data
|
|
186
|
+
http.request(req)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def serialize_identify(user_id, email, traits, timezone, timestamp, message_id)
|
|
190
|
+
raise Error, "identify: user_id is required" if blank?(user_id)
|
|
191
|
+
|
|
192
|
+
item = {
|
|
193
|
+
"type" => "identify",
|
|
194
|
+
"userId" => user_id,
|
|
195
|
+
"traits" => traits || {},
|
|
196
|
+
"messageId" => message_id || SecureRandom.uuid
|
|
197
|
+
}
|
|
198
|
+
item["email"] = email unless email.nil?
|
|
199
|
+
item["timezone"] = timezone unless timezone.nil?
|
|
200
|
+
iso = to_iso(timestamp)
|
|
201
|
+
item["timestamp"] = iso unless iso.nil?
|
|
202
|
+
item
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def serialize_group(user_id, account_id, name, traits, timestamp, message_id)
|
|
206
|
+
raise Error, "group: user_id is required" if blank?(user_id)
|
|
207
|
+
raise Error, "group: account_id is required" if blank?(account_id)
|
|
208
|
+
|
|
209
|
+
item = {
|
|
210
|
+
"type" => "group",
|
|
211
|
+
"userId" => user_id,
|
|
212
|
+
"accountId" => account_id,
|
|
213
|
+
"traits" => traits || {},
|
|
214
|
+
"messageId" => message_id || SecureRandom.uuid
|
|
215
|
+
}
|
|
216
|
+
item["name"] = name unless name.nil?
|
|
217
|
+
iso = to_iso(timestamp)
|
|
218
|
+
item["timestamp"] = iso unless iso.nil?
|
|
219
|
+
item
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def serialize_track(user_id, event, properties, timestamp, message_id)
|
|
223
|
+
raise Error, "track: user_id is required" if blank?(user_id)
|
|
224
|
+
raise Error, "track: event is required" if blank?(event)
|
|
225
|
+
|
|
226
|
+
item = {
|
|
227
|
+
"type" => "track",
|
|
228
|
+
"userId" => user_id,
|
|
229
|
+
"event" => event,
|
|
230
|
+
"properties" => properties || {},
|
|
231
|
+
"messageId" => message_id || SecureRandom.uuid
|
|
232
|
+
}
|
|
233
|
+
iso = to_iso(timestamp)
|
|
234
|
+
item["timestamp"] = iso unless iso.nil?
|
|
235
|
+
item
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def to_iso(value)
|
|
239
|
+
return nil if value.nil?
|
|
240
|
+
return value.iso8601 if value.respond_to?(:iso8601)
|
|
241
|
+
|
|
242
|
+
value.to_s
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def blank?(value)
|
|
246
|
+
value.nil? || value.to_s.empty?
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def retry_after(res)
|
|
250
|
+
raw = res["retry-after"]
|
|
251
|
+
Float(raw)
|
|
252
|
+
rescue ArgumentError, TypeError
|
|
253
|
+
1.0
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def backoff(attempt)
|
|
257
|
+
(0.25 * (2**attempt)) + (rand * 0.1)
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
data/lib/letterapp.rb
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "letterapp/version"
|
|
4
|
+
require_relative "letterapp/client"
|
|
5
|
+
|
|
6
|
+
# Official Ruby client for letter.app.
|
|
7
|
+
#
|
|
8
|
+
# require "letterapp"
|
|
9
|
+
#
|
|
10
|
+
# letter = Letterapp::Client.new(api_key: ENV["LETTER_API_KEY"])
|
|
11
|
+
# letter.identify(user_id: "user_123", email: "alice@example.com")
|
|
12
|
+
# letter.track(user_id: "user_123", event: "Signed Up")
|
|
13
|
+
# letter.close
|
|
14
|
+
module Letterapp
|
|
15
|
+
# Convenience: Letterapp.new(...) == Letterapp::Client.new(...)
|
|
16
|
+
def self.new(**kwargs)
|
|
17
|
+
Client.new(**kwargs)
|
|
18
|
+
end
|
|
19
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: letterapp
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- letter.app
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: 'Auto-batching Ruby client for letter.app: identify users, track events,
|
|
13
|
+
and group accounts over the ingestion API. Retries, idempotency, zero runtime dependencies.'
|
|
14
|
+
executables: []
|
|
15
|
+
extensions: []
|
|
16
|
+
extra_rdoc_files: []
|
|
17
|
+
files:
|
|
18
|
+
- LICENSE
|
|
19
|
+
- README.md
|
|
20
|
+
- lib/letterapp.rb
|
|
21
|
+
- lib/letterapp/client.rb
|
|
22
|
+
- lib/letterapp/version.rb
|
|
23
|
+
homepage: https://letter.app/docs/ruby-sdk
|
|
24
|
+
licenses:
|
|
25
|
+
- MIT
|
|
26
|
+
metadata:
|
|
27
|
+
homepage_uri: https://letter.app/docs/ruby-sdk
|
|
28
|
+
source_code_uri: https://github.com/vincenzor/letter-ruby
|
|
29
|
+
bug_tracker_uri: https://github.com/vincenzor/letter-ruby/issues
|
|
30
|
+
rubygems_mfa_required: 'true'
|
|
31
|
+
rdoc_options: []
|
|
32
|
+
require_paths:
|
|
33
|
+
- lib
|
|
34
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '3.0'
|
|
39
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
40
|
+
requirements:
|
|
41
|
+
- - ">="
|
|
42
|
+
- !ruby/object:Gem::Version
|
|
43
|
+
version: '0'
|
|
44
|
+
requirements: []
|
|
45
|
+
rubygems_version: 4.0.4
|
|
46
|
+
specification_version: 4
|
|
47
|
+
summary: Official Ruby client for letter.app - onboarding email drip campaigns.
|
|
48
|
+
test_files: []
|