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 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Loomail
4
+ VERSION = "0.1.0"
5
+ 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: []