Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/emails-attachments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-ruby": minor
---

`client.emails.send` with `template_key:` takes `cc:`, `bcc:` and `attachments:`. `cc` and `bcc` take one address or a list. Each attachment is a Hash with `filename`, `content`, and an optional `content_type` and `content_id` (`content_id` shows the file inline). Pass the file's bytes as `content`, such as `File.binread('invoice.pdf')` or a list of byte values, and the client encodes them as Base64; any other String is taken as the file already encoded as Base64. A file the client can't send, such as one given by `path`, raises `PostHog::EmailError` with code `invalid_attachment` before any request.
5 changes: 5 additions & 0 deletions .changeset/emails-send.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-ruby": minor
---

Send transactional email with `client.emails.send`, from an email template made in PostHog (`template_id:` with `template_data:`) or from content your code rendered (`template_key:`, `subject:` and `html:` or `text:`). Read what happened to a sent email with `client.emails.get(message_id)`. Sends need `secret_key`, a project secret API key with the `hog_flow:write` scope, and go to the PostHog Cloud app host of the client's `host`; set `app_host` for any other host. A send retries network errors, rate limits and an unavailable service with the same idempotency key, and raises `PostHog::EmailError` when PostHog doesn't queue the email. To send a React email, render it to HTML in your app first. New client options: `app_host`, `email_request_timeout_seconds`, `email_request_max_retries` and `migrated_from`.
31 changes: 30 additions & 1 deletion lib/posthog/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
require 'posthog/feature_flag_evaluations'
require 'posthog/send_feature_flags_options'
require 'posthog/exception_capture'
require 'posthog/emails'
require 'posthog/internal/context'

module PostHog
Expand Down Expand Up @@ -127,6 +128,14 @@ def _decrement_instance_count(api_key)
# @option opts [Boolean] :compress_request Set to +false+ to disable gzip compression for batch uploads.
# @option opts [Object] :flag_definition_cache_provider An object implementing the {FlagDefinitionCacheProvider}
# interface for distributed flag definition caching.
# @option opts [String, nil] :app_host Your PostHog app host, such as `https://posthog.example.com`, for {#emails}.
# Needed only when +:host+ is not PostHog Cloud, such as a reverse proxy, which must not receive the secret key.
# @option opts [Numeric] :email_request_timeout_seconds How long each {#emails} request may take, in seconds.
# Defaults to 15.
# @option opts [Integer] :email_request_max_retries How many times {#emails} retries a request after a network
# error, a rate limit or an unavailable service. Defaults to 2. Set to 0 when a job runner already retries.
# @option opts [String, nil] :migrated_from The email provider you moved from, such as `resend`. {#emails} sends
# it with every request.
# @option opts [Boolean] :is_server +true+ to stamp captured events with `$is_server => true` so PostHog
# attributes them as server-side. Defaults to +true+. Set to +false+ when running posthog-ruby as a
# client/CLI so the device OS is attributed normally and `$is_server` is omitted.
Expand Down Expand Up @@ -154,6 +163,19 @@ def initialize(opts = {})
@lib = opts[:_lib] || 'posthog-ruby'
@lib_version = (opts[:_lib_version] || PostHog::VERSION).to_s
@headers = Defaults::Request::HEADERS.merge('User-Agent' => "#{@lib}/#{@lib_version}")
# Built before the worker, the instance count and the flags poller start, so a bad email option raises
# before anything runs or sends the secret key.
@emails = Emails.new(
secret_key: secret_key,
host: opts[:host],
app_host: opts[:app_host],
timeout: opts[:email_request_timeout_seconds] || Defaults::Emails::REQUEST_TIMEOUT_SECONDS,
max_retries: opts[:email_request_max_retries] || Defaults::Emails::REQUEST_MAX_RETRIES,
migrated_from: opts[:migrated_from],
user_agent: @headers['User-Agent'],
disabled: @disabled,
skip_ssl_verification: opts[:skip_ssl_verification] == true
)
@worker_mutex = Mutex.new
@shutdown_mutex = Mutex.new
@shutdown_condition = ConditionVariable.new
Expand Down Expand Up @@ -233,6 +255,12 @@ def initialize(opts = {})
@deprecation_emitted_for = Concurrent::Set.new
end

# Sends transactional email from email templates made in PostHog or from content your code rendered, and reads
# the status of each email sent. Needs +:secret_key+, a project secret API key with the `hog_flow:write` scope.
#
# @return [PostHog::Emails]
attr_reader :emails

# When sync_mode is enabled, blocks until in-flight requests are complete
# and returns true. Timeout parameter has no effect.
#
Expand Down Expand Up @@ -977,7 +1005,8 @@ def shutdown(timeout: nil)
@distinct_id_has_sent_flag_calls_mutex.synchronize do
@distinct_id_has_sent_flag_calls.clear
end
flushed
emails_settled = @emails.close(timeout: deadline && [deadline - monotonic_time, 0].max)
flushed &&= emails_settled
ensure
@shutdown_mutex.synchronize do
@shutdown_result = flushed
Expand Down
5 changes: 5 additions & 0 deletions lib/posthog/defaults.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ module FeatureFlags
FLAG_REQUEST_MAX_RETRIES = 1
end

module Emails
REQUEST_TIMEOUT_SECONDS = 15
REQUEST_MAX_RETRIES = 2
end

module Queue
MAX_SIZE = 10_000
end
Expand Down
60 changes: 60 additions & 0 deletions lib/posthog/email_error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# frozen_string_literal: true

module PostHog
# PostHog did not queue the email, or the client could not tell whether it did.
#
# Branch on {#code}, PostHog's stable error code, or on {#name}, a coarse category of the failure.
# The message never contains the secret key or the request body.
class EmailError < StandardError
# @return [String] A coarse category of the failure, such as `validation_error`, `rate_limit_exceeded`
# or `service_unavailable`.
attr_reader :name
# @return [String] PostHog's error code, such as `quota_limited`, or an SDK code such as `network_error`.
attr_reader :code
# @return [Integer, nil] The HTTP status, when PostHog answered.
attr_reader :status_code
# @return [String, nil] Why PostHog refused the email.
attr_reader :why
# @return [String, nil] How to fix the request.
attr_reader :fix
# @return [String, nil] A link to the docs for this error.
attr_reader :docs_url
# @return [String, nil] The request field the error is about, when PostHog named one.
attr_reader :field
# @return [Float, nil] How long PostHog asked the client to wait before it sends again, in seconds.
attr_reader :retry_after

# @api private
def initialize(code:, message:, status_code: nil, why: nil, fix: nil, docs_url: nil, field: nil, retry_after: nil)
super(message)
@name = self.class.name_for(status_code, code)
@code = code
@status_code = status_code
@why = why
@fix = fix
@docs_url = docs_url
@field = field
@retry_after = retry_after
end

SDK_ERROR_NAMES = { 'network_error' => 'service_unavailable', 'invalid_attachment' => 'validation_error' }.freeze
private_constant :SDK_ERROR_NAMES

# @api private
def self.name_for(status_code, code)
return 'missing_api_key' if code == 'missing_secret_key' || status_code == 401
return SDK_ERROR_NAMES.fetch(code, 'application_error') if status_code.nil?

case status_code
when 400 then 'validation_error'
when 403 then 'invalid_permission'
when 404 then 'not_found'
when 409 then code == 'template_outdated' ? 'validation_error' : 'concurrent_idempotent_requests'
when 422 then code == 'idempotency_key_reused' ? 'invalid_idempotent_request' : 'validation_error'
when 429 then code == 'quota_limited' ? 'monthly_quota_exceeded' : 'rate_limit_exceeded'
when 503 then 'service_unavailable'
else 'application_error'
end
end
end
end
Loading
Loading