diff --git a/.changeset/emails-send-category.md b/.changeset/emails-send-category.md new file mode 100644 index 00000000..c0f89e62 --- /dev/null +++ b/.changeset/emails-send-category.md @@ -0,0 +1,5 @@ +--- +"posthog-ruby": minor +--- + +`emails.send` takes a `category` on sends with `template_key`: `PostHog::Emails::TRANSACTIONAL` for transactional email with no category, or a `PostHog::Emails::MessageCategory` with a `key`, `type` (`marketing` or `transactional`), `name` and optional `description`. Leave it out for transactional email with no category, as before, or for the category of the template that `template_key` names. PostHog refuses `TRANSACTIONAL` on a template in a category with `category_mismatch`. A template made in PostHog is transactional: PostHog refuses a send whose `template_key` names a template in a marketing category with `marketing_template`. PostHog creates the category the first time a send names its key, and uses its `name` and `description` only then; change them later in PostHog. A marketing email skips recipients who opted out and needs an `{{ unsubscribe_url }}` link. diff --git a/lib/posthog/emails.rb b/lib/posthog/emails.rb index 1ea5ffde..d182915a 100644 --- a/lib/posthog/emails.rb +++ b/lib/posthog/emails.rb @@ -13,8 +13,8 @@ require 'posthog/utils' module PostHog - # Sends transactional email, from an email template made in PostHog or from content your code rendered, - # and reads the status of each email sent. Reach it through {Client#emails}. + # Sends transactional email from an email template made in PostHog, and transactional or marketing email from + # content your code rendered. It also reads the status of each email sent. Reach it through {Client#emails}. # # A send needs the client's `secret_key`, a project secret API key (`phs_...`) with the # `transactional_email:write` scope: the "Send transactional email" preset in your project settings. A key with @@ -43,6 +43,18 @@ module PostHog # html: rendered_html, # distinct_id: 'user_123' # ) + # + # @example A marketing email in one of the project's message categories + # posthog.emails.send( + # to: 'user@example.com', + # template_key: 'monthly-digest', + # subject: 'Your monthly digest', + # html: rendered_html, # links to {{ unsubscribe_url }} + # category: PostHog::Emails::MessageCategory.new( + # key: 'newsletter', type: 'marketing', name: 'Newsletter', description: 'Monthly product news' + # ), + # distinct_id: 'user_123' + # ) class Emails include PostHog::Logging include PostHog::Utils @@ -66,10 +78,34 @@ class Emails # @return [String] `queued`, `sent`, `failed`, `delivered`, `bounced` or `complained`. Later SDK versions # can see values this list does not have yet. # @!attribute [r] reason - # @return [String, nil] Why a failed email was not sent, such as `recipient_suppressed`, or the bounce type + # @return [String, nil] Why a failed email was not sent, such as `recipient_suppressed` or + # `recipient_opted_out`, or the bounce type # of a bounced email: `hard`, `soft` or `unknown`. Status = Struct.new(:id, :status, :created_at, :status_at, :reason, keyword_init: true) + # The built-in `category` of {#send}: a transactional email with no category. Unlike leaving `category` out, it + # doesn't follow the category of the template that `template_key` names, so PostHog refuses it for a template in a + # category. + TRANSACTIONAL = 'transactional' + + # A message category that a send defines in code. PostHog creates the category the first time a send names its + # key, or restores it if it was deleted, and otherwise leaves it unchanged. Its name and description apply only + # when the send creates the category; change them later in PostHog. Recipients opt in to and out of it in their + # email preferences. + # + # @!attribute [r] key + # @return [String] The category's key, such as `newsletter`. It names the category in the opt-out and + # preferences APIs. + # @!attribute [r] type + # @return [String] `marketing` or `transactional`. It must match the type of an existing category with the + # same key. + # @!attribute [r] name + # @return [String] The name recipients see in their email preferences, used only when the send creates the + # category. + # @!attribute [r] description + # @return [String, nil] What the category's emails are about, used only when the send creates the category. + MessageCategory = Struct.new(:key, :type, :name, :description, keyword_init: true) + SEND_PATH = '/api/projects/@current/messaging/send/email/' STATUS_PATH = '/api/projects/@current/messaging/emails/%s/' US_APP_HOST = 'https://us.posthog.com' @@ -90,11 +126,12 @@ class Emails 'transactional_email:write scope.' READ_KEY_NEEDED = 'Reading an email status needs secret_key, a project secret API key with the ' \ 'transactional_email:read or transactional_email:write scope.' - TEMPLATE_FORM_WHY = 'A template made in PostHog sets its own sender, subject and content, and follows the ' \ - "project's engagement setting." + TEMPLATE_FORM_WHY = 'A template made in PostHog sets its own sender, subject, content and category, and ' \ + "follows the project's engagement setting." CODE_FORM_WHY = 'A send with template_key takes its content from your code.' TEMPLATE_FIELDS = %i[template_data].freeze - CODE_FIELDS = %i[subject html text from reply_to capture_engagement].freeze + CODE_FIELDS = %i[subject html text from reply_to capture_engagement category].freeze + CATEGORY_TYPES = [TRANSACTIONAL, 'marketing'].freeze NETWORK_ERRORS = [ Timeout::Error, SocketError, SystemCallError, IOError, EOFError, OpenSSL::SSL::SSLError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError, Zlib::Error @@ -102,7 +139,7 @@ class Emails private_constant :SEND_PATH, :STATUS_PATH, :US_APP_HOST, :EU_APP_HOST, :CLOUD_APP_HOSTS, :MAX_RETRY_DELAY_SECONDS, :UUID_PATTERN, :IDEMPOTENCY_KEY_PATTERN, :TEMPLATE_KEY_PATTERN, :HEADER_TOKEN_PATTERN, :SEND_KEY_NEEDED, :READ_KEY_NEEDED, :TEMPLATE_FORM_WHY, - :CODE_FORM_WHY, :TEMPLATE_FIELDS, :CODE_FIELDS, :NETWORK_ERRORS + :CODE_FORM_WHY, :TEMPLATE_FIELDS, :CODE_FIELDS, :CATEGORY_TYPES, :NETWORK_ERRORS # @api private def initialize(secret_key:, host:, app_host:, timeout:, max_retries:, migrated_from:, user_agent:, disabled:, @@ -142,6 +179,15 @@ def initialize(secret_key:, host:, app_host:, timeout:, max_retries:, migrated_f # @param reply_to [String, Array] Code form: one address or a list of addresses. # @param capture_engagement [Boolean] Code form: whether PostHog captures the email's opens and clicks as events # of the recipient. Defaults to +true+. A project whose admin turned engagement capture off captures nothing. + # @param category [String, MessageCategory] Code form: {TRANSACTIONAL} for a transactional email with no category, + # or the {MessageCategory} the email belongs to. Left out, the email follows the category of the template that + # `template_key` names, or is transactional with no category. A marketing email skips recipients who opted out of + # its category or of all marketing, can't have copies, and needs a `{{ unsubscribe_url }}` link in its `html`, or + # in its `text` when it has no `html`. PostHog refuses a send with `category_mismatch` when the type differs from + # the existing category's, or when `template_key` names a template in another category, {TRANSACTIONAL} for a + # template in a category included; `marketing_template` when `template_key` names a template in a marketing + # category, because a template made in PostHog is transactional; `too_many_categories` when the project can't + # take another category; `unsubscribe_link_required`; `copies_not_allowed`; or `recipient_opted_out`. # @param distinct_id [String] The recipient's distinct ID in your product. The email's events go to this # person. Without it, they go to a person whose distinct ID is the email address. # @param idempotency_key [String] 1 to 255 visible ASCII characters. A send with a key PostHog has already @@ -155,11 +201,11 @@ def initialize(secret_key:, host:, app_host:, timeout:, max_retries:, migrated_f # @raise [ArgumentError] An argument has the wrong type or format, or the call mixes the two forms. def send( to:, template_id: nil, template_data: nil, template_key: nil, subject: nil, html: nil, text: nil, from: nil, - reply_to: nil, capture_engagement: nil, distinct_id: nil, idempotency_key: nil, max_retries: nil + reply_to: nil, capture_engagement: nil, category: nil, distinct_id: nil, idempotency_key: nil, max_retries: nil ) fields = { template_data: template_data, subject: subject, html: html, text: text, from: from, reply_to: reply_to, - capture_engagement: capture_engagement + capture_engagement: capture_engagement, category: category }.compact check_recipient(to, distinct_id, idempotency_key, max_retries) payload = content(template_id, template_key, fields).merge(to: to) @@ -312,6 +358,7 @@ def code_content(template_key, fields) code = fields.slice(:subject, :html, :text, :from).merge(template_key: template_key) code[:reply_to] = addresses(fields[:reply_to]) if fields.key?(:reply_to) code[:capture_engagement] = fields.fetch(:capture_engagement, true) + code[:category] = category_payload(fields[:category]) if fields.key?(:category) code end @@ -330,6 +377,28 @@ def addresses(reply_to) raise ArgumentError, 'reply_to must be one address or a list of addresses.' end + def category_payload(category) + return category if category == TRANSACTIONAL + unless category.is_a?(MessageCategory) + raise ArgumentError, 'category must be PostHog::Emails::TRANSACTIONAL or a PostHog::Emails::MessageCategory.' + end + + check_category_fields(category) + category.to_h.compact + end + + def check_category_fields(category) + raise ArgumentError, 'category key and name must be strings.' unless [category.key, category.name].all?(String) + raise ArgumentError, "category type must be 'marketing' or 'transactional'." unless category_type?(category.type) + return if category.description.nil? || category.description.is_a?(String) + + raise ArgumentError, 'category description must be a string.' + end + + def category_type?(value) + CATEGORY_TYPES.include?(value) + end + def call(nothing_done, key_needed) admit(nothing_done) begin diff --git a/public_api_snapshot.txt b/public_api_snapshot.txt index 16679caa..e1946088 100644 --- a/public_api_snapshot.txt +++ b/public_api_snapshot.txt @@ -74,8 +74,22 @@ instance_method PostHog::EmailError#status_code() instance_method PostHog::EmailError#why() class PostHog::Emails instance_method PostHog::Emails#get(message_id) -instance_method PostHog::Emails#send(to:, template_id: ..., template_data: ..., template_key: ..., subject: ..., html: ..., text: ..., from: ..., reply_to: ..., capture_engagement: ..., distinct_id: ..., idempotency_key: ..., max_retries: ...) +instance_method PostHog::Emails#send(to:, template_id: ..., template_data: ..., template_key: ..., subject: ..., html: ..., text: ..., from: ..., reply_to: ..., capture_engagement: ..., category: ..., distinct_id: ..., idempotency_key: ..., max_retries: ...) class_method PostHog::Emails.logger() +class PostHog::Emails::MessageCategory < Struct +instance_method PostHog::Emails::MessageCategory#description() +instance_method PostHog::Emails::MessageCategory#description=(_) +instance_method PostHog::Emails::MessageCategory#key() +instance_method PostHog::Emails::MessageCategory#key=(_) +instance_method PostHog::Emails::MessageCategory#name() +instance_method PostHog::Emails::MessageCategory#name=(_) +instance_method PostHog::Emails::MessageCategory#type() +instance_method PostHog::Emails::MessageCategory#type=(_) +class_method PostHog::Emails::MessageCategory.[](*arg0) +class_method PostHog::Emails::MessageCategory.inspect() +class_method PostHog::Emails::MessageCategory.keyword_init?() +class_method PostHog::Emails::MessageCategory.members() +class_method PostHog::Emails::MessageCategory.new(*arg0) class PostHog::Emails::SendResult < Struct instance_method PostHog::Emails::SendResult#id() instance_method PostHog::Emails::SendResult#id=(_) @@ -104,6 +118,7 @@ class_method PostHog::Emails::Status.inspect() class_method PostHog::Emails::Status.keyword_init?() class_method PostHog::Emails::Status.members() class_method PostHog::Emails::Status.new(*arg0) +constant PostHog::Emails::TRANSACTIONAL: String class PostHog::Emails::Warning < Struct instance_method PostHog::Emails::Warning#code() instance_method PostHog::Emails::Warning#code=(_) diff --git a/spec/posthog/emails_spec.rb b/spec/posthog/emails_spec.rb index 31ea9d62..6e3d11b0 100644 --- a/spec/posthog/emails_spec.rb +++ b/spec/posthog/emails_spec.rb @@ -36,6 +36,36 @@ def sent_bodies bodies end + def serve_categories(categories = {}, templates: {}) + filed = [] + stub_request(:post, send_url).to_return do |request| + body = JSON.parse(request.body) + category = body['category'] + next refuse(400, 'invalid_input') unless [nil, 'transactional'].include?(category) || category.is_a?(Hash) + + if templates.key?(body['template_key']) + stored = categories[templates[body['template_key']]] + next refuse(400, 'marketing_template') if stored&.fetch('type') == 'marketing' + + named = category.is_a?(Hash) ? category.slice('key', 'type') : category + followed = stored&.slice('key', 'type') || 'transactional' + next refuse(400, 'category_mismatch') unless category.nil? || named == followed + + category = stored + end + if category.is_a?(Hash) + content = body['html'].to_s.empty? ? body['text'] : body['html'] + unsubscribable = content.to_s.match?(/\{\{\s*unsubscribe_url\s*\}\}/) + next refuse(400, 'unsubscribe_link_required') if category['type'] == 'marketing' && !unsubscribable + + categories[category['key']] ||= category + end + filed << (category['key'] if category.is_a?(Hash)) + accept + end + [categories, filed] + end + # Serves each raw HTTP response once, in order, on a local socket, and points the client's app host at it. WebMock # is off meanwhile: its Net::HTTP wrapper reads the whole body before it hands over the response. def serve(*responses, tls: nil) @@ -140,6 +170,9 @@ def send_code(**overrides) it 'refuses code content, because the template sets its own' do expect { send_template(subject: 'Hi') } .to raise_error(ArgumentError, /template_id doesn't take subject.*send with template_key/) + expect { send_template(category: Emails::TRANSACTIONAL) } + .to raise_error(ArgumentError, + /template_id doesn't take category.*its own sender, subject, content and category/) expect(a_request(:any, %r{/messaging/})).not_to have_been_made end end @@ -181,6 +214,66 @@ def send_code(**overrides) expect(sent_bodies.first.keys).to contain_exactly('template_key', 'subject', 'text', 'capture_engagement', 'to') end + it 'creates a category the first time a send names it, and leaves it unchanged on later sends' do + categories, = serve_categories + html = '

News

Unsubscribe' + newsletter = { key: 'newsletter', type: 'marketing' } + + send_code(category: Emails::MessageCategory.new(**newsletter, name: 'Newsletter', + description: 'Monthly product news'), html: html) + result = send_code(category: Emails::MessageCategory.new(**newsletter, name: 'Weekly news', + description: 'Weekly'), html: html) + + expect(result.id).to eq('msg_1') + expect(categories).to eq( + 'newsletter' => { 'key' => 'newsletter', 'type' => 'marketing', 'name' => 'Newsletter', + 'description' => 'Monthly product news' } + ) + end + + it 'files a send under the transactional category it names, and TRANSACTIONAL under none' do + categories, filed = serve_categories + + send_code(category: Emails::MessageCategory.new(key: 'receipts', type: 'transactional', name: 'Receipts')) + send_code(category: Emails::TRANSACTIONAL) + + expect(filed).to eq(['receipts', nil]) + expect(categories).to eq('receipts' => { 'key' => 'receipts', 'type' => 'transactional', 'name' => 'Receipts' }) + end + + context 'with a template_key that names a template in a transactional category' do + let(:receipts) { { 'key' => 'receipts', 'type' => 'transactional', 'name' => 'Receipts' } } + + it "files the email under the template's category when the send leaves category out" do + _, filed = serve_categories({ 'receipts' => receipts }, templates: { 'order-receipt' => 'receipts' }) + + send_code(template_key: 'order-receipt') + + expect(filed).to eq(['receipts']) + end + + it 'refuses TRANSACTIONAL with category_mismatch' do + _, filed = serve_categories({ 'receipts' => receipts }, templates: { 'order-receipt' => 'receipts' }) + + expect { send_code(template_key: 'order-receipt', category: Emails::TRANSACTIONAL) } + .to raise_error(EmailError) do |error| + expect([error.code, error.name]).to eq(%w[category_mismatch validation_error]) + end + expect(filed).to be_empty + end + end + + it 'refuses a template_key that names a template in a marketing category with marketing_template' do + newsletter = { 'key' => 'newsletter', 'type' => 'marketing', 'name' => 'Newsletter' } + _, filed = serve_categories({ 'newsletter' => newsletter }, templates: { 'november-digest' => 'newsletter' }) + + expect { send_code(template_key: 'november-digest', html: 'Unsubscribe') } + .to raise_error(EmailError) do |error| + expect([error.code, error.name]).to eq(%w[marketing_template validation_error]) + end + expect(filed).to be_empty + end + it 'refuses template_data, because local templates are not supported' do expect { send_code(template_data: { name: 'Ada' }) } .to raise_error(ArgumentError, /template_key doesn't take template_data/) @@ -208,6 +301,16 @@ def send_code(**overrides) expect { send_template(template_id: 'welcome') }.to raise_error(ArgumentError, /template's UUID/) expect { send_template(to: ' ') }.to raise_error(ArgumentError, /to must be/) expect { send_template(distinct_id: '') }.to raise_error(ArgumentError, /distinct_id/) + newsletter = { key: 'newsletter', type: 'marketing', name: 'Newsletter' } + [ + 'newsletter', newsletter, + Emails::MessageCategory.new(**newsletter, key: :newsletter), + Emails::MessageCategory.new(**newsletter, type: 'promotional'), + Emails::MessageCategory.new(**newsletter, name: nil), + Emails::MessageCategory.new(**newsletter, description: 1) + ].each do |category| + expect { send_code(category: category) }.to raise_error(ArgumentError, /\Acategory/) + end expect { send_template(idempotency_key: 'has space') }.to raise_error(ArgumentError, /idempotency_key/) expect { send_template(max_retries: -1) }.to raise_error(ArgumentError, /max_retries/) expect { send_template(template_data: []) }.to raise_error(ArgumentError, /template_data must be a Hash/) @@ -351,22 +454,19 @@ def send_code(**overrides) expect(a_request(:post, send_url)).to have_been_made.once end - [ - [503, 'delivery_unavailable', 2], - [429, 'throttled', 2], - [409, 'idempotency_key_in_flight', 2], - [429, 'quota_limited', 1], - [422, 'recipient_suppressed', 1] - ].each do |status, code, attempts| - it "makes #{attempts} attempts for a #{status} #{code}" do + [[503, 'delivery_unavailable'], [429, 'throttled'], [409, 'idempotency_key_in_flight']].each do |status, code| + it "retries a #{status} #{code} and sends the email" do stub_request(:post, send_url).to_return(refuse(status, code), accept) - begin - send_template - rescue EmailError - nil - end - expect(a_request(:post, send_url)).to have_been_made.times(attempts) + expect(send_template.id).to eq('msg_1') + end + end + + [[429, 'quota_limited'], [422, 'recipient_suppressed'], [422, 'recipient_opted_out']].each do |status, code| + it "raises a #{status} #{code} without retrying it" do + stub_request(:post, send_url).to_return(refuse(status, code), accept) + + expect { send_template }.to raise_error(EmailError) { |error| expect(error.code).to eq(code) } end end @@ -508,6 +608,11 @@ def send_code(**overrides) [409, 'idempotency_key_in_flight', 'concurrent_idempotent_requests'], [422, 'idempotency_key_reused', 'invalid_idempotent_request'], [422, 'recipient_suppressed', 'validation_error'], + [422, 'recipient_opted_out', 'validation_error'], + [400, 'category_mismatch', 'validation_error'], + [400, 'too_many_categories', 'validation_error'], + [400, 'unsubscribe_link_required', 'validation_error'], + [400, 'copies_not_allowed', 'validation_error'], [429, 'quota_limited', 'monthly_quota_exceeded'], [429, 'throttled', 'rate_limit_exceeded'], [500, 'server_error', 'application_error']