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: 4 additions & 1 deletion lib/posthog/email_error.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,13 @@ def initialize(code:, message:, status_code: nil, why: nil, fix: nil, docs_url:
@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 code == 'network_error' ? 'service_unavailable' : 'application_error' if status_code.nil?
return SDK_ERROR_NAMES.fetch(code, 'application_error') if status_code.nil?

case status_code
when 400 then 'validation_error'
Expand Down
104 changes: 96 additions & 8 deletions lib/posthog/emails.rb
Original file line number Diff line number Diff line change
Expand Up @@ -91,15 +91,18 @@ class Emails
"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 cc bcc attachments capture_engagement].freeze
ATTACHMENT_FIELDS = %w[filename content content_type content_id].freeze
BASE64_PATTERN = %r{\A(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?\z}
NETWORK_ERRORS = [
Timeout::Error, SocketError, SystemCallError, IOError, EOFError, OpenSSL::SSL::SSLError,
Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError, Zlib::Error
].freeze
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, :ATTACHMENT_FIELDS, :BASE64_PATTERN,
:NETWORK_ERRORS

# @api private
def initialize(secret_key:, host:, app_host:, timeout:, max_retries:, migrated_from:, user_agent:, disabled:,
Expand Down Expand Up @@ -137,6 +140,18 @@ def initialize(secret_key:, host:, app_host:, timeout:, max_retries:, migrated_f
# @param from [String] Code form: a verified sender, such as `Acme <hello@example.com>`. Without it, the
# project's default sender.
# @param reply_to [String, Array<String>] Code form: one address or a list of addresses.
# @param cc [String, Array<String>] Code form: one address or a list of addresses that get a visible copy, at
# most 10. An empty list sends no copy.
# @param bcc [String, Array<String>] Code form: one address or a list of addresses that get a hidden copy, at
# most 10. An empty list sends no copy.
# @param attachments [Array<Hash>] Code form: files sent with the email, at most 10 and 512 KB in total. Each
# file is a Hash with:
# - +filename+ [String] the name the recipient sees, such as `invoice.pdf`;
# - +content+ [String, Array<Integer>] the file's bytes, as a binary String such as `File.binread(path)`
# returns or as a list of byte values, which the client encodes as Base64. Any other String is taken as the
# file already Base64 encoded. PostHog can't fetch a file from a path or URL;
# - +content_type+ [String] the MIME type, optional. Without it, the type follows the file name;
# - +content_id+ [String] optional: shows the file inline where `html` has `<img src="cid:...">` with this ID.
# @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 distinct_id [String] The recipient's distinct ID in your product. The email's events go to this
Expand All @@ -149,14 +164,16 @@ def initialize(secret_key:, host:, app_host:, timeout:, max_retries:, migrated_f
# retries off.
# @return [SendResult] The queued email.
# @raise [EmailError] PostHog did not queue the email, or the client could not tell whether it did.
# {EmailError#code} is `invalid_attachment`, before any request, for a file the client can't send.
# @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, cc: nil, bcc: nil, attachments: nil, capture_engagement: 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
cc: cc, bcc: bcc, attachments: attachments, capture_engagement: capture_engagement
}.compact
check_recipient(to, distinct_id, idempotency_key, max_retries)
payload = content(template_id, template_key, fields).merge(to: to)
Expand Down Expand Up @@ -307,7 +324,9 @@ def code_content(template_key, fields)
end

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[:reply_to] = addresses(:reply_to, fields[:reply_to]) if fields.key?(:reply_to)
%i[cc bcc].each { |name| put_unless_empty(code, name, addresses(name, fields[name])) if fields.key?(name) }
put_unless_empty(code, :attachments, attachment_list(fields[:attachments])) if fields.key?(:attachments)
code[:capture_engagement] = fields.fetch(:capture_engagement, true)
code
end
Expand All @@ -320,11 +339,80 @@ def refuse_fields(fields, allowed:, form:, other_form:, why:)
raise ArgumentError, "#{message} #{why} Remove #{field}, or send with #{other_form}."
end

def addresses(reply_to)
list = reply_to.is_a?(String) ? [reply_to] : reply_to
def put_unless_empty(code, name, list)
code[name] = list unless list.empty?
end

def addresses(name, value)
list = value.is_a?(String) ? [value] : value
return list if list.is_a?(Array) && list.all? { |address| nonempty_string?(address) }

raise ArgumentError, 'reply_to must be one address or a list of addresses.'
raise ArgumentError, "#{name} must be one address or a list of addresses."
end

def attachment_list(files)
raise invalid_attachment('attachments must be a list of files.', 'attachments') unless files.is_a?(Array)

files.each_with_index.map { |file, index| attachment(file, "attachments__#{index}") }
end

def attachment(file, field)
unless file.is_a?(Hash)
raise invalid_attachment("An attachment is a Hash with filename and content. Pass the file's bytes as its " \
'content.', field)
end

file = file.transform_keys(&:to_s).compact
if file.key?('path')
raise invalid_attachment("PostHog can't fetch an attachment from a path or URL. Read the file with " \
'File.binread and pass it as content instead.', "#{field}__path")
end
unknown = file.keys.find { |key| !ATTACHMENT_FIELDS.include?(key) }
if unknown
raise invalid_attachment("An attachment takes filename, content, content_type and content_id, not #{unknown}.",
"#{field}__#{unknown}")
end

%w[filename content_type content_id].each do |name|
next if name != 'filename' && !file.key?(name)
next if file[name].is_a?(String)

raise invalid_attachment("An attachment's #{name} must be a String.", "#{field}__#{name}")
end
file.merge('content' => base64_content(file['content'], "#{field}__content"))
end

def base64_content(content, field)
bytes = file_bytes(content)
return [bytes].pack('m0') if bytes
unless content.is_a?(String)
raise invalid_attachment('An attachment needs content: the bytes File.binread returns, a list of byte ' \
'values, or the file encoded as Base64.', field)
end
return content if single_line_base64?(content)
return content.delete("\n") if encode64_layout?(content)

raise invalid_attachment("An attachment's content is text that isn't Base64. Pass the file's bytes, read " \
'with File.binread, or the file encoded as Base64.', field)
end

def single_line_base64?(content)
content.ascii_only? && BASE64_PATTERN.match?(content)
end

def encode64_layout?(content)
content.ascii_only? && content.include?("\n") && [content.unpack1('m')].pack('m') == content
end

def file_bytes(content)
return content if content.is_a?(String) && content.encoding == Encoding::BINARY
return unless content.is_a?(Array) && content.all? { |byte| byte.is_a?(Integer) && byte.between?(0, 255) }

content.pack('C*')
end

def invalid_attachment(message, field)
EmailError.new(code: 'invalid_attachment', message: message, field: field)
end

def call(nothing_done, key_needed)
Expand Down
2 changes: 1 addition & 1 deletion public_api_snapshot.txt
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ 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: ..., cc: ..., bcc: ..., attachments: ..., capture_engagement: ..., distinct_id: ..., idempotency_key: ..., max_retries: ...)
class_method PostHog::Emails.logger()
class PostHog::Emails::SendResult < Struct
instance_method PostHog::Emails::SendResult#id()
Expand Down
114 changes: 113 additions & 1 deletion spec/posthog/emails_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,118 @@ def send_code(**overrides)
end
end

describe 'copies and attachments' do
let(:pdf) { "%PDF-1.7\n\xFF\x00\xE2".b }

def refused_attachment(**overrides)
send_code(**overrides)
rescue EmailError => e
e
end

it 'sends cc and bcc as lists' do
stub_request(:post, send_url).to_return(accept)

send_code(cc: 'finance@example.com', bcc: %w[audit@example.com archive@example.com])

expect(sent_bodies.first).to include('cc' => ['finance@example.com'],
'bcc' => %w[audit@example.com archive@example.com])
end

it 'sends the bytes of each file as Base64, with its type and content ID' do
stub_request(:post, send_url).to_return(accept)

send_code(attachments: [
{ filename: 'invoice.pdf', content: pdf, content_type: 'application/pdf' },
{ 'filename' => 'logo.png', 'content' => [137, 80, 78, 71], 'content_id' => 'logo' },
{ filename: 'notes.txt', content: 'aGVsbG8=' }
])

expect(sent_bodies.first['attachments']).to eq([
{ 'filename' => 'invoice.pdf',
'content' => 'JVBERi0xLjcK/wDi',
'content_type' => 'application/pdf' },
{ 'filename' => 'logo.png', 'content' => 'iVBORw==',
'content_id' => 'logo' },
{ 'filename' => 'notes.txt', 'content' => 'aGVsbG8=' }
])
expect(sent_bodies.first['attachments'][0]['content'].unpack1('m0')).to eq(pdf)
end

it 'leaves out empty lists, so a PostHog version without these fields still sends the email' do
stub_request(:post, send_url).to_return(accept)

send_code(cc: [], bcc: [], attachments: [])

expect(sent_bodies.first.keys).not_to include('cc', 'bcc', 'attachments')
end

it 'sends the same encoded files on every attempt' do
stub_request(:post, send_url).to_return(refuse(503, 'service_unavailable'), accept)

send_code(attachments: [{ filename: 'invoice.pdf', content: pdf }])

encoded_once = [{ 'filename' => 'invoice.pdf', 'content' => 'JVBERi0xLjcK/wDi' }]
expect(a_request(:post, send_url).with { |request| JSON.parse(request.body)['attachments'] == encoded_once })
.to have_been_made.twice
end

it 'takes Base64 with line breaks, as Base64.encode64 writes it' do
stub_request(:post, send_url).to_return(accept)
file = (0..99).to_a.pack('C*')

send_code(attachments: [{ filename: 'data.bin', content: [file].pack('m') }])

expect([file].pack('m')).to include("\n")
expect(sent_bodies.first['attachments'][0]['content']).to eq([file].pack('m0'))
end

it 'refuses a file it cannot send before any request, naming the field and never the content' do
cases = {
{ attachments: { filename: 'a.pdf', content: pdf } } => ['attachments', /list of files/],
{ attachments: ['a.pdf'] } => ['attachments__0', /Hash with filename and content/],
{ attachments: [{ filename: 'a.pdf', path: '/tmp/a.pdf' }] } => ['attachments__0__path', /File.binread/],
{ attachments: [{ filename: 'a.pdf', content: pdf, headers: {} }] } =>
['attachments__0__headers', /not headers/],
{ attachments: [{ filename: 'a.pdf' }] } => ['attachments__0__content', /needs content/],
{ attachments: [{ filename: 'a.pdf', content: [256] }] } => ['attachments__0__content', /needs content/],
{ attachments: [{ filename: 'a.pdf', content: 'secret text, not Base64' }] } =>
['attachments__0__content', /isn't Base64/],
{ attachments: [{ filename: 'a.pdf', content: "%PDF secret text \xE2\xE3".dup.force_encoding('UTF-8') }] } =>
['attachments__0__content', /isn't Base64/],
{ attachments: [{ filename: 'codes.txt', content: "ABCD1234\nEFGH5678\n" }] } =>
['attachments__0__content', /isn't Base64/],
{ attachments: [{ filename: 'a.txt', content: 'aGVsbG8='.encode('UTF-16LE') }] } =>
['attachments__0__content', /isn't Base64/],
{ attachments: [{ filename: :a, content: pdf }] } => ['attachments__0__filename', /filename/],
{ attachments: [{ filename: 'a.pdf', content: pdf, content_type: 1 }] } =>
['attachments__0__content_type', /content_type/]
}

cases.each do |arguments, (field, message)|
error = refused_attachment(**arguments)
expect(error).to be_a(EmailError), "#{arguments} raised #{error.inspect}"
expect([error.code, error.name, error.field]).to eq(['invalid_attachment', 'validation_error', field])
expect(error.message).to match(message)
expect(error.message).not_to include('secret text')
end
expect(a_request(:any, %r{/messaging/})).not_to have_been_made
end

it 'refuses an address list that is not one', :aggregate_failures do
expect { send_code(cc: [nil]) }.to raise_error(ArgumentError, /cc must be one address or a list/)
expect { send_code(bcc: 3) }.to raise_error(ArgumentError, /bcc must be one address or a list/)
end

it 'refuses them on a template send, because the template sets its own recipients and files' do
%i[cc bcc attachments].each do |field|
expect { send_template(field => []) }
.to raise_error(ArgumentError, /template_id doesn't take #{field}.*send with template_key/)
end
expect(a_request(:any, %r{/messaging/})).not_to have_been_made
end
end

describe 'argument checks' do
it 'needs exactly one form' do
expect { emails.send(to: 'user@example.com') }.to raise_error(ArgumentError, /either template_id, or/)
Expand All @@ -213,7 +325,7 @@ def send_code(**overrides)
expect { send_template(template_data: []) }.to raise_error(ArgumentError, /template_data must be a Hash/)
expect { send_code(capture_engagement: 'no') }.to raise_error(ArgumentError, /capture_engagement/)
expect { send_code(reply_to: [1]) }.to raise_error(ArgumentError, /reply_to/)
expect { send_template(cc: 'x@example.com') }.to raise_error(ArgumentError, /unknown keyword: :cc/)
expect { send_template(headers: {}) }.to raise_error(ArgumentError, /unknown keyword: :headers/)
expect(a_request(:any, %r{/messaging/})).not_to have_been_made
end
end
Expand Down
Loading