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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f34428952a2c04f306b6202d26e5fed5537a347aef6b5682d893eacd2b5d148c
4
- data.tar.gz: f21c70172c12e83daf530971113ae379c385b18a2101189546c97cd1c978d70e
3
+ metadata.gz: 0f2fde121f30391e8ef7d0272b8b8ca855d13f8597f90618ca493969eef045c2
4
+ data.tar.gz: 2647178c2d51cb3edbf7aace4aa8c71343321434107d5d55c06e8d75407a05f0
5
5
  SHA512:
6
- metadata.gz: 04155060eef72dc6631a404abf9ea37327a7584642d576f52e43245c1d95729c31a60728589fd750839ddd8c5279b5d8e3bc7bfafc8316042f33fbde0ff0021b
7
- data.tar.gz: a91ef9c7620135df31e75a05b4164b78e17feae3630f89b14f17a92e36e87a0d0dde7fd9ceb57abf5b38ae3746f28f9dd1f7b0f34111399531121d7c4a60ccd0
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** - 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,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 ingestion client.
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
- def request(path, body)
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..@max_retries).each do |attempt|
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 < @max_retries
201
+ if code == 429 && attempt < max_retries
154
202
  sleep(retry_after(res))
155
203
  next
156
204
  end
157
- if code >= 500 && attempt < @max_retries
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}: #{res.body}", status: code, body: res.body
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 >= @max_retries
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
@@ -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.0"
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.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: '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: []
@@ -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 - onboarding email drip campaigns.
50
+ summary: Official Ruby client for letter.app - transactional email and onboarding
51
+ drip campaigns.
48
52
  test_files: []