letterapp 0.1.0 → 0.2.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f34428952a2c04f306b6202d26e5fed5537a347aef6b5682d893eacd2b5d148c
4
- data.tar.gz: f21c70172c12e83daf530971113ae379c385b18a2101189546c97cd1c978d70e
3
+ metadata.gz: 6a28e33d3a0a3f740b0d45a1a53c22983dbd781fba93c87dc8a167ae51075bc6
4
+ data.tar.gz: 643ca3a372bf627e1cea174989880196ce66c722da3ebfdd605e35d4bd8373b8
5
5
  SHA512:
6
- metadata.gz: 04155060eef72dc6631a404abf9ea37327a7584642d576f52e43245c1d95729c31a60728589fd750839ddd8c5279b5d8e3bc7bfafc8316042f33fbde0ff0021b
7
- data.tar.gz: a91ef9c7620135df31e75a05b4164b78e17feae3630f89b14f17a92e36e87a0d0dde7fd9ceb57abf5b38ae3746f28f9dd1f7b0f34111399531121d7c4a60ccd0
6
+ metadata.gz: 334a7ce7651803672d8d46bc37c97924bf18a556513a66d879ab0dfb6cf8f5559db138f3501bccc1e23ca4836071c4512a8c3886ff69eb8759b9273a13865080
7
+ data.tar.gz: 25819b5ad570ceec7b853f19bc2629a6b5a2c5b27e10849a5994e0d6e616c4335b616fd5c0be7e88f33419d60a2f7b73ee61e0d809db52aeb350ab0c32edce82
data/CHANGELOG.md ADDED
@@ -0,0 +1,43 @@
1
+ # Changelog
2
+
3
+ ## 0.2.2 - 2026-10-25
4
+
5
+ ### Fixed
6
+
7
+ - Sending mail through the `:letter` ActionMailer delivery method no longer
8
+ leaks a thread per message. `Client.new` started the batching thread, and
9
+ registered an `at_exit` hook holding the client, even for a client that
10
+ only calls `send_email`; Mail builds a new delivery method, and so a new
11
+ client, for every message. A long-running process kept one idle thread and
12
+ one client per email sent. The thread now starts with the first `identify`,
13
+ `group` or `track` call.
14
+ - A client created before a fork (a Rails initializer under Puma cluster mode
15
+ or Solid Queue) now sends from the forked process as usual. The child
16
+ inherited a dead copy of the parent's thread, so its `identify`, `group` and
17
+ `track` calls waited in the queue until the process exited. The client now
18
+ starts a new thread when the old one is dead.
19
+
20
+ ## 0.2.1 - 2026-10-25
21
+
22
+ ### Fixed
23
+
24
+ - `Letterapp::Client` no longer breaks Active Support's `present?`, `blank?`
25
+ and `presence`. 0.2.0 had a private `blank?(value)` helper that shadowed
26
+ `Object#blank?`, so in a Rails app `client.present?` and `client.presence`
27
+ raised `ArgumentError: wrong number of arguments (given 0, expected 1)`, and
28
+ `client.blank?` raised `NoMethodError`. The helper is now named `missing?`.
29
+
30
+ ## 0.2.0 - 2026-08-20
31
+
32
+ ### Added
33
+
34
+ - `send_email` for transactional email (`POST /v1/send`), with
35
+ `idempotency_key:` for replay-safe retries.
36
+ - `Letterapp::DeliveryMethod`, registered as `:letter` with ActionMailer in a
37
+ Rails app.
38
+ - `Letterapp::Error#code` and `#reason`, from the API's error body.
39
+
40
+ ## 0.1.0 - 2026-05-29
41
+
42
+ - First release: `identify`, `group` and `track`, auto-batched in the
43
+ background, with retries and idempotent message IDs.
data/README.md CHANGED
@@ -48,14 +48,78 @@ def handler(event:, context:)
48
48
  end
49
49
  ```
50
50
 
51
+ ## Transactional email
52
+
53
+ `send_email` mails one person right now: a receipt, a password reset, a
54
+ verification link. It is never batched and never waits for `flush`.
55
+
56
+ ```ruby
57
+ result = letter.send_email(
58
+ to: "alice@example.com",
59
+ subject: "Reset your password",
60
+ html: "<p>Click <a href='https://...'>here</a> to reset.</p>",
61
+ tag: "password-reset",
62
+ idempotency_key: "password-reset:#{token}"
63
+ )
64
+
65
+ result["messageId"] # provider id, appears in delivery events
66
+ ```
67
+
68
+ It's `send_email` and not `send` because `Object#send` is Ruby's dynamic
69
+ dispatch; shadowing it would break metaprogramming in the host app.
70
+
71
+ Only `to:`, `subject:` and one of `html:` / `text:` are required. `from:`
72
+ defaults to the project's sender and must be on a verified domain. A
73
+ plain-text part is derived from the HTML when you don't supply one.
74
+
75
+ Pass an `idempotency_key:` whenever the call can be retried (a job with
76
+ retries, a webhook handler): a replay returns the original send rather than
77
+ mailing the recipient twice, and it's what lets the SDK retry a `5xx` safely.
78
+
79
+ Failures raise `Letterapp::Error` with `#status`, `#code` and `#reason`. The
80
+ reason is what tells "this recipient is unreachable" apart from "our account
81
+ is blocked":
82
+
83
+ ```ruby
84
+ begin
85
+ letter.send_email(to: to, subject: subject, html: html)
86
+ rescue Letterapp::Error => e
87
+ raise unless e.reason == "suppressed" # hard-bounced or complained; nothing to fix
88
+ end
89
+ ```
90
+
91
+ Transactional mail ignores marketing unsubscribes (an opted-out user still
92
+ gets their password reset) but respects bounces, complaints, and addresses
93
+ suppressed by hand.
94
+
95
+ ### Rails
96
+
97
+ The gem registers `:letter` as an ActionMailer delivery method, so switching
98
+ providers is two lines of config. Every existing mailer, view, and
99
+ `deliver_later` keeps working unchanged.
100
+
101
+ ```ruby
102
+ # config/environments/production.rb
103
+ config.action_mailer.delivery_method = :letter
104
+ config.action_mailer.letter_settings = { api_key: ENV["LETTER_API_KEY"] }
105
+ ```
106
+
107
+ HTML and text parts map straight through. A message with several recipients
108
+ becomes one send each, so everyone gets their own log row and bounce.
109
+ Attachments raise, rather than being dropped silently, since the transactional
110
+ API doesn't carry them.
111
+
51
112
  ## What it does
52
113
 
53
- - **Auto-batching** - calls are queued and flushed every 100ms or 50 events by
54
- a background thread.
114
+ - **Auto-batching** - `identify` / `group` / `track` are queued and flushed
115
+ every 100ms or 50 events by a background thread. `send_email` always goes out
116
+ immediately.
55
117
  - **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.
118
+ exponentially with jitter, up to `max_retries` (default 3). A `send_email`
119
+ without an `idempotency_key` is never retried, since a duplicate email is
120
+ worse than a failed one.
121
+ - **Idempotent** - every ingestion call gets a UUID `message_id` so retries are
122
+ deduplicated server-side; `send_email` takes your own key.
59
123
  - **No dependencies** - HTTP over the standard library `net/http`.
60
124
 
61
125
  ## API
@@ -75,18 +139,22 @@ Letterapp::Client.new(
75
139
  letter.identify(user_id:, email: nil, traits: nil, timezone: nil, timestamp: nil, message_id: nil)
76
140
  letter.group(user_id:, account_id:, name: nil, traits: nil, timestamp: nil, message_id: nil)
77
141
  letter.track(user_id:, event:, properties: nil, timestamp: nil, message_id: nil)
142
+ letter.send_email(to:, subject:, html: nil, text: nil, from: nil, from_name: nil,
143
+ reply_to: nil, headers: nil, tag: nil, metadata: nil,
144
+ idempotency_key: nil) # => Hash, sent immediately
78
145
  letter.flush # send queued calls now, block until done
79
146
  letter.close # flush + stop the background thread (also runs at exit)
80
147
  ```
81
148
 
82
149
  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.
150
+ (with `#status`, `#code`, `#reason` and `#body`). Background transport errors
151
+ are passed to `on_error` instead, since they cannot be raised to the caller.
85
152
 
86
153
  ## Full documentation
87
154
 
88
155
  - **SDK reference:** <https://letter.app/docs/ruby-sdk>
89
156
  - **Ingestion API:** <https://letter.app/docs/api>
157
+ - **Transactional API:** <https://letter.app/docs/transactional>
90
158
 
91
159
  ## License
92
160
 
@@ -12,23 +12,37 @@ module Letterapp
12
12
  DEFAULT_BASE_URL = "https://api.letter.app"
13
13
 
14
14
  # Raised for client misconfiguration and non-retryable API errors.
15
+ #
16
+ # +code+ is the API's machine-readable error code (bad_request, forbidden,
17
+ # ...). +reason+ is the finer-grained cause when a send was refused
18
+ # (suppressed, account_suspended, email_quota_exceeded, daily_cap,
19
+ # billing_required, provider_rejected), which is what tells "this one
20
+ # recipient is unreachable" apart from "our account is blocked".
15
21
  class Error < StandardError
16
- attr_reader :status, :body
22
+ attr_reader :status, :body, :code, :reason
17
23
 
18
- def initialize(message, status: nil, body: nil)
24
+ def initialize(message, status: nil, body: nil, payload: nil)
19
25
  super(message)
20
26
  @status = status
21
27
  @body = body
28
+ error = payload.is_a?(Hash) ? (payload["error"] || {}) : {}
29
+ @code = error["code"]
30
+ @reason = error["reason"]
22
31
  end
23
32
  end
24
33
 
25
- # A Letter ingestion client.
34
+ # A Letter client.
26
35
  #
27
36
  # 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.
37
+ # background thread auto-batches and flushes every 100ms or 50 events. The
38
+ # thread starts with the first queued call. Call +close+ before the process
39
+ # exits.
30
40
  #
31
41
  # Serverless: pass flush_at: 1 and call +flush+ at the end of each invocation.
42
+ #
43
+ # +send_email+ (transactional) sits outside that machinery: it always performs
44
+ # the request and returns the result. Batching a password reset would be a
45
+ # bug, not an optimization.
32
46
  class Client
33
47
  # @param api_key [String] API key (Dashboard -> Settings -> API keys).
34
48
  # @param base_url [String] API origin. Defaults to https://api.letter.app.
@@ -56,9 +70,7 @@ module Letterapp
56
70
  @cond = ConditionVariable.new
57
71
  @send_mutex = Mutex.new
58
72
  @closed = false
59
-
60
- @thread = Thread.new { loop_run }
61
- at_exit { close }
73
+ @thread = nil # see start_worker
62
74
  end
63
75
 
64
76
  # Queue an identify call (creates or updates a contact).
@@ -78,6 +90,38 @@ module Letterapp
78
90
  enqueue(serialize_track(user_id, event, properties, timestamp, message_id))
79
91
  end
80
92
 
93
+ # Send one transactional email and return the parsed result Hash
94
+ # ("id", "messageId", "status", "replayed", ...).
95
+ #
96
+ # Named +send_email+ rather than +send+ because Object#send is Ruby's
97
+ # dynamic dispatch: shadowing it on a client object would break every
98
+ # +client.send(:private_method)+ and metaprogramming call in the host app.
99
+ #
100
+ # Never queued: the call performs the request and returns once the provider
101
+ # has accepted the message. Pass +idempotency_key+ wherever the call can be
102
+ # retried (a job with retries, a webhook handler): a replay returns the
103
+ # original send rather than mailing the recipient twice, and it is what
104
+ # makes retrying a 5xx safe. Without one this method does not retry,
105
+ # because a duplicate email is worse than a failed one.
106
+ #
107
+ # +from+ defaults to the project's sender and must be on a verified domain.
108
+ def send_email(to:, subject:, html: nil, text: nil, from: nil, from_name: nil,
109
+ reply_to: nil, headers: nil, tag: nil, metadata: nil,
110
+ idempotency_key: nil)
111
+ raise Error, "send_email: to is required" if missing?(to)
112
+ raise Error, "send_email: subject is required" if missing?(subject)
113
+ raise Error, "send_email: provide html, text, or both" if missing?(html) && missing?(text)
114
+
115
+ body = { "to" => to, "subject" => subject }
116
+ {
117
+ "html" => html, "text" => text, "from" => from, "fromName" => from_name,
118
+ "replyTo" => reply_to, "headers" => headers, "tag" => tag,
119
+ "metadata" => metadata, "idempotencyKey" => idempotency_key
120
+ }.each { |key, value| body[key] = value unless value.nil? }
121
+
122
+ request("/v1/send", body, max_retries: idempotency_key ? @max_retries : 0) || {}
123
+ end
124
+
81
125
  # Send everything currently queued and block until it completes.
82
126
  def flush
83
127
  loop do
@@ -109,10 +153,23 @@ module Letterapp
109
153
  return
110
154
  end
111
155
  @queue << item
156
+ start_worker
112
157
  @cond.signal
113
158
  end
114
159
  end
115
160
 
161
+ # Starts the batching thread (and the at_exit hook that drains it) on the
162
+ # first queued call rather than in +initialize+. Mail builds a new delivery
163
+ # method, and so a new client, for every message; each one used to leave
164
+ # an idle thread behind. Also restarts a dead thread, which is what a
165
+ # forked child inherits from its parent. Called with @mutex held.
166
+ def start_worker
167
+ return if @thread&.alive?
168
+
169
+ at_exit { close } if @thread.nil?
170
+ @thread = Thread.new { loop_run }
171
+ end
172
+
116
173
  def take_batch
117
174
  @mutex.synchronize do
118
175
  return nil if @queue.empty?
@@ -140,30 +197,37 @@ module Letterapp
140
197
  @on_error.call(e.is_a?(Error) ? e : Error.new(e.message))
141
198
  end
142
199
 
143
- def request(path, body)
200
+ # POST +body+ to +path+, returning the decoded JSON response (or nil for an
201
+ # empty one). +max_retries+ defaults to the client setting; callers pass 0
202
+ # for requests that are not replay-safe (see +send_email+).
203
+ def request(path, body, max_retries: @max_retries)
144
204
  data = JSON.generate(body)
145
205
  last_err = nil
146
206
 
147
- (0..@max_retries).each do |attempt|
207
+ (0..max_retries).each do |attempt|
148
208
  begin
149
209
  res = post(path, data)
150
210
  code = res.code.to_i
151
- return if code >= 200 && code < 300
211
+ return decode_json(res.body) if code >= 200 && code < 300
152
212
 
153
- if code == 429 && attempt < @max_retries
213
+ if code == 429 && attempt < max_retries
154
214
  sleep(retry_after(res))
155
215
  next
156
216
  end
157
- if code >= 500 && attempt < @max_retries
217
+ if code >= 500 && attempt < max_retries
158
218
  sleep(backoff(attempt))
159
219
  next
160
220
  end
221
+
222
+ payload = decode_json(res.body)
223
+ message = payload&.dig("error", "message") || res.body
161
224
  raise Error.new(
162
- "Request failed with #{code}: #{res.body}", status: code, body: res.body
225
+ "Request failed with #{code}: #{message}",
226
+ status: code, body: res.body, payload: payload
163
227
  )
164
228
  rescue Timeout::Error, IOError, SystemCallError, SocketError => e
165
229
  last_err = Error.new("Network error: #{e.message}")
166
- raise last_err if attempt >= @max_retries
230
+ raise last_err if attempt >= max_retries
167
231
 
168
232
  sleep(backoff(attempt))
169
233
  end
@@ -172,6 +236,15 @@ module Letterapp
172
236
  raise(last_err || Error.new("Request failed for unknown reason"))
173
237
  end
174
238
 
239
+ def decode_json(raw)
240
+ return nil if raw.nil? || raw.empty?
241
+
242
+ parsed = JSON.parse(raw)
243
+ parsed.is_a?(Hash) ? parsed : nil
244
+ rescue JSON::ParserError
245
+ nil
246
+ end
247
+
175
248
  def post(path, data)
176
249
  http = Net::HTTP.new(@uri.host, @uri.port)
177
250
  http.use_ssl = (@uri.scheme == "https")
@@ -187,7 +260,7 @@ module Letterapp
187
260
  end
188
261
 
189
262
  def serialize_identify(user_id, email, traits, timezone, timestamp, message_id)
190
- raise Error, "identify: user_id is required" if blank?(user_id)
263
+ raise Error, "identify: user_id is required" if missing?(user_id)
191
264
 
192
265
  item = {
193
266
  "type" => "identify",
@@ -203,8 +276,8 @@ module Letterapp
203
276
  end
204
277
 
205
278
  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)
279
+ raise Error, "group: user_id is required" if missing?(user_id)
280
+ raise Error, "group: account_id is required" if missing?(account_id)
208
281
 
209
282
  item = {
210
283
  "type" => "group",
@@ -220,8 +293,8 @@ module Letterapp
220
293
  end
221
294
 
222
295
  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)
296
+ raise Error, "track: user_id is required" if missing?(user_id)
297
+ raise Error, "track: event is required" if missing?(event)
225
298
 
226
299
  item = {
227
300
  "type" => "track",
@@ -242,7 +315,10 @@ module Letterapp
242
315
  value.to_s
243
316
  end
244
317
 
245
- def blank?(value)
318
+ # Not named +blank?+: Active Support defines Object#blank?, and its
319
+ # +present?+ and +presence+ call it, so a private blank?(value) here would
320
+ # shadow it and make +client.present?+ raise ArgumentError in a Rails app.
321
+ def missing?(value)
246
322
  value.nil? || value.to_s.empty?
247
323
  end
248
324
 
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "client"
4
+
5
+ module Letterapp
6
+ # ActionMailer delivery method backed by Letter's transactional API.
7
+ #
8
+ # Register it and every existing mailer, view and `deliver_later` keeps
9
+ # working unchanged - Rails builds the same Mail::Message it always did, and
10
+ # this class is only responsible for putting it on the wire:
11
+ #
12
+ # # config/initializers/letter.rb
13
+ # ActionMailer::Base.add_delivery_method :letter, Letterapp::DeliveryMethod
14
+ #
15
+ # # config/environments/production.rb
16
+ # config.action_mailer.delivery_method = :letter
17
+ # config.action_mailer.letter_settings = { api_key: ENV["LETTER_API_KEY"] }
18
+ #
19
+ # Multipart mail maps cleanly: Letter takes an HTML part and a text part, and
20
+ # a Mail::Message carries exactly those two. Attachments and inline images do
21
+ # not map, and we raise rather than silently dropping them, since a receipt
22
+ # that arrives without its PDF looks delivered but isn't.
23
+ #
24
+ # One recipient per API call. A mail addressed to several people becomes
25
+ # several sends, which is also what you want: each recipient gets their own
26
+ # log row, their own bounce, and can't see the others.
27
+ #
28
+ # To make a delivery replay-safe, set the idempotency key in the mailer:
29
+ #
30
+ # headers["X-Letter-Idempotency-Key"] = "password-reset:#{token}"
31
+ #
32
+ # The header is consumed here, not forwarded. It has to come from the mailer
33
+ # because it must be derived from what you are sending about; Message-ID
34
+ # can't stand in for it, since ActionMailer rebuilds the message (and so
35
+ # generates a fresh Message-ID) every time a `deliver_later` job retries.
36
+ class DeliveryMethod
37
+ IDEMPOTENCY_HEADER = "X-Letter-Idempotency-Key"
38
+
39
+ # ActionMailer instantiates this with the configured settings hash and
40
+ # reads `#settings` back when merging defaults.
41
+ attr_accessor :settings
42
+
43
+ def initialize(settings = {})
44
+ @settings = settings || {}
45
+ end
46
+
47
+ def deliver!(mail)
48
+ html, text = bodies(mail)
49
+ if html.nil? && text.nil?
50
+ raise Error, "Letter delivery: message has no text or HTML body"
51
+ end
52
+
53
+ unless mail.attachments.empty?
54
+ raise Error,
55
+ "Letter delivery: attachments are not supported by the " \
56
+ "transactional API (#{mail.attachments.map(&:filename).join(', ')})"
57
+ end
58
+
59
+ recipients = Array(mail.to) + Array(mail.cc) + Array(mail.bcc)
60
+ raise Error, "Letter delivery: message has no recipients" if recipients.empty?
61
+
62
+ base_key = mail[IDEMPOTENCY_HEADER]&.value&.to_s
63
+ headers = custom_headers(mail)
64
+
65
+ recipients.map do |to|
66
+ client.send_email(
67
+ to: to,
68
+ subject: mail.subject.to_s,
69
+ html: html,
70
+ text: text,
71
+ from: address_of(mail.from),
72
+ from_name: display_name_of(mail[:from]),
73
+ reply_to: address_of(mail.reply_to),
74
+ headers: headers,
75
+ tag: settings[:tag],
76
+ # Scoped per recipient: the same message to three people is three
77
+ # distinct sends, not one replayed twice.
78
+ idempotency_key: base_key && !base_key.empty? ? "#{base_key}:#{to}" : nil
79
+ )
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ # Mail builds a new delivery method for every message, so this client
86
+ # lives for one message. That's cheap: it only calls send_email, which
87
+ # bypasses the queue, so the client never starts its batching thread.
88
+ def client
89
+ @client ||= Client.new(
90
+ api_key: settings.fetch(:api_key) { ENV.fetch("LETTER_API_KEY") },
91
+ base_url: settings[:base_url] || ENV["LETTER_BASE_URL"] || DEFAULT_BASE_URL
92
+ )
93
+ end
94
+
95
+ def bodies(mail)
96
+ if mail.multipart?
97
+ [mail.html_part&.decoded, mail.text_part&.decoded]
98
+ elsif mail.mime_type == "text/html"
99
+ [mail.body.decoded, nil]
100
+ else
101
+ [nil, mail.body.decoded]
102
+ end
103
+ end
104
+
105
+ # Headers Rails or Mail generate for the envelope, which Letter sets itself
106
+ # from the fields above. Passing them through would be rejected as reserved
107
+ # or would produce duplicates.
108
+ ENVELOPE_HEADERS = %w[
109
+ from to cc bcc subject reply-to date message-id mime-version
110
+ content-type content-transfer-encoding
111
+ ].freeze
112
+
113
+ def custom_headers(mail)
114
+ skip = ENVELOPE_HEADERS + [IDEMPOTENCY_HEADER.downcase]
115
+ headers = {}
116
+ mail.header.fields.each do |field|
117
+ name = field.name.to_s
118
+ next if skip.include?(name.downcase)
119
+
120
+ headers[name] = field.value.to_s
121
+ end
122
+ headers.empty? ? nil : headers
123
+ end
124
+
125
+ def address_of(field)
126
+ Array(field).first
127
+ end
128
+
129
+ def display_name_of(field)
130
+ field&.display_names&.compact&.first
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ require_relative "delivery_method"
6
+
7
+ module Letterapp
8
+ # Registers `:letter` as an ActionMailer delivery method so a Rails app only
9
+ # has to pick it:
10
+ #
11
+ # config.action_mailer.delivery_method = :letter
12
+ # config.action_mailer.letter_settings = { api_key: ENV["LETTER_API_KEY"] }
13
+ #
14
+ # Loaded from letterapp.rb only when Rails is present, so the gem stays
15
+ # dependency-free everywhere else.
16
+ class Railtie < ::Rails::Railtie
17
+ initializer "letterapp.add_delivery_method" do
18
+ ActiveSupport.on_load(:action_mailer) do
19
+ add_delivery_method :letter, Letterapp::DeliveryMethod
20
+ end
21
+ end
22
+ end
23
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Letterapp
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.2"
5
5
  end
data/lib/letterapp.rb CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  require_relative "letterapp/version"
4
4
  require_relative "letterapp/client"
5
+ require_relative "letterapp/delivery_method"
6
+
7
+ # In a Rails app, register `:letter` as an ActionMailer delivery method so the
8
+ # host only has to select it. Guarded so the gem keeps working (and stays
9
+ # dependency-free) outside Rails.
10
+ require_relative "letterapp/railtie" if defined?(::Rails::Railtie)
5
11
 
6
12
  # Official Ruby client for letter.app.
7
13
  #
@@ -10,6 +16,7 @@ require_relative "letterapp/client"
10
16
  # letter = Letterapp::Client.new(api_key: ENV["LETTER_API_KEY"])
11
17
  # letter.identify(user_id: "user_123", email: "alice@example.com")
12
18
  # letter.track(user_id: "user_123", event: "Signed Up")
19
+ # letter.send_email(to: "alice@example.com", subject: "Hi", html: "<p>Hi</p>")
13
20
  # letter.close
14
21
  module Letterapp
15
22
  # Convenience: Letterapp.new(...) == Letterapp::Client.new(...)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: letterapp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - letter.app
@@ -9,16 +9,20 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
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.'
12
+ description: 'Ruby client for letter.app: send transactional email (with an ActionMailer
13
+ delivery method), identify users, track events, and group accounts. Auto-batching,
14
+ retries, idempotency, zero runtime dependencies.'
14
15
  executables: []
15
16
  extensions: []
16
17
  extra_rdoc_files: []
17
18
  files:
19
+ - CHANGELOG.md
18
20
  - LICENSE
19
21
  - README.md
20
22
  - lib/letterapp.rb
21
23
  - lib/letterapp/client.rb
24
+ - lib/letterapp/delivery_method.rb
25
+ - lib/letterapp/railtie.rb
22
26
  - lib/letterapp/version.rb
23
27
  homepage: https://letter.app/docs/ruby-sdk
24
28
  licenses:
@@ -26,6 +30,7 @@ licenses:
26
30
  metadata:
27
31
  homepage_uri: https://letter.app/docs/ruby-sdk
28
32
  source_code_uri: https://github.com/vincenzor/letter-ruby
33
+ changelog_uri: https://github.com/vincenzor/letter-ruby/blob/main/CHANGELOG.md
29
34
  bug_tracker_uri: https://github.com/vincenzor/letter-ruby/issues
30
35
  rubygems_mfa_required: 'true'
31
36
  rdoc_options: []
@@ -44,5 +49,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
44
49
  requirements: []
45
50
  rubygems_version: 4.0.4
46
51
  specification_version: 4
47
- summary: Official Ruby client for letter.app - onboarding email drip campaigns.
52
+ summary: Official Ruby client for letter.app - transactional email and onboarding
53
+ drip campaigns.
48
54
  test_files: []