letterapp 0.1.0 → 0.2.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 +4 -4
- data/README.md +75 -7
- data/lib/letterapp/client.rb +71 -10
- data/lib/letterapp/delivery_method.rb +134 -0
- data/lib/letterapp/railtie.rb +23 -0
- data/lib/letterapp/version.rb +1 -1
- data/lib/letterapp.rb +7 -0
- metadata +8 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0f2fde121f30391e8ef7d0272b8b8ca855d13f8597f90618ca493969eef045c2
|
|
4
|
+
data.tar.gz: 2647178c2d51cb3edbf7aace4aa8c71343321434107d5d55c06e8d75407a05f0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d5b5f7fa5a456b68b32659ffa690f3550e6f6ae618be9f54cab3462d7e2676d31eaf2ebe75a1ab09c04fc0525ae813fc2c7cece6a25f3ea472efbb317df0dafe
|
|
7
|
+
data.tar.gz: cfc610d764cf25e18d0894f14bc440613a72945ebc5441d3bb1d86d706832f4f6b28401581fdc6b3fddcbaa381514e8ad9d599c594ef3bb14877c33fc213d691
|
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,36 @@ 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
37
|
# background thread auto-batches and flushes every 100ms or 50 events. Call
|
|
29
38
|
# +close+ before the process exits.
|
|
30
39
|
#
|
|
31
40
|
# Serverless: pass flush_at: 1 and call +flush+ at the end of each invocation.
|
|
41
|
+
#
|
|
42
|
+
# +send_email+ (transactional) sits outside that machinery: it always performs
|
|
43
|
+
# the request and returns the result. Batching a password reset would be a
|
|
44
|
+
# bug, not an optimization.
|
|
32
45
|
class Client
|
|
33
46
|
# @param api_key [String] API key (Dashboard -> Settings -> API keys).
|
|
34
47
|
# @param base_url [String] API origin. Defaults to https://api.letter.app.
|
|
@@ -78,6 +91,38 @@ module Letterapp
|
|
|
78
91
|
enqueue(serialize_track(user_id, event, properties, timestamp, message_id))
|
|
79
92
|
end
|
|
80
93
|
|
|
94
|
+
# Send one transactional email and return the parsed result Hash
|
|
95
|
+
# ("id", "messageId", "status", "replayed", ...).
|
|
96
|
+
#
|
|
97
|
+
# Named +send_email+ rather than +send+ because Object#send is Ruby's
|
|
98
|
+
# dynamic dispatch: shadowing it on a client object would break every
|
|
99
|
+
# +client.send(:private_method)+ and metaprogramming call in the host app.
|
|
100
|
+
#
|
|
101
|
+
# Never queued: the call performs the request and returns once the provider
|
|
102
|
+
# has accepted the message. Pass +idempotency_key+ wherever the call can be
|
|
103
|
+
# retried (a job with retries, a webhook handler): a replay returns the
|
|
104
|
+
# original send rather than mailing the recipient twice, and it is what
|
|
105
|
+
# makes retrying a 5xx safe. Without one this method does not retry,
|
|
106
|
+
# because a duplicate email is worse than a failed one.
|
|
107
|
+
#
|
|
108
|
+
# +from+ defaults to the project's sender and must be on a verified domain.
|
|
109
|
+
def send_email(to:, subject:, html: nil, text: nil, from: nil, from_name: nil,
|
|
110
|
+
reply_to: nil, headers: nil, tag: nil, metadata: nil,
|
|
111
|
+
idempotency_key: nil)
|
|
112
|
+
raise Error, "send_email: to is required" if blank?(to)
|
|
113
|
+
raise Error, "send_email: subject is required" if blank?(subject)
|
|
114
|
+
raise Error, "send_email: provide html, text, or both" if blank?(html) && blank?(text)
|
|
115
|
+
|
|
116
|
+
body = { "to" => to, "subject" => subject }
|
|
117
|
+
{
|
|
118
|
+
"html" => html, "text" => text, "from" => from, "fromName" => from_name,
|
|
119
|
+
"replyTo" => reply_to, "headers" => headers, "tag" => tag,
|
|
120
|
+
"metadata" => metadata, "idempotencyKey" => idempotency_key
|
|
121
|
+
}.each { |key, value| body[key] = value unless value.nil? }
|
|
122
|
+
|
|
123
|
+
request("/v1/send", body, max_retries: idempotency_key ? @max_retries : 0) || {}
|
|
124
|
+
end
|
|
125
|
+
|
|
81
126
|
# Send everything currently queued and block until it completes.
|
|
82
127
|
def flush
|
|
83
128
|
loop do
|
|
@@ -140,30 +185,37 @@ module Letterapp
|
|
|
140
185
|
@on_error.call(e.is_a?(Error) ? e : Error.new(e.message))
|
|
141
186
|
end
|
|
142
187
|
|
|
143
|
-
|
|
188
|
+
# POST +body+ to +path+, returning the decoded JSON response (or nil for an
|
|
189
|
+
# empty one). +max_retries+ defaults to the client setting; callers pass 0
|
|
190
|
+
# for requests that are not replay-safe (see +send_email+).
|
|
191
|
+
def request(path, body, max_retries: @max_retries)
|
|
144
192
|
data = JSON.generate(body)
|
|
145
193
|
last_err = nil
|
|
146
194
|
|
|
147
|
-
(0
|
|
195
|
+
(0..max_retries).each do |attempt|
|
|
148
196
|
begin
|
|
149
197
|
res = post(path, data)
|
|
150
198
|
code = res.code.to_i
|
|
151
|
-
return if code >= 200 && code < 300
|
|
199
|
+
return decode_json(res.body) if code >= 200 && code < 300
|
|
152
200
|
|
|
153
|
-
if code == 429 && attempt <
|
|
201
|
+
if code == 429 && attempt < max_retries
|
|
154
202
|
sleep(retry_after(res))
|
|
155
203
|
next
|
|
156
204
|
end
|
|
157
|
-
if code >= 500 && attempt <
|
|
205
|
+
if code >= 500 && attempt < max_retries
|
|
158
206
|
sleep(backoff(attempt))
|
|
159
207
|
next
|
|
160
208
|
end
|
|
209
|
+
|
|
210
|
+
payload = decode_json(res.body)
|
|
211
|
+
message = payload&.dig("error", "message") || res.body
|
|
161
212
|
raise Error.new(
|
|
162
|
-
"Request failed with #{code}: #{
|
|
213
|
+
"Request failed with #{code}: #{message}",
|
|
214
|
+
status: code, body: res.body, payload: payload
|
|
163
215
|
)
|
|
164
216
|
rescue Timeout::Error, IOError, SystemCallError, SocketError => e
|
|
165
217
|
last_err = Error.new("Network error: #{e.message}")
|
|
166
|
-
raise last_err if attempt >=
|
|
218
|
+
raise last_err if attempt >= max_retries
|
|
167
219
|
|
|
168
220
|
sleep(backoff(attempt))
|
|
169
221
|
end
|
|
@@ -172,6 +224,15 @@ module Letterapp
|
|
|
172
224
|
raise(last_err || Error.new("Request failed for unknown reason"))
|
|
173
225
|
end
|
|
174
226
|
|
|
227
|
+
def decode_json(raw)
|
|
228
|
+
return nil if raw.nil? || raw.empty?
|
|
229
|
+
|
|
230
|
+
parsed = JSON.parse(raw)
|
|
231
|
+
parsed.is_a?(Hash) ? parsed : nil
|
|
232
|
+
rescue JSON::ParserError
|
|
233
|
+
nil
|
|
234
|
+
end
|
|
235
|
+
|
|
175
236
|
def post(path, data)
|
|
176
237
|
http = Net::HTTP.new(@uri.host, @uri.port)
|
|
177
238
|
http.use_ssl = (@uri.scheme == "https")
|
|
@@ -0,0 +1,134 @@
|
|
|
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
|
+
def client
|
|
86
|
+
@client ||= Client.new(
|
|
87
|
+
api_key: settings.fetch(:api_key) { ENV.fetch("LETTER_API_KEY") },
|
|
88
|
+
base_url: settings[:base_url] || ENV["LETTER_BASE_URL"] || DEFAULT_BASE_URL,
|
|
89
|
+
# This client only ever calls send_email, which bypasses the queue, so
|
|
90
|
+
# the batching thread would sit idle forever. Setting flush_at to 1
|
|
91
|
+
# keeps it from being started for nothing.
|
|
92
|
+
flush_at: 1
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def bodies(mail)
|
|
97
|
+
if mail.multipart?
|
|
98
|
+
[mail.html_part&.decoded, mail.text_part&.decoded]
|
|
99
|
+
elsif mail.mime_type == "text/html"
|
|
100
|
+
[mail.body.decoded, nil]
|
|
101
|
+
else
|
|
102
|
+
[nil, mail.body.decoded]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Headers Rails or Mail generate for the envelope, which Letter sets itself
|
|
107
|
+
# from the fields above. Passing them through would be rejected as reserved
|
|
108
|
+
# or would produce duplicates.
|
|
109
|
+
ENVELOPE_HEADERS = %w[
|
|
110
|
+
from to cc bcc subject reply-to date message-id mime-version
|
|
111
|
+
content-type content-transfer-encoding
|
|
112
|
+
].freeze
|
|
113
|
+
|
|
114
|
+
def custom_headers(mail)
|
|
115
|
+
skip = ENVELOPE_HEADERS + [IDEMPOTENCY_HEADER.downcase]
|
|
116
|
+
headers = {}
|
|
117
|
+
mail.header.fields.each do |field|
|
|
118
|
+
name = field.name.to_s
|
|
119
|
+
next if skip.include?(name.downcase)
|
|
120
|
+
|
|
121
|
+
headers[name] = field.value.to_s
|
|
122
|
+
end
|
|
123
|
+
headers.empty? ? nil : headers
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def address_of(field)
|
|
127
|
+
Array(field).first
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def display_name_of(field)
|
|
131
|
+
field&.display_names&.compact&.first
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
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.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- letter.app
|
|
@@ -9,8 +9,9 @@ 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: []
|
|
@@ -19,6 +20,8 @@ files:
|
|
|
19
20
|
- README.md
|
|
20
21
|
- lib/letterapp.rb
|
|
21
22
|
- lib/letterapp/client.rb
|
|
23
|
+
- lib/letterapp/delivery_method.rb
|
|
24
|
+
- lib/letterapp/railtie.rb
|
|
22
25
|
- lib/letterapp/version.rb
|
|
23
26
|
homepage: https://letter.app/docs/ruby-sdk
|
|
24
27
|
licenses:
|
|
@@ -44,5 +47,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
44
47
|
requirements: []
|
|
45
48
|
rubygems_version: 4.0.4
|
|
46
49
|
specification_version: 4
|
|
47
|
-
summary: Official Ruby client for letter.app -
|
|
50
|
+
summary: Official Ruby client for letter.app - transactional email and onboarding
|
|
51
|
+
drip campaigns.
|
|
48
52
|
test_files: []
|