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 +4 -4
- data/CHANGELOG.md +43 -0
- data/README.md +75 -7
- data/lib/letterapp/client.rb +97 -21
- data/lib/letterapp/delivery_method.rb +133 -0
- data/lib/letterapp/railtie.rb +23 -0
- data/lib/letterapp/version.rb +1 -1
- data/lib/letterapp.rb +7 -0
- metadata +10 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6a28e33d3a0a3f740b0d45a1a53c22983dbd781fba93c87dc8a167ae51075bc6
|
|
4
|
+
data.tar.gz: 643ca3a372bf627e1cea174989880196ce66c722da3ebfdd605e35d4bd8373b8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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** -
|
|
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
|
-
|
|
58
|
-
|
|
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
|
|
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
|
|
data/lib/letterapp/client.rb
CHANGED
|
@@ -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
|
|
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.
|
|
29
|
-
# +close+ before the process
|
|
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
|
-
|
|
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
|
|
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 <
|
|
213
|
+
if code == 429 && attempt < max_retries
|
|
154
214
|
sleep(retry_after(res))
|
|
155
215
|
next
|
|
156
216
|
end
|
|
157
|
-
if code >= 500 && attempt <
|
|
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}: #{
|
|
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 >=
|
|
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
|
|
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
|
|
207
|
-
raise Error, "group: account_id is required" if
|
|
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
|
|
224
|
-
raise Error, "track: event is required" if
|
|
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
|
-
|
|
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
|
data/lib/letterapp/version.rb
CHANGED
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.
|
|
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: '
|
|
13
|
-
|
|
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 -
|
|
52
|
+
summary: Official Ruby client for letter.app - transactional email and onboarding
|
|
53
|
+
drip campaigns.
|
|
48
54
|
test_files: []
|