Skip to content
Merged
30 changes: 30 additions & 0 deletions .changeset/emails-send.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
"posthog-ruby": minor
---

The new `client.emails` sends transactional email from your Ruby code and reads each email's delivery status.

```ruby
posthog = PostHog::Client.new(api_key: 'phc_...', secret_key: 'phs_...')

result = posthog.emails.send(
to: 'user@example.com',
distinct_id: 'user_123',
template_key: 'password-reset',
subject: 'Reset your password',
html: rendered_html
)

# 'delivered', 'bounced', ...
status = posthog.emails.get(result.id).status
```

You can also send a template made in PostHog by its `template_id:`.

You need a project secret API key, the kind that starts with `phs_`. Create one under **Project secret API keys** in your project settings. Set its **Transactional email** scope to Write to send emails, or to Read for a key that only reads statuses. Pass it to the client as `secret_key`. Set `app_host` to your PostHog app URL when you self-host PostHog or send through a reverse proxy.

PostHog records each email's delivery, opens and clicks on the person with that `distinct_id`. It groups every email with the same `template_key`, so you see their metrics and logs in one place. The SDK retries a request that hits a network error, a rate limit or an outage twice by default. Every send carries an idempotency key, so a retry never sends the email twice. Pass your own `idempotency_key`, such as `welcome:user_123`, and your job runner's retries are safe too.

A send with your own content also takes `from:`, `reply_to:` and `capture_engagement: false`. Set `migrated_from` on the client to tell PostHog which email provider you moved from. A failed call raises a `PostHog::EmailError` with PostHog's `code` and the HTTP `status_code`.

Docs: [Send transactional emails with Ruby](https://posthog.com/docs/workflows/transactional-emails/ruby) and the [transactional email overview](https://posthog.com/docs/workflows/transactional-emails).
32 changes: 31 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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: Email credentials still reach the ingestion proxy

With host: 'https://proxy.example.com' and app_host: 'https://us.posthog.com', this email credential is also passed to FeatureFlagsPoller at client.rb:236–240, whose constructor fetches /flags/definitions with Authorization: Bearer <secret_key> by default (feature_flags.rb:115–121, 1854–1858), exposing the key to the proxy before any email call. Keep email-only credentials out of the ingestion-host poller, or route its secret-bearing requests through the trusted app host.

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,13 @@ 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
# `transactional_email: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 @@ -979,7 +1008,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
57 changes: 57 additions & 0 deletions lib/posthog/email_error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# 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

# @api private
def self.name_for(status_code, code)
return 'missing_api_key' if code == 'missing_secret_key' || status_code == 401
return code == 'network_error' ? 'service_unavailable' : '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