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 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
+ [![Gem Version](https://img.shields.io/gem/v/letterapp)](https://rubygems.org/gems/letterapp)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green)](./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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letterapp
4
+ VERSION = "0.1.0"
5
+ 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: []