Class: Letterapp::Client

Inherits:
Object
  • Object
show all
Defined in:
lib/letterapp/client.rb

Overview

A Letter client.

Long-running server (default): identify / group / track enqueue calls that a background thread auto-batches and flushes every 100ms or 50 events. Call close before the process exits.

Serverless: pass flush_at: 1 and call flush at the end of each invocation.

send_email (transactional) sits outside that machinery: it always performs the request and returns the result. Batching a password reset would be a bug, not an optimization.

Instance Method Summary collapse

Constructor Details

#initialize(api_key:, base_url: DEFAULT_BASE_URL, flush_at: 50, flush_interval: 0.1, max_retries: 3, open_timeout: 10, read_timeout: 10, on_error: nil) ⇒ Client

Returns a new instance of Client.

Parameters:

  • api_key (String)

    API key (Dashboard -> Settings -> API keys).

  • base_url (String) (defaults to: DEFAULT_BASE_URL)

    API origin. Defaults to https://api.letter.app.

  • flush_at (Integer) (defaults to: 50)

    Flush after this many queued events (1 = serverless).

  • flush_interval (Float) (defaults to: 0.1)

    Seconds between background flushes.

  • max_retries (Integer) (defaults to: 3)

    Max retry attempts per request.

  • on_error (#call) (defaults to: nil)

    Callback for background transport errors.

Raises:



52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
# File 'lib/letterapp/client.rb', line 52

def initialize(api_key:, base_url: DEFAULT_BASE_URL, flush_at: 50,
               flush_interval: 0.1, max_retries: 3, open_timeout: 10,
               read_timeout: 10, on_error: nil)
  raise Error, "api_key is required" if api_key.nil? || api_key.to_s.empty?

  @api_key = api_key
  @base_url = (base_url || DEFAULT_BASE_URL).sub(%r{/+\z}, "")
  @flush_at = [1, flush_at.to_i].max
  @flush_interval = flush_interval.to_f
  @max_retries = max_retries.to_i
  @open_timeout = open_timeout
  @read_timeout = read_timeout
  @on_error = on_error || ->(err) { warn("[letter] #{err.message}") }

  @uri = URI.parse(@base_url)
  @queue = []
  @mutex = Mutex.new
  @cond = ConditionVariable.new
  @send_mutex = Mutex.new
  @closed = false

  @thread = Thread.new { loop_run }
  at_exit { close }
end

Instance Method Details

#closeObject

Flush, stop the background thread, and block until drained.



137
138
139
140
141
142
143
144
145
146
# File 'lib/letterapp/client.rb', line 137

def close
  @mutex.synchronize do
    return if @closed

    @closed = true
    @cond.broadcast
  end
  @thread&.join(@read_timeout * (@max_retries + 2))
  flush
end

#flushObject

Send everything currently queued and block until it completes.



127
128
129
130
131
132
133
134
# File 'lib/letterapp/client.rb', line 127

def flush
  loop do
    batch = take_batch
    break if batch.nil?

    @send_mutex.synchronize { send_batch(batch) }
  end
end

#group(user_id:, account_id:, name: nil, traits: nil, timestamp: nil, message_id: nil) ⇒ Object

Queue a group call (associates a contact with an account).



84
85
86
87
# File 'lib/letterapp/client.rb', line 84

def group(user_id:, account_id:, name: nil, traits: nil,
          timestamp: nil, message_id: nil)
  enqueue(serialize_group(user_id, , name, traits, timestamp, message_id))
end

#identify(user_id:, email: nil, traits: nil, timezone: nil, timestamp: nil, message_id: nil) ⇒ Object

Queue an identify call (creates or updates a contact).



78
79
80
81
# File 'lib/letterapp/client.rb', line 78

def identify(user_id:, email: nil, traits: nil, timezone: nil,
             timestamp: nil, message_id: nil)
  enqueue(serialize_identify(user_id, email, traits, timezone, timestamp, message_id))
end

#send_email(to:, subject:, html: nil, text: nil, from: nil, from_name: nil, reply_to: nil, headers: nil, tag: nil, metadata: nil, idempotency_key: nil) ⇒ Object

Send one transactional email and return the parsed result Hash ("id", "messageId", "status", "replayed", ...).

Named send_email rather than send because Object#send is Ruby's dynamic dispatch: shadowing it on a client object would break every client.send(:private_method) and metaprogramming call in the host app.

Never queued: the call performs the request and returns once the provider has accepted the message. Pass idempotency_key wherever the call can be retried (a job with retries, a webhook handler): a replay returns the original send rather than mailing the recipient twice, and it is what makes retrying a 5xx safe. Without one this method does not retry, because a duplicate email is worse than a failed one.

from defaults to the project's sender and must be on a verified domain.

Raises:



109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/letterapp/client.rb', line 109

def send_email(to:, subject:, html: nil, text: nil, from: nil, from_name: nil,
               reply_to: nil, headers: nil, tag: nil, metadata: nil,
               idempotency_key: nil)
  raise Error, "send_email: to is required" if blank?(to)
  raise Error, "send_email: subject is required" if blank?(subject)
  raise Error, "send_email: provide html, text, or both" if blank?(html) && blank?(text)

  body = { "to" => to, "subject" => subject }
  {
    "html" => html, "text" => text, "from" => from, "fromName" => from_name,
    "replyTo" => reply_to, "headers" => headers, "tag" => tag,
    "metadata" => , "idempotencyKey" => idempotency_key
  }.each { |key, value| body[key] = value unless value.nil? }

  request("/v1/send", body, max_retries: idempotency_key ? @max_retries : 0) || {}
end

#track(user_id:, event:, properties: nil, timestamp: nil, message_id: nil) ⇒ Object

Queue a track call (records an event).



90
91
92
# File 'lib/letterapp/client.rb', line 90

def track(user_id:, event:, properties: nil, timestamp: nil, message_id: nil)
  enqueue(serialize_track(user_id, event, properties, timestamp, message_id))
end