posthog-ruby 3.29.1 → 3.30.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: dc3cd53140f36b4f77fb11d60a903858d5053362e7c8fd3d8d4bd55472ed1c4a
4
- data.tar.gz: f1213ce104a582ae6b9e82315538e31b77fc85dbe7336cdf0e41cbb169b0b47e
3
+ metadata.gz: 83e0b91accb2b3ec2fa0f56ece8a8823877290454f55a7fa3f1c810f3eee4577
4
+ data.tar.gz: fb8bc42c783e9fb5ce9601fc68b83024453f67aa2cb7330d95c1481f2cd23e0d
5
5
  SHA512:
6
- metadata.gz: 489f3bd76d70c2034c62c02e217d6e09bd16e6600d9de7b757ae5bda1a0a24d7dafad88a735ddf13ab39fa18cffdab59fcb20e2d080689ff373238fce11f9925
7
- data.tar.gz: ab7bb81da61fc82e4058355c95bc1d9b97018f0cbcfd5b476f380a058a39e9a0ce6ae3b8ce1fd11aa18acb4d4a1c4fb0cec3546f4729ee5a56dcf4e959e5ee3a
6
+ metadata.gz: a157aa2438e7845107409133cc1825ceacde289d070ee6d13140f89d26341030fd45cfc8b8004caeb588d4dc3b982e693490027d67091dc6305cbdcafb2e0861
7
+ data.tar.gz: 63e42fc08a1241e5fe7c0e6de790e3bc4293fa3b36067e8f80f77f521760e0be82a975b94e9ce4121f75e5a86a07f1f44954c0abd341b759457c2f1bc492172c
@@ -15,6 +15,7 @@ require 'posthog/feature_flags'
15
15
  require 'posthog/feature_flag_evaluations'
16
16
  require 'posthog/send_feature_flags_options'
17
17
  require 'posthog/exception_capture'
18
+ require 'posthog/emails'
18
19
  require 'posthog/internal/context'
19
20
 
20
21
  module PostHog
@@ -127,6 +128,14 @@ module PostHog
127
128
  # @option opts [Boolean] :compress_request Set to +false+ to disable gzip compression for batch uploads.
128
129
  # @option opts [Object] :flag_definition_cache_provider An object implementing the {FlagDefinitionCacheProvider}
129
130
  # interface for distributed flag definition caching.
131
+ # @option opts [String, nil] :app_host Your PostHog app host, such as `https://posthog.example.com`, for {#emails}.
132
+ # Needed only when +:host+ is not PostHog Cloud, such as a reverse proxy, which must not receive the secret key.
133
+ # @option opts [Numeric] :email_request_timeout_seconds How long each {#emails} request may take, in seconds.
134
+ # Defaults to 15.
135
+ # @option opts [Integer] :email_request_max_retries How many times {#emails} retries a request after a network
136
+ # error, a rate limit or an unavailable service. Defaults to 2. Set to 0 when a job runner already retries.
137
+ # @option opts [String, nil] :migrated_from The email provider you moved from, such as `resend`. {#emails} sends
138
+ # it with every request.
130
139
  # @option opts [Boolean] :is_server +true+ to stamp captured events with `$is_server => true` so PostHog
131
140
  # attributes them as server-side. Defaults to +true+. Set to +false+ when running posthog-ruby as a
132
141
  # client/CLI so the device OS is attributed normally and `$is_server` is omitted.
@@ -154,6 +163,19 @@ module PostHog
154
163
  @lib = opts[:_lib] || 'posthog-ruby'
155
164
  @lib_version = (opts[:_lib_version] || PostHog::VERSION).to_s
156
165
  @headers = Defaults::Request::HEADERS.merge('User-Agent' => "#{@lib}/#{@lib_version}")
166
+ # Built before the worker, the instance count and the flags poller start, so a bad email option raises
167
+ # before anything runs or sends the secret key.
168
+ @emails = Emails.new(
169
+ secret_key: secret_key,
170
+ host: opts[:host],
171
+ app_host: opts[:app_host],
172
+ timeout: opts[:email_request_timeout_seconds] || Defaults::Emails::REQUEST_TIMEOUT_SECONDS,
173
+ max_retries: opts[:email_request_max_retries] || Defaults::Emails::REQUEST_MAX_RETRIES,
174
+ migrated_from: opts[:migrated_from],
175
+ user_agent: @headers['User-Agent'],
176
+ disabled: @disabled,
177
+ skip_ssl_verification: opts[:skip_ssl_verification] == true
178
+ )
157
179
  @worker_mutex = Mutex.new
158
180
  @shutdown_mutex = Mutex.new
159
181
  @shutdown_condition = ConditionVariable.new
@@ -233,6 +255,13 @@ module PostHog
233
255
  @deprecation_emitted_for = Concurrent::Set.new
234
256
  end
235
257
 
258
+ # Sends transactional email from email templates made in PostHog or from content your code rendered, and reads
259
+ # the status of each email sent. Needs +:secret_key+, a project secret API key with the
260
+ # `transactional_email:write` scope.
261
+ #
262
+ # @return [PostHog::Emails]
263
+ attr_reader :emails
264
+
236
265
  # When sync_mode is enabled, blocks until in-flight requests are complete
237
266
  # and returns true. Timeout parameter has no effect.
238
267
  #
@@ -979,7 +1008,8 @@ module PostHog
979
1008
  @distinct_id_has_sent_flag_calls_mutex.synchronize do
980
1009
  @distinct_id_has_sent_flag_calls.clear
981
1010
  end
982
- flushed
1011
+ emails_settled = @emails.close(timeout: deadline && [deadline - monotonic_time, 0].max)
1012
+ flushed &&= emails_settled
983
1013
  ensure
984
1014
  @shutdown_mutex.synchronize do
985
1015
  @shutdown_result = flushed
@@ -26,6 +26,11 @@ module PostHog
26
26
  FLAG_REQUEST_MAX_RETRIES = 1
27
27
  end
28
28
 
29
+ module Emails
30
+ REQUEST_TIMEOUT_SECONDS = 15
31
+ REQUEST_MAX_RETRIES = 2
32
+ end
33
+
29
34
  module Queue
30
35
  MAX_SIZE = 10_000
31
36
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PostHog
4
+ # PostHog did not queue the email, or the client could not tell whether it did.
5
+ #
6
+ # Branch on {#code}, PostHog's stable error code, or on {#name}, a coarse category of the failure.
7
+ # The message never contains the secret key or the request body.
8
+ class EmailError < StandardError
9
+ # @return [String] A coarse category of the failure, such as `validation_error`, `rate_limit_exceeded`
10
+ # or `service_unavailable`.
11
+ attr_reader :name
12
+ # @return [String] PostHog's error code, such as `quota_limited`, or an SDK code such as `network_error`.
13
+ attr_reader :code
14
+ # @return [Integer, nil] The HTTP status, when PostHog answered.
15
+ attr_reader :status_code
16
+ # @return [String, nil] Why PostHog refused the email.
17
+ attr_reader :why
18
+ # @return [String, nil] How to fix the request.
19
+ attr_reader :fix
20
+ # @return [String, nil] A link to the docs for this error.
21
+ attr_reader :docs_url
22
+ # @return [String, nil] The request field the error is about, when PostHog named one.
23
+ attr_reader :field
24
+ # @return [Float, nil] How long PostHog asked the client to wait before it sends again, in seconds.
25
+ attr_reader :retry_after
26
+
27
+ # @api private
28
+ def initialize(code:, message:, status_code: nil, why: nil, fix: nil, docs_url: nil, field: nil, retry_after: nil)
29
+ super(message)
30
+ @name = self.class.name_for(status_code, code)
31
+ @code = code
32
+ @status_code = status_code
33
+ @why = why
34
+ @fix = fix
35
+ @docs_url = docs_url
36
+ @field = field
37
+ @retry_after = retry_after
38
+ end
39
+
40
+ # @api private
41
+ def self.name_for(status_code, code)
42
+ return 'missing_api_key' if code == 'missing_secret_key' || status_code == 401
43
+ return code == 'network_error' ? 'service_unavailable' : 'application_error' if status_code.nil?
44
+
45
+ case status_code
46
+ when 400 then 'validation_error'
47
+ when 403 then 'invalid_permission'
48
+ when 404 then 'not_found'
49
+ when 409 then code == 'template_outdated' ? 'validation_error' : 'concurrent_idempotent_requests'
50
+ when 422 then code == 'idempotency_key_reused' ? 'invalid_idempotent_request' : 'validation_error'
51
+ when 429 then code == 'quota_limited' ? 'monthly_quota_exceeded' : 'rate_limit_exceeded'
52
+ when 503 then 'service_unavailable'
53
+ else 'application_error'
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,518 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'openssl'
6
+ require 'securerandom'
7
+ require 'time'
8
+ require 'uri'
9
+ require 'zlib'
10
+
11
+ require 'posthog/email_error'
12
+ require 'posthog/logging'
13
+ require 'posthog/utils'
14
+
15
+ module PostHog
16
+ # Sends transactional email, from an email template made in PostHog or from content your code rendered,
17
+ # and reads the status of each email sent. Reach it through {Client#emails}.
18
+ #
19
+ # A send needs the client's `secret_key`, a project secret API key (`phs_...`) with the
20
+ # `transactional_email:write` scope: the "Send transactional email" preset in your project settings. A key with
21
+ # only `transactional_email:read` can read statuses but not send. Keys made with `hog_flow:write` still work.
22
+ # Sends go to the PostHog app host: a client on PostHog Cloud uses its region's app host, and any other `host`
23
+ # needs `app_host`, because a reverse proxy for ingestion must not receive the secret key.
24
+ #
25
+ # Each send retries network errors, rate limits and an unavailable service, waiting about 1 and then 2 seconds,
26
+ # or the server's `Retry-After` when that is longer. Every attempt sends the same idempotency key, so a retry
27
+ # never sends twice.
28
+ #
29
+ # @example A template made in PostHog
30
+ # posthog.emails.send(
31
+ # to: 'user@example.com',
32
+ # template_id: '0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b',
33
+ # template_data: { name: 'Ada' },
34
+ # distinct_id: 'user_123',
35
+ # idempotency_key: 'welcome:user_123'
36
+ # )
37
+ #
38
+ # @example Content your code rendered, such as React Email output
39
+ # posthog.emails.send(
40
+ # to: 'user@example.com',
41
+ # template_key: 'password-reset',
42
+ # subject: 'Reset your password',
43
+ # html: rendered_html,
44
+ # distinct_id: 'user_123'
45
+ # )
46
+ class Emails
47
+ include PostHog::Logging
48
+ include PostHog::Utils
49
+
50
+ # The queued email. A result means PostHog queued the email, not that it was delivered.
51
+ #
52
+ # @!attribute [r] id
53
+ # @return [String] The queued email's message ID.
54
+ # @!attribute [r] replayed
55
+ # @return [Boolean] Whether PostHog had already accepted this idempotency key and returned the first result.
56
+ # @!attribute [r] warnings
57
+ # @return [Array<Warning>]
58
+ SendResult = Struct.new(:id, :replayed, :warnings, keyword_init: true)
59
+
60
+ # A warning PostHog returned with a queued email.
61
+ Warning = Struct.new(:code, :message, :fix, keyword_init: true)
62
+
63
+ # What happened to an email that {Emails#send} queued.
64
+ #
65
+ # @!attribute [r] status
66
+ # @return [String] `queued`, `sent`, `failed`, `delivered`, `bounced` or `complained`. Later SDK versions
67
+ # can see values this list does not have yet.
68
+ # @!attribute [r] reason
69
+ # @return [String, nil] Why a failed email was not sent, such as `recipient_suppressed`, or the bounce type
70
+ # of a bounced email: `hard`, `soft` or `unknown`.
71
+ Status = Struct.new(:id, :status, :created_at, :status_at, :reason, keyword_init: true)
72
+
73
+ SEND_PATH = '/api/projects/@current/messaging/send/email/'
74
+ STATUS_PATH = '/api/projects/@current/messaging/emails/%<message_id>s/'
75
+ US_APP_HOST = 'https://us.posthog.com'
76
+ EU_APP_HOST = 'https://eu.posthog.com'
77
+ CLOUD_APP_HOSTS = {
78
+ 'https://us.i.posthog.com' => US_APP_HOST,
79
+ 'https://us.posthog.com' => US_APP_HOST,
80
+ 'https://app.posthog.com' => US_APP_HOST,
81
+ 'https://eu.i.posthog.com' => EU_APP_HOST,
82
+ 'https://eu.posthog.com' => EU_APP_HOST
83
+ }.freeze
84
+ MAX_RETRY_DELAY_SECONDS = 30.0
85
+ UUID_PATTERN = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
86
+ IDEMPOTENCY_KEY_PATTERN = /\A[\x21-\x7e]{1,255}\z/
87
+ TEMPLATE_KEY_PATTERN = /\A[a-zA-Z0-9_-]{1,400}\z/
88
+ HEADER_TOKEN_PATTERN = /\A[\x21-\x7e]+\z/
89
+ SEND_KEY_NEEDED = 'Sending email needs secret_key, a project secret API key with the ' \
90
+ 'transactional_email:write scope.'
91
+ READ_KEY_NEEDED = 'Reading an email status needs secret_key, a project secret API key with the ' \
92
+ 'transactional_email:read or transactional_email:write scope.'
93
+ TEMPLATE_FORM_WHY = 'A template made in PostHog sets its own sender, subject and content, and follows the ' \
94
+ "project's engagement setting."
95
+ CODE_FORM_WHY = 'A send with template_key takes its content from your code.'
96
+ TEMPLATE_FIELDS = %i[template_data].freeze
97
+ CODE_FIELDS = %i[subject html text from reply_to capture_engagement].freeze
98
+ NETWORK_ERRORS = [
99
+ Timeout::Error, SocketError, SystemCallError, IOError, EOFError, OpenSSL::SSL::SSLError,
100
+ Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError, Zlib::Error
101
+ ].freeze
102
+ private_constant :SEND_PATH, :STATUS_PATH, :US_APP_HOST, :EU_APP_HOST, :CLOUD_APP_HOSTS,
103
+ :MAX_RETRY_DELAY_SECONDS, :UUID_PATTERN, :IDEMPOTENCY_KEY_PATTERN, :TEMPLATE_KEY_PATTERN,
104
+ :HEADER_TOKEN_PATTERN, :SEND_KEY_NEEDED, :READ_KEY_NEEDED, :TEMPLATE_FORM_WHY,
105
+ :CODE_FORM_WHY, :TEMPLATE_FIELDS, :CODE_FIELDS, :NETWORK_ERRORS
106
+
107
+ # @api private
108
+ def initialize(secret_key:, host:, app_host:, timeout:, max_retries:, migrated_from:, user_agent:, disabled:,
109
+ skip_ssl_verification: false)
110
+ check_options(timeout, max_retries, migrated_from)
111
+ @secret_key = secret_key.is_a?(String) ? secret_key.strip : nil
112
+ @app_host = resolve_app_host(host, app_host)
113
+ @timeout = timeout
114
+ @max_retries = max_retries
115
+ @migrated_from = migrated_from
116
+ @user_agent = user_agent
117
+ @disabled = disabled
118
+ @skip_ssl_verification = skip_ssl_verification
119
+ @closed = false
120
+ @in_flight = 0
121
+ @pid = Process.pid
122
+ @mutex = Mutex.new
123
+ @idle = ConditionVariable.new
124
+ end
125
+
126
+ # Queues one email, from a template made in PostHog or from content your code rendered.
127
+ #
128
+ # Pass exactly one form. The template form names a template made in PostHog with `template_id`. The code form
129
+ # names the email with `template_key` and passes `subject` and `html`, `text` or both. To send a React email,
130
+ # render it to HTML in your app first.
131
+ #
132
+ # @param to [String] One recipient email address.
133
+ # @param template_id [String] Template form: the UUID of a template made transactional in PostHog.
134
+ # @param template_data [Hash] Template form: values for the template's `{{ variables.* }}` placeholders.
135
+ # @param template_key [String] Code form: names the email, such as `password-reset`, so its metrics, logs and
136
+ # status group under it in PostHog. Letters, digits, hyphens and underscores, at most 400.
137
+ # @param subject [String] Code form: the subject line.
138
+ # @param html [String] Code form: the HTML body.
139
+ # @param text [String] Code form: the plain-text body. Without it, PostHog derives it from `html`.
140
+ # @param from [String] Code form: a verified sender, such as `Acme <hello@example.com>`. Without it, the
141
+ # project's default sender.
142
+ # @param reply_to [String, Array<String>] Code form: one address or a list of addresses.
143
+ # @param capture_engagement [Boolean] Code form: whether PostHog captures the email's opens and clicks as events
144
+ # of the recipient. Defaults to +true+. A project whose admin turned engagement capture off captures nothing.
145
+ # @param distinct_id [String] The recipient's distinct ID in your product. The email's events go to this
146
+ # person. Without it, they go to a person whose distinct ID is the email address.
147
+ # @param idempotency_key [String] 1 to 255 visible ASCII characters. A send with a key PostHog has already
148
+ # accepted queues nothing and returns the first send's result. Without it, the client makes a UUID that covers
149
+ # its own retries. Pass a key derived from what the email is for, such as `welcome:user_123`, when a job runner
150
+ # retries the send.
151
+ # @param max_retries [Integer] Overrides the client's `email_request_max_retries` for this send. +0+ turns
152
+ # retries off.
153
+ # @return [SendResult] The queued email.
154
+ # @raise [EmailError] PostHog did not queue the email, or the client could not tell whether it did.
155
+ # @raise [ArgumentError] An argument has the wrong type or format, or the call mixes the two forms.
156
+ def send(
157
+ to:, template_id: nil, template_data: nil, template_key: nil, subject: nil, html: nil, text: nil, from: nil,
158
+ reply_to: nil, capture_engagement: nil, distinct_id: nil, idempotency_key: nil, max_retries: nil
159
+ )
160
+ fields = {
161
+ template_data: template_data, subject: subject, html: html, text: text, from: from, reply_to: reply_to,
162
+ capture_engagement: capture_engagement
163
+ }.compact
164
+ check_recipient(to, distinct_id, idempotency_key, max_retries)
165
+ payload = content(template_id, template_key, fields).merge(to: to)
166
+ payload[:distinct_id] = distinct_id unless distinct_id.nil?
167
+ body = JSON.generate(payload)
168
+ headers = { 'Content-Type' => 'application/json', 'Idempotency-Key' => idempotency_key || SecureRandom.uuid }
169
+
170
+ result = call('no email was sent', SEND_KEY_NEEDED) do |secret_key|
171
+ uri = URI(@app_host + SEND_PATH)
172
+ with_retries(max_retries || @max_retries) do
173
+ parse_send_reply(request(Net::HTTP::Post.new(uri), uri, secret_key, headers, body))
174
+ end
175
+ end
176
+ result.warnings.each { |warning| logger.warn("PostHog email warning #{warning.code}: #{warning.message}") }
177
+ result
178
+ end
179
+
180
+ # Reads what happened to an email that {#send} queued, for 30 days after the send.
181
+ #
182
+ # @param message_id [String] The {SendResult#id} that {#send} returned.
183
+ # @return [Status] The email's latest status.
184
+ # @raise [EmailError] PostHog did not answer with a status. {EmailError#code} is `not_found` for an unknown ID,
185
+ # an email older than 30 days, or for a few seconds after the send.
186
+ # @raise [ArgumentError] +message_id+ is not a message ID.
187
+ def get(message_id)
188
+ raise ArgumentError, 'message_id must be the message ID that send returned.' unless uuid?(message_id)
189
+
190
+ call('no email status was read', READ_KEY_NEEDED) do |secret_key|
191
+ uri = URI(@app_host + format(STATUS_PATH, message_id: message_id.downcase))
192
+ with_retries(@max_retries) { parse_status_reply(request(Net::HTTP::Get.new(uri), uri, secret_key, {})) }
193
+ end
194
+ end
195
+
196
+ # Refuses new sends and waits for the sends and status reads in flight, retries included, to settle.
197
+ #
198
+ # @api private
199
+ # @param timeout [Numeric, nil] Maximum seconds to wait, or +nil+ to wait until they settle.
200
+ # @return [Boolean] +true+ if nothing is in flight any more.
201
+ def close(timeout: nil)
202
+ deadline = timeout && (monotonic_time + timeout)
203
+ @mutex.synchronize do
204
+ forget_parent_requests
205
+ @closed = true
206
+ until @in_flight.zero?
207
+ remaining = deadline && (deadline - monotonic_time)
208
+ break if remaining && remaining <= 0
209
+
210
+ @idle.wait(@mutex, remaining)
211
+ end
212
+ @in_flight.zero?
213
+ end
214
+ end
215
+
216
+ private
217
+
218
+ def check_options(timeout, max_retries, migrated_from)
219
+ raise ArgumentError, 'email_request_timeout_seconds must be a positive number.' unless positive?(timeout)
220
+ raise ArgumentError, 'email_request_max_retries must be a nonnegative integer.' unless retry_count?(max_retries)
221
+ return if migrated_from.nil? || (migrated_from.is_a?(String) && HEADER_TOKEN_PATTERN.match?(migrated_from))
222
+
223
+ raise ArgumentError, "migrated_from must be visible ASCII characters, such as 'resend'."
224
+ end
225
+
226
+ def positive?(value)
227
+ value.is_a?(Numeric) && value.real? && value.positive? && value.finite?
228
+ end
229
+
230
+ def retry_count?(value)
231
+ value.is_a?(Integer) && value >= 0
232
+ end
233
+
234
+ def uuid?(value)
235
+ value.is_a?(String) && UUID_PATTERN.match?(value)
236
+ end
237
+
238
+ def resolve_app_host(host, app_host)
239
+ explicit = app_host.is_a?(String) ? app_host.strip.chomp('/') : ''
240
+ return CLOUD_APP_HOSTS[host.to_s.strip.chomp('/')] if explicit.empty?
241
+ # A host without a scheme would send the secret key to the wrong place, such as localhost.
242
+ unless web_url?(explicit)
243
+ raise ArgumentError, 'app_host must be an http or https URL, such as https://us.posthog.com.'
244
+ end
245
+
246
+ CLOUD_APP_HOSTS.fetch(explicit, explicit)
247
+ end
248
+
249
+ def web_url?(value)
250
+ uri = URI(value)
251
+ %w[http https].include?(uri.scheme) && !uri.host.to_s.empty?
252
+ rescue URI::InvalidURIError
253
+ false
254
+ end
255
+
256
+ def check_recipient(to, distinct_id, idempotency_key, max_retries)
257
+ raise ArgumentError, 'to must be one email address as a nonempty string.' unless nonempty_string?(to)
258
+ unless distinct_id.nil? || nonempty_string?(distinct_id)
259
+ raise ArgumentError, 'distinct_id must be a nonempty string.'
260
+ end
261
+ unless idempotency_key.nil? || (idempotency_key.is_a?(String) && IDEMPOTENCY_KEY_PATTERN.match?(idempotency_key))
262
+ raise ArgumentError, 'idempotency_key must be 1 to 255 visible ASCII characters without spaces.'
263
+ end
264
+ return if max_retries.nil? || retry_count?(max_retries)
265
+
266
+ raise ArgumentError, 'max_retries must be a nonnegative integer.'
267
+ end
268
+
269
+ def nonempty_string?(value)
270
+ value.is_a?(String) && !value.strip.empty?
271
+ end
272
+
273
+ def content(template_id, template_key, fields)
274
+ if template_id.nil? == template_key.nil?
275
+ raise ArgumentError, 'Send either template_id, or template_key with subject and html or text.'
276
+ end
277
+
278
+ template_key.nil? ? template_content(template_id, fields) : code_content(template_key, fields)
279
+ end
280
+
281
+ def template_content(template_id, fields)
282
+ refuse_fields(fields, allowed: TEMPLATE_FIELDS, form: :template_id, other_form: :template_key,
283
+ why: TEMPLATE_FORM_WHY)
284
+ raise ArgumentError, "template_id must be the template's UUID." unless uuid?(template_id)
285
+
286
+ template_data = fields.fetch(:template_data, {})
287
+ raise ArgumentError, 'template_data must be a Hash.' unless template_data.is_a?(Hash)
288
+
289
+ { template_id: template_id.downcase, message_data: isoify_dates(template_data) }
290
+ end
291
+
292
+ def code_content(template_key, fields)
293
+ refuse_fields(fields, allowed: CODE_FIELDS, form: :template_key, other_form: :template_id,
294
+ why: CODE_FORM_WHY)
295
+ unless template_key.is_a?(String) && TEMPLATE_KEY_PATTERN.match?(template_key)
296
+ raise ArgumentError,
297
+ 'template_key can only have letters, digits, hyphens (-) and underscores (_), at most 400.'
298
+ end
299
+ raise ArgumentError, 'A send with template_key needs a subject.' unless fields[:subject].is_a?(String)
300
+ unless fields.key?(:html) || fields.key?(:text)
301
+ raise ArgumentError,
302
+ 'A send with template_key needs html, text or both.'
303
+ end
304
+
305
+ %i[html text from].each do |name|
306
+ raise ArgumentError, "#{name} must be a string." if fields.key?(name) && !fields[name].is_a?(String)
307
+ end
308
+ unless [nil, true, false].include?(fields[:capture_engagement])
309
+ raise ArgumentError, 'capture_engagement must be true or false.'
310
+ end
311
+
312
+ code = fields.slice(:subject, :html, :text, :from).merge(template_key: template_key)
313
+ code[:reply_to] = addresses(fields[:reply_to]) if fields.key?(:reply_to)
314
+ code[:capture_engagement] = fields.fetch(:capture_engagement, true)
315
+ code
316
+ end
317
+
318
+ def refuse_fields(fields, allowed:, form:, other_form:, why:)
319
+ field = fields.keys.find { |name| !allowed.include?(name) }
320
+ return if field.nil?
321
+
322
+ message = "A send with #{form} doesn't take #{field}, so no email was sent."
323
+ raise ArgumentError, "#{message} #{why} Remove #{field}, or send with #{other_form}."
324
+ end
325
+
326
+ def addresses(reply_to)
327
+ list = reply_to.is_a?(String) ? [reply_to] : reply_to
328
+ return list if list.is_a?(Array) && list.all? { |address| nonempty_string?(address) }
329
+
330
+ raise ArgumentError, 'reply_to must be one address or a list of addresses.'
331
+ end
332
+
333
+ def call(nothing_done, key_needed)
334
+ admit(nothing_done)
335
+ begin
336
+ unless @app_host
337
+ # A custom host is often an ingestion proxy, which must not receive the secret key.
338
+ raise EmailError.new(
339
+ code: 'missing_app_host',
340
+ message: "The client's host is not PostHog Cloud, so the client can't tell where to send email. " \
341
+ 'Set app_host to your PostHog app host on the client.'
342
+ )
343
+ end
344
+ raise EmailError.new(code: 'missing_secret_key', message: key_needed) if @secret_key.nil? || @secret_key.empty?
345
+
346
+ yield @secret_key
347
+ ensure
348
+ finish
349
+ end
350
+ end
351
+
352
+ def admit(nothing_done)
353
+ if @disabled
354
+ raise EmailError.new(code: 'client_disabled',
355
+ message: "The PostHog client is disabled, so #{nothing_done}.")
356
+ end
357
+
358
+ @mutex.synchronize do
359
+ forget_parent_requests
360
+ if @closed
361
+ raise EmailError.new(code: 'client_closed',
362
+ message: "The PostHog client is shut down, so #{nothing_done}.")
363
+ end
364
+
365
+ @in_flight += 1
366
+ end
367
+ end
368
+
369
+ # A forked child inherits the parent's count, but not the threads that would settle those requests.
370
+ def forget_parent_requests
371
+ return if @pid == Process.pid
372
+
373
+ @pid = Process.pid
374
+ @in_flight = 0
375
+ end
376
+
377
+ def finish
378
+ @mutex.synchronize do
379
+ @in_flight -= 1
380
+ @idle.broadcast if @in_flight.zero?
381
+ end
382
+ end
383
+
384
+ def with_retries(max_retries)
385
+ attempt = 0
386
+ begin
387
+ yield
388
+ rescue EmailError => e
389
+ delay = retry_delay(e, attempt, max_retries)
390
+ raise if delay.nil?
391
+
392
+ sleep(delay)
393
+ attempt += 1
394
+ retry
395
+ end
396
+ end
397
+
398
+ def retry_delay(error, attempt, max_retries)
399
+ return nil if attempt >= max_retries || !retryable?(error)
400
+ # Retrying before a longer Retry-After would break the server's rate limit.
401
+ return nil if error.retry_after && error.retry_after > MAX_RETRY_DELAY_SECONDS
402
+
403
+ backoff = [MAX_RETRY_DELAY_SECONDS, (2**attempt) * rand(0.8..1.2)].min
404
+ [backoff, error.retry_after || 0].max
405
+ end
406
+
407
+ def retryable?(error)
408
+ # A send carries an idempotency key, so a retry can't queue a second email. A status read changes nothing.
409
+ case error.status_code
410
+ when nil then error.code == 'network_error'
411
+ when 409 then error.code == 'idempotency_key_in_flight'
412
+ when 429 then error.code != 'quota_limited'
413
+ else error.status_code == 503
414
+ end
415
+ end
416
+
417
+ def request(request, uri, secret_key, headers, body = nil)
418
+ request['Authorization'] = "Bearer #{secret_key}"
419
+ request['Accept'] = 'application/json'
420
+ request['User-Agent'] = @user_agent if @user_agent
421
+ request['X-PostHog-Migrated-From'] = @migrated_from if @migrated_from
422
+ headers.each { |name, value| request[name] = value }
423
+ request.body = body if body
424
+
425
+ head = nil
426
+ response = Net::HTTP.start(uri.hostname, uri.port, connection_options(uri)) do |http|
427
+ http.request(request) { |started| head = started }
428
+ end
429
+ [response.code.to_i, parse_json(response.body), parse_retry_after(response['Retry-After'])]
430
+ rescue *NETWORK_ERRORS
431
+ # A reply whose body broke off retries with the same idempotency key, after its Retry-After when it sent one.
432
+ raise EmailError.new(code: 'network_error', message: 'The request to PostHog failed.',
433
+ retry_after: head && parse_retry_after(head['Retry-After']))
434
+ end
435
+
436
+ def connection_options(uri)
437
+ options = {
438
+ use_ssl: uri.scheme == 'https', open_timeout: @timeout, read_timeout: @timeout, write_timeout: @timeout,
439
+ # Net::HTTP returns a body cut off by a closed connection as if it were whole. Raise instead.
440
+ max_retries: 0, ignore_eof: false
441
+ }
442
+ options[:verify_mode] = OpenSSL::SSL::VERIFY_NONE if @skip_ssl_verification
443
+ options
444
+ end
445
+
446
+ def parse_json(body)
447
+ JSON.parse(body.to_s)
448
+ rescue JSON::ParserError
449
+ nil
450
+ end
451
+
452
+ def parse_send_reply((status, payload, retry_after))
453
+ fields = payload.is_a?(Hash) ? payload : {}
454
+ raise reply_error(fields, status, retry_after) unless status.between?(200, 299)
455
+
456
+ id = text(fields['message_id'])
457
+ unless id
458
+ raise EmailError.new(code: 'api_error', message: 'PostHog answered without a message ID.',
459
+ status_code: status)
460
+ end
461
+
462
+ SendResult.new(id: id, replayed: fields['replayed'] == true, warnings: warnings(fields['warnings']))
463
+ end
464
+
465
+ def warnings(entries)
466
+ return [] unless entries.is_a?(Array)
467
+
468
+ entries.grep(Hash).map do |entry|
469
+ Warning.new(
470
+ code: text(entry['code']) || 'warning',
471
+ message: text(entry['detail']) || text(entry['message']) || '',
472
+ fix: text(entry['fix'])
473
+ )
474
+ end
475
+ end
476
+
477
+ def parse_status_reply((status, payload, retry_after))
478
+ fields = payload.is_a?(Hash) ? payload : {}
479
+ raise reply_error(fields, status, retry_after) unless status.between?(200, 299)
480
+
481
+ record = {
482
+ id: text(fields['id']),
483
+ status: text(fields['status']),
484
+ created_at: timestamp(fields['created_at']),
485
+ status_at: timestamp(fields['status_at'])
486
+ }
487
+ if record.value?(nil)
488
+ raise EmailError.new(code: 'api_error', message: 'PostHog answered without an email status.',
489
+ status_code: status)
490
+ end
491
+
492
+ Status.new(**record, reason: text(fields['reason']))
493
+ end
494
+
495
+ def timestamp(value)
496
+ text(value) && Time.iso8601(value)
497
+ rescue ArgumentError
498
+ nil
499
+ end
500
+
501
+ def reply_error(fields, status, retry_after)
502
+ EmailError.new(
503
+ code: text(fields['code']) || 'api_error',
504
+ message: text(fields['detail']) || "PostHog answered HTTP #{status}.",
505
+ status_code: status,
506
+ why: text(fields['why']),
507
+ fix: text(fields['fix']),
508
+ docs_url: text(fields['docs_url']),
509
+ field: text(fields['attr']),
510
+ retry_after: retry_after
511
+ )
512
+ end
513
+
514
+ def text(value)
515
+ value if value.is_a?(String) && !value.empty?
516
+ end
517
+ end
518
+ end
@@ -151,19 +151,6 @@ module PostHog
151
151
  @backoff_policy.next_interval.to_f / 1000
152
152
  end
153
153
 
154
- def parse_retry_after(value)
155
- return nil if value.nil? || value.empty?
156
-
157
- seconds = Float(value, exception: false)
158
- return seconds if seconds && seconds >= 0
159
-
160
- parsed_time = Time.httpdate(value)
161
- delay = parsed_time - Time.now
162
- delay.positive? ? delay : nil
163
- rescue ArgumentError
164
- nil
165
- end
166
-
167
154
  # Sends a request for the batch, returns [status_code, body]
168
155
  def send_request(api_key, batch)
169
156
  @last_retry_after = nil
data/lib/posthog/utils.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'time'
4
+
3
5
  require 'posthog/flag_evaluation_errors'
4
6
 
5
7
  module PostHog
@@ -143,6 +145,22 @@ module PostHog
143
145
  end
144
146
  end
145
147
 
148
+ # public: Seconds to wait from a Retry-After header, given as seconds or as an HTTP date, or nil when the
149
+ # header is absent, malformed or in the past
150
+ #
151
+ def parse_retry_after(value)
152
+ return nil if value.nil? || value.empty?
153
+
154
+ seconds = Float(value, exception: false)
155
+ return seconds if seconds&.finite? && seconds >= 0
156
+
157
+ parsed_time = Time.httpdate(value)
158
+ delay = parsed_time - Time.now
159
+ delay.positive? ? delay : nil
160
+ rescue ArgumentError
161
+ nil
162
+ end
163
+
146
164
  # public: Current monotonic clock time in seconds, for measuring durations
147
165
  #
148
166
  def monotonic_time
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PostHog
4
- VERSION = '3.29.1'
4
+ VERSION = '3.30.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: posthog-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.29.1
4
+ version: 3.30.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ''
@@ -38,6 +38,8 @@ files:
38
38
  - lib/posthog/backoff_policy.rb
39
39
  - lib/posthog/client.rb
40
40
  - lib/posthog/defaults.rb
41
+ - lib/posthog/email_error.rb
42
+ - lib/posthog/emails.rb
41
43
  - lib/posthog/exception_capture.rb
42
44
  - lib/posthog/feature_flag.rb
43
45
  - lib/posthog/feature_flag_error.rb