loomail 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 +22 -0
- data/README.md +86 -0
- data/lib/loomail/client.rb +268 -0
- data/lib/loomail/deliver_job.rb +25 -0
- data/lib/loomail/error.rb +36 -0
- data/lib/loomail/railtie.rb +30 -0
- data/lib/loomail/version.rb +5 -0
- data/lib/loomail.rb +97 -0
- metadata +125 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: bee25d3570620ba5495c50774a6f520f0430acef33f7dd936714d52894ead06d
|
|
4
|
+
data.tar.gz: 9774e9f7cb1062b005f33b51d0e987aadc68adb7644a3a10949f6e769a74d16d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: fc3885e6300e3b078dfa7eb6838b19ff89c952c5958afe4f1931ca97df4d716fd35792b34d6e63828740b6835783e87480fa4d42ad10ffff82d059c3d376aa1b
|
|
7
|
+
data.tar.gz: 4a89471900043ce6180fdacf4f4f6d0dc84bf90d15742f96b284b33a9cfefd9e122184348350f0a374a54791dba051875cabb576dffa73af92cad1c3ecc2b1ac
|
data/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Loomail
|
|
4
|
+
Copyright (c) 2026 Claros (original work)
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# loomail (Ruby and Rails)
|
|
2
|
+
|
|
3
|
+
Server-side client for [Loomail](https://loomail.site). Ruby 3.0+, no dependencies (Net::HTTP), with a Rails integration.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
gem install loomail # or: bundle add loomail
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
loomail = Loomail::Client.new(secret_key: ENV["LOOMAIL_SECRET_KEY"]) # mf_live_...
|
|
11
|
+
|
|
12
|
+
loomail.identify("user_123", { email: "a@b.c", plan: "trial" })
|
|
13
|
+
loomail.track("user_123", "signed_up", { source: "ads" })
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Use a **secret** key (`mf_live_...`). A publishable key (`mf_pub_...`) belongs in the browser snippet, and the client refuses it.
|
|
17
|
+
|
|
18
|
+
## Rails
|
|
19
|
+
|
|
20
|
+
The gem is picked up automatically. Give it the key in any one of these:
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
# config/application.rb or an environment file
|
|
24
|
+
config.loomail.secret_key = ENV["LOOMAIL_SECRET_KEY"]
|
|
25
|
+
config.loomail.host = "https://loomail.example.com" # only for a self-hosted install
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```yaml
|
|
29
|
+
# rails credentials:edit
|
|
30
|
+
loomail:
|
|
31
|
+
secret_key: mf_live_...
|
|
32
|
+
host: https://loomail.example.com
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
or just set `LOOMAIL_SECRET_KEY` (and `LOOMAIL_HOST`) in the environment. Then, anywhere:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
Loomail.identify(user.id, email: user.email, plan: "trial")
|
|
39
|
+
Loomail.track(user.id, "signed_up")
|
|
40
|
+
|
|
41
|
+
# on a background job (ActiveJob), so a Loomail outage never slows the request:
|
|
42
|
+
Loomail.track_later(user.id, "invoice_paid", { amount: 4900 }, message_id: "paid:#{invoice.id}")
|
|
43
|
+
Loomail.identify_later(user.id, email: user.email)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`track_later` fixes the `message_id` when it queues the job, so a retried job is recognised and not counted twice. An outage or rate limit is retried with a growing wait (5 attempts); a refusal that retrying cannot fix (a bad key, a plan limit) is logged and dropped.
|
|
47
|
+
|
|
48
|
+
Outside Rails, `Loomail.configure { |c| c.secret_key = "..." }` sets the shared client the same way.
|
|
49
|
+
|
|
50
|
+
## Options
|
|
51
|
+
|
|
52
|
+
| Option | Default | |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| `secret_key:` | `LOOMAIL_SECRET_KEY` | Required. |
|
|
55
|
+
| `host:` | `LOOMAIL_HOST`, then `https://loomail.site` | Your Loomail install. |
|
|
56
|
+
| `timeout:` | `10` | Seconds per attempt. |
|
|
57
|
+
| `max_retries:` | `3` | On network errors, 429 and 5xx, with backoff; `Retry-After` is honoured. |
|
|
58
|
+
|
|
59
|
+
## Calls
|
|
60
|
+
|
|
61
|
+
- `identify(user_id, traits = {}, timestamp:, message_id:, context:)` creates or updates a person. Traits merge; a `nil` trait removes it. Put `email` here so the person can be mailed, and `payment_status` / `plan` to drive lifecycle flows.
|
|
62
|
+
- `track(user_id, event, properties = {}, timestamp:, message_id:, context:)` records something a person did.
|
|
63
|
+
- `batch(items)` sends many items, split into requests of 100 for you. Refused items come back in `errors` with their position; the rest go through.
|
|
64
|
+
|
|
65
|
+
Traits and properties can be a Hash or plain keywords (`identify("u1", email: "a@b.c")`). The names `timestamp`, `message_id` and `context` are the options; a trait with one of those names has to go in a Hash.
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
result = loomail.batch([
|
|
69
|
+
{ type: :identify, user_id: "u1", traits: { plan: "pro" } },
|
|
70
|
+
{ type: :track, user_id: "u1", event: "upgraded", properties: { plan: "pro" }, message_id: "up:1" }
|
|
71
|
+
])
|
|
72
|
+
result.received # 2
|
|
73
|
+
result.errors # [{ index: 0, message: "...", code: "plan_limit" }, ...]
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Retries never double-count
|
|
77
|
+
|
|
78
|
+
Every call carries a `messageId` (generated if you don't give one), and Loomail drops a repeat. So a retried request, or the same request sent twice, records once. To make a repeat safe across restarts too, pass your own: `message_id: "paid:#{invoice.id}"`. The result tells you when it was a repeat: `result.deduplicated?`.
|
|
79
|
+
|
|
80
|
+
## Errors
|
|
81
|
+
|
|
82
|
+
Calls raise `Loomail::Error` with `#code`, plus `#status` and `#body` when Loomail answered:
|
|
83
|
+
|
|
84
|
+
`:invalid_argument` (caught before any request) · `:unauthorized` (401) · `:forbidden` (403) · `:plan_limit` (402, the workspace is at its contact limit) · `:validation` (400/422) · `:rate_limited` (429) · `:server` (5xx) · `:network`.
|
|
85
|
+
|
|
86
|
+
Only 429, 5xx and network errors are retried; the others are mistakes that retrying cannot fix. Events more than 72 hours from Loomail's clock are re-dated to now by the server.
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "time"
|
|
7
|
+
require "uri"
|
|
8
|
+
|
|
9
|
+
module Loomail
|
|
10
|
+
# Server-side client for Loomail's ingestion API.
|
|
11
|
+
#
|
|
12
|
+
# Sends identify and track calls with a secret key (mf_live_...), retries what is safe to retry, and never
|
|
13
|
+
# double-counts: every call carries a message_id, which Loomail uses to drop repeats.
|
|
14
|
+
#
|
|
15
|
+
# loomail = Loomail::Client.new(secret_key: ENV["LOOMAIL_SECRET_KEY"])
|
|
16
|
+
# loomail.identify("user_123", { email: "a@b.c", plan: "trial" })
|
|
17
|
+
# loomail.track("user_123", "signed_up", { source: "ads" })
|
|
18
|
+
#
|
|
19
|
+
# Methods raise Loomail::Error when Loomail cannot be reached or refuses the call. In a web request that means a
|
|
20
|
+
# Loomail outage can fail the request, so use Loomail.track_later (Rails) or rescue Loomail::Error for fire-and-forget.
|
|
21
|
+
class Client
|
|
22
|
+
DEFAULT_HOST = "https://loomail.site"
|
|
23
|
+
# Loomail rejects a batch of more than 100 items or more than 512 KB.
|
|
24
|
+
MAX_BATCH_ITEMS = 100
|
|
25
|
+
MAX_BATCH_BYTES = 400 * 1024 # headroom under the server's 512 KB body limit
|
|
26
|
+
|
|
27
|
+
# secret_key: mf_live_... (default ENV["LOOMAIL_SECRET_KEY"]). Never use it in a browser.
|
|
28
|
+
# host: your Loomail install (default ENV["LOOMAIL_HOST"], then https://loomail.site)
|
|
29
|
+
# timeout: seconds per attempt (default 10)
|
|
30
|
+
# max_retries: retries after the first attempt, on network errors, 429 and 5xx (default 3)
|
|
31
|
+
# transport: replaces Net::HTTP (tests, proxies): callable(url, headers, body, timeout) returning
|
|
32
|
+
# [status, headers_hash_with_lowercase_keys, body_string], raising Loomail::Client::TransportError
|
|
33
|
+
# sleeper: replaces the wait between retries (tests): callable(seconds)
|
|
34
|
+
def initialize(secret_key: nil, host: nil, timeout: 10, max_retries: 3, transport: nil, sleeper: nil)
|
|
35
|
+
key = secret_key || ENV["LOOMAIL_SECRET_KEY"]
|
|
36
|
+
if key.nil? || key.empty?
|
|
37
|
+
raise Error.new(:invalid_argument, "secret_key is required (or set LOOMAIL_SECRET_KEY)")
|
|
38
|
+
end
|
|
39
|
+
if key.start_with?("mf_pub_")
|
|
40
|
+
raise Error.new(:invalid_argument,
|
|
41
|
+
"That is a publishable key (mf_pub_...), meant for browsers. Server code needs a secret key (mf_live_...).")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
@secret_key = key
|
|
45
|
+
host = host || ENV["LOOMAIL_HOST"]
|
|
46
|
+
host = DEFAULT_HOST if host.nil? || host.empty?
|
|
47
|
+
@host = host.sub(%r{/+\z}, "")
|
|
48
|
+
@timeout = timeout.to_f
|
|
49
|
+
@max_retries = [max_retries.to_i, 0].max
|
|
50
|
+
@transport = transport || method(:net_http)
|
|
51
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# No answer from the server: it could not be reached, or did not reply in time.
|
|
55
|
+
class TransportError < StandardError; end
|
|
56
|
+
|
|
57
|
+
OPTION_KEYS = %i[timestamp message_id context].freeze
|
|
58
|
+
|
|
59
|
+
# Create or update a person. Traits merge into what Loomail already knows; a nil trait removes it.
|
|
60
|
+
#
|
|
61
|
+
# client.identify("u1", { email: "a@b.c" }) # a Hash of traits
|
|
62
|
+
# client.identify("u1", email: "a@b.c") # or plain keywords
|
|
63
|
+
#
|
|
64
|
+
# The options timestamp: (Time, DateTime or String), message_id: and context: (Hash) are reserved words: a trait
|
|
65
|
+
# with one of those names has to go in the Hash form.
|
|
66
|
+
def identify(user_id, traits = {}, **keywords)
|
|
67
|
+
options, extra = split_keywords(keywords)
|
|
68
|
+
send_call("/v1/identify", identify_body(user_id, merge_data(traits, extra), options[:timestamp], options[:message_id], options[:context]))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Record something a person did. Properties go in a Hash or as plain keywords, like traits in #identify.
|
|
72
|
+
def track(user_id, event, properties = {}, **keywords)
|
|
73
|
+
options, extra = split_keywords(keywords)
|
|
74
|
+
send_call("/v1/track", track_body(user_id, event, merge_data(properties, extra), options[:timestamp], options[:message_id], options[:context]))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Send many identify/track items. Splits them into requests of at most 100 items (and under the size limit), so any
|
|
78
|
+
# length is fine. A refused item lands in the result's +errors+ and does not stop the rest; a request that fails
|
|
79
|
+
# outright (bad key, an outage after retries) raises.
|
|
80
|
+
#
|
|
81
|
+
# loomail.batch([
|
|
82
|
+
# { type: :identify, user_id: "u1", traits: { plan: "pro" } },
|
|
83
|
+
# { type: :track, user_id: "u1", event: "upgraded", properties: { plan: "pro" }, message_id: "up:1" }
|
|
84
|
+
# ])
|
|
85
|
+
def batch(items)
|
|
86
|
+
prepared = items.each_with_index.map do |item, i|
|
|
87
|
+
item = item.transform_keys(&:to_sym)
|
|
88
|
+
begin
|
|
89
|
+
case item[:type]&.to_s
|
|
90
|
+
when "identify"
|
|
91
|
+
identify_body(item[:user_id], item[:traits] || {}, item[:timestamp], item[:message_id], item[:context]).merge(type: "identify")
|
|
92
|
+
when "track"
|
|
93
|
+
track_body(item[:user_id], item[:event], item[:properties] || {}, item[:timestamp], item[:message_id], item[:context]).merge(type: "track")
|
|
94
|
+
else
|
|
95
|
+
raise Error.new(:invalid_argument, 'type must be "identify" or "track"')
|
|
96
|
+
end
|
|
97
|
+
rescue Error => e
|
|
98
|
+
raise Error.new(:invalid_argument, "batch[#{i}]: #{e.message}")
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
received = 0
|
|
103
|
+
errors = []
|
|
104
|
+
chunk = []
|
|
105
|
+
chunk_bytes = 0
|
|
106
|
+
chunk_start = 0
|
|
107
|
+
flush = lambda do
|
|
108
|
+
next if chunk.empty?
|
|
109
|
+
|
|
110
|
+
res = post("/v1/batch", { batch: chunk })
|
|
111
|
+
received += res["received"].to_i
|
|
112
|
+
Array(res["errors"]).each do |err|
|
|
113
|
+
entry = { index: err["index"].to_i + chunk_start, message: err["message"].to_s }
|
|
114
|
+
entry[:code] = err["code"] if err["code"]
|
|
115
|
+
errors << entry
|
|
116
|
+
end
|
|
117
|
+
chunk_start += chunk.length
|
|
118
|
+
chunk = []
|
|
119
|
+
chunk_bytes = 0
|
|
120
|
+
end
|
|
121
|
+
prepared.each do |item|
|
|
122
|
+
size = encode(item).bytesize + 1
|
|
123
|
+
flush.call if chunk.length >= MAX_BATCH_ITEMS || (!chunk.empty? && chunk_bytes + size > MAX_BATCH_BYTES)
|
|
124
|
+
chunk << item
|
|
125
|
+
chunk_bytes += size
|
|
126
|
+
end
|
|
127
|
+
flush.call
|
|
128
|
+
BatchResult.new(received: received, errors: errors)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
# Keywords that are options, and the rest (which are data).
|
|
134
|
+
def split_keywords(keywords)
|
|
135
|
+
options = keywords.slice(*OPTION_KEYS)
|
|
136
|
+
[options, keywords.reject { |key, _| OPTION_KEYS.include?(key) }]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def merge_data(hash, extra)
|
|
140
|
+
return hash if extra.empty?
|
|
141
|
+
|
|
142
|
+
(hash || {}).merge(extra)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def base_body(user_id, timestamp, message_id, context)
|
|
146
|
+
raise Error.new(:invalid_argument, "userId must be a non-empty string") if user_id.nil? || user_id.to_s.strip.empty?
|
|
147
|
+
|
|
148
|
+
body = { userId: user_id.to_s, messageId: (message_id || SecureRandom.uuid).to_s }
|
|
149
|
+
body[:timestamp] = iso(timestamp) unless timestamp.nil?
|
|
150
|
+
body[:context] = context unless context.nil?
|
|
151
|
+
body
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def identify_body(user_id, traits, timestamp, message_id, context)
|
|
155
|
+
base_body(user_id, timestamp, message_id, context).merge(traits: traits || {})
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def track_body(user_id, event, properties, timestamp, message_id, context)
|
|
159
|
+
body = base_body(user_id, timestamp, message_id, context)
|
|
160
|
+
raise Error.new(:invalid_argument, "event must be a non-empty string") if event.nil? || event.to_s.strip.empty?
|
|
161
|
+
|
|
162
|
+
body.merge(event: event.to_s, properties: properties || {})
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def iso(value)
|
|
166
|
+
time =
|
|
167
|
+
case value
|
|
168
|
+
when Time then value
|
|
169
|
+
when String then Time.parse(value)
|
|
170
|
+
else value.respond_to?(:to_time) ? value.to_time : raise(ArgumentError)
|
|
171
|
+
end
|
|
172
|
+
time.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ")
|
|
173
|
+
rescue ArgumentError, TypeError
|
|
174
|
+
raise Error.new(:invalid_argument, "timestamp is not a valid date: #{value}")
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def encode(body)
|
|
178
|
+
JSON.generate(body)
|
|
179
|
+
rescue JSON::GeneratorError, Encoding::UndefinedConversionError => e
|
|
180
|
+
raise Error.new(:invalid_argument, "The data could not be encoded as JSON: #{e.message}")
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def send_call(path, body)
|
|
184
|
+
Result.new(deduplicated: post(path, body)["deduplicated"] == true)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def code_for_status(status)
|
|
188
|
+
case status
|
|
189
|
+
when 401 then :unauthorized
|
|
190
|
+
when 402 then :plan_limit
|
|
191
|
+
when 403 then :forbidden
|
|
192
|
+
when 429 then :rate_limited
|
|
193
|
+
when 500.. then :server
|
|
194
|
+
else :validation
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# One POST with retries. Every body here carries a messageId, so repeating it is safe.
|
|
199
|
+
def post(path, body)
|
|
200
|
+
payload = encode(body)
|
|
201
|
+
headers = {
|
|
202
|
+
"Authorization" => "Bearer #{@secret_key}",
|
|
203
|
+
"Content-Type" => "application/json",
|
|
204
|
+
"User-Agent" => "loomail-ruby/#{VERSION}"
|
|
205
|
+
}
|
|
206
|
+
attempt = 0
|
|
207
|
+
loop do
|
|
208
|
+
begin
|
|
209
|
+
status, response_headers, raw = @transport.call(@host + path, headers, payload, @timeout)
|
|
210
|
+
rescue TransportError => e
|
|
211
|
+
if attempt >= @max_retries
|
|
212
|
+
raise Error.new(:network, "Could not reach Loomail at #{@host}: #{e.message}")
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
@sleeper.call([backoff(attempt), 30.0].min)
|
|
216
|
+
attempt += 1
|
|
217
|
+
next
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
parsed = parse(raw)
|
|
221
|
+
return parsed.is_a?(Hash) ? parsed : {} if status.between?(200, 299)
|
|
222
|
+
|
|
223
|
+
retryable = status == 429 || status >= 500
|
|
224
|
+
if !retryable || attempt >= @max_retries
|
|
225
|
+
detail = parsed.is_a?(Hash) && parsed["error"].is_a?(String) ? ": #{parsed['error']}" : ""
|
|
226
|
+
raise Error.new(code_for_status(status), "Loomail answered #{status}#{detail}", status: status, body: parsed)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
after = Float(response_headers["retry-after"], exception: false) if response_headers["retry-after"]
|
|
230
|
+
wait = after && after >= 0 ? after : backoff(attempt)
|
|
231
|
+
@sleeper.call([wait, 30.0].min)
|
|
232
|
+
attempt += 1
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def parse(raw)
|
|
237
|
+
return nil if raw.nil? || raw.empty?
|
|
238
|
+
|
|
239
|
+
JSON.parse(raw)
|
|
240
|
+
rescue JSON::ParserError
|
|
241
|
+
raw
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# 0.5s, 1s, 2s... with up to 25% jitter.
|
|
245
|
+
def backoff(attempt)
|
|
246
|
+
base = 0.5 * (2**attempt)
|
|
247
|
+
base + rand * base * 0.25
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# The default transport.
|
|
251
|
+
def net_http(url, headers, body, timeout)
|
|
252
|
+
uri = URI.parse(url)
|
|
253
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
254
|
+
http.use_ssl = uri.scheme == "https"
|
|
255
|
+
http.open_timeout = [timeout, 10.0].min
|
|
256
|
+
http.read_timeout = timeout
|
|
257
|
+
http.write_timeout = timeout
|
|
258
|
+
request = Net::HTTP::Post.new(uri.request_uri, headers)
|
|
259
|
+
request.body = body
|
|
260
|
+
response = http.start { |h| h.request(request) }
|
|
261
|
+
[response.code.to_i, response.each_header.to_h.transform_keys(&:downcase), response.body.to_s]
|
|
262
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Timeout::Error => e
|
|
263
|
+
raise TransportError, "timed out (#{e.class})"
|
|
264
|
+
rescue SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError, EOFError, URI::InvalidURIError => e
|
|
265
|
+
raise TransportError, "#{e.class}: #{e.message}"
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_job"
|
|
4
|
+
|
|
5
|
+
module Loomail
|
|
6
|
+
# Sends one identify or track from a background job. The call keeps the message_id it was queued with, so a retry of
|
|
7
|
+
# the job (or a duplicate delivery) is recognised by Loomail and not counted twice.
|
|
8
|
+
class DeliverJob < ::ActiveJob::Base
|
|
9
|
+
RETRYABLE = %i[network server rate_limited].freeze
|
|
10
|
+
|
|
11
|
+
queue_as :default
|
|
12
|
+
|
|
13
|
+
# An outage or a rate limit is retried with a growing wait. Anything else (a bad key, a plan limit, a payload Loomail
|
|
14
|
+
# refuses) cannot be fixed by retrying, so it is logged and dropped in #perform.
|
|
15
|
+
retry_on Loomail::Error, wait: :polynomially_longer, attempts: 5
|
|
16
|
+
|
|
17
|
+
def perform(method, args, options)
|
|
18
|
+
Loomail.public_send(method, *args, **options.transform_keys(&:to_sym))
|
|
19
|
+
rescue Loomail::Error => e
|
|
20
|
+
raise if RETRYABLE.include?(e.code)
|
|
21
|
+
|
|
22
|
+
logger&.warn("[loomail] dropped #{method}: #{e.message}")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Loomail
|
|
4
|
+
# Raised when Loomail cannot be reached or refuses a call.
|
|
5
|
+
#
|
|
6
|
+
# #code is one of:
|
|
7
|
+
# :invalid_argument caught before any request was made
|
|
8
|
+
# :unauthorized 401: key missing, wrong or revoked
|
|
9
|
+
# :forbidden 403: key not allowed here
|
|
10
|
+
# :plan_limit 402: workspace is at its contact limit
|
|
11
|
+
# :validation 400 / 422: Loomail did not accept the payload
|
|
12
|
+
# :rate_limited 429, still failing after retries
|
|
13
|
+
# :server 5xx, still failing after retries
|
|
14
|
+
# :network could not reach Loomail, or it did not answer in time
|
|
15
|
+
class Error < StandardError
|
|
16
|
+
attr_reader :code, :status, :body
|
|
17
|
+
|
|
18
|
+
def initialize(code, message, status: nil, body: nil)
|
|
19
|
+
super(message)
|
|
20
|
+
@code = code
|
|
21
|
+
@status = status
|
|
22
|
+
@body = body
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# What #identify and #track return.
|
|
27
|
+
Result = Struct.new(:deduplicated, keyword_init: true) do
|
|
28
|
+
# True when Loomail had already seen this message_id and ignored the repeat.
|
|
29
|
+
def deduplicated?
|
|
30
|
+
deduplicated == true
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# What #batch returns. +errors+ is an array of { index:, message:, code: } for the items Loomail refused.
|
|
35
|
+
BatchResult = Struct.new(:received, :errors, keyword_init: true)
|
|
36
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module Loomail
|
|
6
|
+
# Rails integration. Loaded automatically when Rails is present. Reads, in this order of preference:
|
|
7
|
+
#
|
|
8
|
+
# config.loomail.secret_key / .host / .timeout / .max_retries (config/application.rb or an environment file)
|
|
9
|
+
# Rails credentials: loomail: { secret_key: ..., host: ... }
|
|
10
|
+
# ENV["LOOMAIL_SECRET_KEY"] and ENV["LOOMAIL_HOST"]
|
|
11
|
+
#
|
|
12
|
+
# Then Loomail.identify / Loomail.track work anywhere, and Loomail.track_later runs on ActiveJob.
|
|
13
|
+
class Railtie < ::Rails::Railtie
|
|
14
|
+
config.loomail = ActiveSupport::OrderedOptions.new
|
|
15
|
+
|
|
16
|
+
initializer "loomail.configure" do |app|
|
|
17
|
+
creds = app.credentials.respond_to?(:loomail) ? app.credentials.loomail : nil
|
|
18
|
+
creds = creds.to_h if creds.respond_to?(:to_h)
|
|
19
|
+
creds ||= {}
|
|
20
|
+
settings = app.config.loomail
|
|
21
|
+
|
|
22
|
+
Loomail.configure do |c|
|
|
23
|
+
c.secret_key = settings.secret_key || creds[:secret_key]
|
|
24
|
+
c.host = settings.host || creds[:host]
|
|
25
|
+
c.timeout = settings.timeout || creds[:timeout] || c.timeout
|
|
26
|
+
c.max_retries = settings.max_retries || creds[:max_retries] || c.max_retries
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/loomail.rb
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "loomail/version"
|
|
4
|
+
require_relative "loomail/error"
|
|
5
|
+
require_relative "loomail/client"
|
|
6
|
+
|
|
7
|
+
# Server-side client for Loomail. See Loomail::Client.
|
|
8
|
+
#
|
|
9
|
+
# Configure once (a Rails app does this from config.loomail, credentials or the environment automatically):
|
|
10
|
+
#
|
|
11
|
+
# Loomail.configure do |c|
|
|
12
|
+
# c.secret_key = ENV["LOOMAIL_SECRET_KEY"]
|
|
13
|
+
# c.host = "https://loomail.example.com" # only for a self-hosted install
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# Loomail.identify(user.id, email: user.email)
|
|
17
|
+
# Loomail.track(user.id, "signed_up")
|
|
18
|
+
module Loomail
|
|
19
|
+
# Settings for the shared client. Anything left empty falls back to LOOMAIL_SECRET_KEY / LOOMAIL_HOST.
|
|
20
|
+
class Configuration
|
|
21
|
+
attr_accessor :secret_key, :host, :timeout, :max_retries
|
|
22
|
+
|
|
23
|
+
def initialize
|
|
24
|
+
@timeout = 10
|
|
25
|
+
@max_retries = 3
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
def configuration
|
|
31
|
+
@configuration ||= Configuration.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def configure
|
|
35
|
+
yield configuration
|
|
36
|
+
@client = nil
|
|
37
|
+
configuration
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The shared client, built from the configuration on first use.
|
|
41
|
+
def client
|
|
42
|
+
@client ||= Client.new(
|
|
43
|
+
secret_key: configuration.secret_key,
|
|
44
|
+
host: configuration.host,
|
|
45
|
+
timeout: configuration.timeout,
|
|
46
|
+
max_retries: configuration.max_retries
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Replace the shared client (tests).
|
|
51
|
+
attr_writer :client
|
|
52
|
+
|
|
53
|
+
def reset!
|
|
54
|
+
@client = nil
|
|
55
|
+
@configuration = nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def identify(user_id, traits = {}, **keywords)
|
|
59
|
+
client.identify(user_id, traits, **keywords)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def track(user_id, event, properties = {}, **keywords)
|
|
63
|
+
client.track(user_id, event, properties, **keywords)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def batch(items)
|
|
67
|
+
client.batch(items)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Same as identify / track, on a background job (Rails with ActiveJob), so a Loomail outage cannot slow or fail the
|
|
71
|
+
# request. Without ActiveJob it calls straight away and logs nothing.
|
|
72
|
+
def identify_later(user_id, traits = {}, **options)
|
|
73
|
+
enqueue(:identify, [user_id, traits], options)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def track_later(user_id, event, properties = {}, **options)
|
|
77
|
+
enqueue(:track, [user_id, event, properties], options)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def enqueue(method, args, options)
|
|
83
|
+
options = options.dup
|
|
84
|
+
# A time becomes text, and a message_id is fixed now, so a retried job cannot double-count.
|
|
85
|
+
options[:timestamp] = options[:timestamp].utc.iso8601(3) if options[:timestamp].respond_to?(:utc)
|
|
86
|
+
options[:message_id] ||= SecureRandom.uuid
|
|
87
|
+
if defined?(::ActiveJob)
|
|
88
|
+
require_relative "loomail/deliver_job"
|
|
89
|
+
Loomail::DeliverJob.perform_later(method.to_s, args, options.transform_keys(&:to_s))
|
|
90
|
+
else
|
|
91
|
+
public_send(method, *args, **options)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
require_relative "loomail/railtie" if defined?(::Rails::Railtie)
|
metadata
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: loomail
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Loomail
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-10-18 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: activejob
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '6.1'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '6.1'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: minitest
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '5.20'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '5.20'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: rake
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '13.0'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '13.0'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: railties
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - ">="
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '6.1'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '6.1'
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: webrick
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - "~>"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '1.8'
|
|
76
|
+
type: :development
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - "~>"
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '1.8'
|
|
83
|
+
description: Zero-dependency Ruby client for Loomail's ingestion API, with retries,
|
|
84
|
+
idempotent calls and a Rails integration.
|
|
85
|
+
email:
|
|
86
|
+
executables: []
|
|
87
|
+
extensions: []
|
|
88
|
+
extra_rdoc_files: []
|
|
89
|
+
files:
|
|
90
|
+
- LICENSE
|
|
91
|
+
- README.md
|
|
92
|
+
- lib/loomail.rb
|
|
93
|
+
- lib/loomail/client.rb
|
|
94
|
+
- lib/loomail/deliver_job.rb
|
|
95
|
+
- lib/loomail/error.rb
|
|
96
|
+
- lib/loomail/railtie.rb
|
|
97
|
+
- lib/loomail/version.rb
|
|
98
|
+
homepage: https://loomail.site
|
|
99
|
+
licenses:
|
|
100
|
+
- MIT
|
|
101
|
+
metadata:
|
|
102
|
+
homepage_uri: https://loomail.site
|
|
103
|
+
source_code_uri: https://github.com/sasbaws2211-cloud/mailforge/tree/main/packages/sdk-ruby
|
|
104
|
+
rubygems_mfa_required: 'true'
|
|
105
|
+
post_install_message:
|
|
106
|
+
rdoc_options: []
|
|
107
|
+
require_paths:
|
|
108
|
+
- lib
|
|
109
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
110
|
+
requirements:
|
|
111
|
+
- - ">="
|
|
112
|
+
- !ruby/object:Gem::Version
|
|
113
|
+
version: '3.0'
|
|
114
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
115
|
+
requirements:
|
|
116
|
+
- - ">="
|
|
117
|
+
- !ruby/object:Gem::Version
|
|
118
|
+
version: '0'
|
|
119
|
+
requirements: []
|
|
120
|
+
rubygems_version: 3.5.22
|
|
121
|
+
signing_key:
|
|
122
|
+
specification_version: 4
|
|
123
|
+
summary: 'Server-side client for Loomail: send identify and track events with a secret
|
|
124
|
+
key.'
|
|
125
|
+
test_files: []
|