diff --git a/.changeset/emails-attachments.md b/.changeset/emails-attachments.md new file mode 100644 index 00000000..8a7ba290 --- /dev/null +++ b/.changeset/emails-attachments.md @@ -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; any other String is taken as the file encoded as Base64, on one line or wrapped at 60, 64 or 76 characters. An email carries at most 10 files and 40 MB after Base64 encoding, about 29 MB of files; the subject and bodies count towards the 40 MB as PostHog counts them, the HTML twice when there is no text, because PostHog derives the text from it. The client hashes each file, uploads the files PostHog doesn't have yet straight to PostHog's file storage, and then sends the email with the files' SHA-256 hashes; a file the project sent before uploads nothing again. A file the client can't send raises `PostHog::EmailError` before any request, with code `invalid_attachment`, `too_many_attachments` or `message_too_large`. An upload that file storage refuses raises `attachment_upload_failed`, and a PostHog without the upload route for the project answers `not_found` on `attachments`; either way no email is sent. Files go to storage only over https, unless `app_host` is an http URL for local development: an upload URL without https raises `api_error` on the file's field, such as `attachments__0`, before any file goes out. diff --git a/lib/posthog/email_attachments.rb b/lib/posthog/email_attachments.rb new file mode 100644 index 00000000..75b1a8b9 --- /dev/null +++ b/lib/posthog/email_attachments.rb @@ -0,0 +1,188 @@ +# frozen_string_literal: true + +require 'digest' + +require 'posthog/email_error' + +module PostHog + # The files of one send: each file's bytes and SHA-256, checked against the limits of one email before any request. + # + # @api private + class EmailAttachments + MAX_FILES = 10 + MAX_MESSAGE_BYTES = 40_000_000 + HEADERS_AND_MIME_BOUNDARIES_BYTES = 64 * 1024 + SUBJECT_BYTES_PER_ENCODED_WORD = 42 + ENCODED_WORD_BYTES = 75 + LIMIT = '10 files and 40 MB per email after Base64 encoding, about 29 MB of files' + FIELDS = %w[filename content content_type content_id].freeze + BASE64_LINE_WIDTHS = [60, 64, 76].freeze + + # One file to attach: its bytes, their SHA-256, and what the send says about it. + Attachment = Struct.new(:bytes, :reference, :field, keyword_init: true) do + def sha256 + @sha256 ||= Digest::SHA256.hexdigest(bytes) + end + end + + # @param files [Object] What the caller passed as `attachments`. + # @param bodies [Array] The email's HTML and text bodies, which count towards its size. + # @param subject [String, nil] The email's subject, which counts towards its size. + # @raise [EmailError] A file the client can't send, or more than one email can carry. + def self.from(files, bodies, subject) + raise invalid('attachments must be a list of files.', 'attachments') unless files.is_a?(Array) + raise too_many_files if files.size > MAX_FILES + + new(files.each_with_index.map { |file, index| attachment(file, "attachments__#{index}") }, bodies, subject) + end + + def self.attachment(file, field) + unless file.is_a?(Hash) + raise invalid("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 + check_keys(file, field) + bytes = file_bytes(file['content'], "#{field}__content") + Attachment.new(bytes: bytes, field: field, reference: file.slice('filename', 'content_type', 'content_id')) + end + + def self.check_keys(file, field) + if file.key?('path') + raise invalid("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| !FIELDS.include?(key) } + if unknown + raise invalid("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("An attachment's #{name} must be a String.", "#{field}__#{name}") + end + end + + def self.file_bytes(content, field) + bytes = raw_bytes(content) || base64_bytes(content, field) + raise invalid("An attachment can't be empty. Pass the file's bytes as its content.", field) if bytes.empty? + + bytes + end + + def self.raw_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 self.base64_bytes(content, field) + unless content.is_a?(String) + raise invalid('An attachment needs content: the bytes File.binread returns, a list of byte values, or the ' \ + 'file encoded as Base64.', field) + end + raise ArgumentError if content.match?(/[\r\n]/) && !wrapped_base64?(content) + + content.delete("\r\n").unpack1('m0') + rescue ArgumentError, EncodingError + raise invalid("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 self.wrapped_base64?(content) + text = content.gsub("\r\n", "\n") + return false if text.include?("\r") + + text = text.delete_suffix("\n") + width = text.index("\n") + return true if width.nil? + + text.ascii_only? && BASE64_LINE_WIDTHS.include?(width) && lines_of_width?(text, width) + end + + def self.lines_of_width?(text, width) + line_break = width + while (next_break = text.index("\n", line_break + 1)) + return false unless next_break == line_break + width + 1 + + line_break = next_break + end + (text.length - line_break - 1).between?(1, width) + end + + def self.invalid(message, field) + EmailError.new(code: 'invalid_attachment', message: message, field: field) + end + + def self.too_many_files + EmailError.new(code: 'too_many_attachments', field: 'attachments', + message: "An email carries at most #{MAX_FILES} files, so no email was sent.", + why: "An email carries at most #{MAX_FILES} files.", + fix: "Attach at most #{MAX_FILES} files, and link to any others from the email body.") + end + + private_class_method :new, :attachment, :check_keys, :file_bytes, :raw_bytes, :base64_bytes, + :wrapped_base64?, :lines_of_width?, :invalid, :too_many_files + + def initialize(attachments, bodies, subject) + @attachments = attachments + check_size(bodies, subject) + end + + def empty? + @attachments.empty? + end + + # @return [Array] The files as the send names them. + def references + @attachments.map { |attachment| { 'sha256' => attachment.sha256 }.merge(attachment.reference) } + end + + # @return [Array] Each distinct file once, in the order the caller gave them. + def distinct + @attachments.uniq(&:sha256) + end + + private + + def check_size(bodies, subject) + return if empty? || message_bytes(bodies, subject) <= MAX_MESSAGE_BYTES + + raise EmailError.new( + code: 'message_too_large', field: 'attachments', + message: 'The files are too large for one email, so no email was sent.', + why: "PostHog's email provider takes emails up to 40 MB, and Base64 encoding makes each file about a " \ + 'third larger.', + fix: "Keep the email under #{LIMIT}: attach fewer or smaller files, or link to large files from the email " \ + 'body.' + ) + end + + def message_bytes(bodies, subject) + files = @attachments.sum { |attachment| encoded_bytes(attachment.bytes.bytesize) } + files + bodies.sum { |body| encoded_bytes(utf8_bytesize(body)) } + subject_bytes(subject) + + HEADERS_AND_MIME_BOUNDARIES_BYTES + end + + def subject_bytes(subject) + return 0 unless subject.is_a?(String) + + (utf8_bytesize(subject) + SUBJECT_BYTES_PER_ENCODED_WORD - 1) / SUBJECT_BYTES_PER_ENCODED_WORD * + ENCODED_WORD_BYTES + end + + def utf8_bytesize(body) + body.encoding == Encoding::BINARY ? body.bytesize : body.encode(Encoding::UTF_8).bytesize + rescue EncodingError + body.bytesize + end + + def encoded_bytes(size) + (((size + 2) / 3 * 4 * 78) + 75) / 76 + end + end +end diff --git a/lib/posthog/email_error.rb b/lib/posthog/email_error.rb index 0133b7b1..c362072b 100644 --- a/lib/posthog/email_error.rb +++ b/lib/posthog/email_error.rb @@ -11,7 +11,7 @@ class EmailError < StandardError 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. + # @return [Integer, nil] The HTTP status, when PostHog answered, or file storage's for `attachment_upload_failed`. attr_reader :status_code # @return [String, nil] Why PostHog refused the email. attr_reader :why @@ -37,10 +37,19 @@ 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', + 'too_many_attachments' => 'validation_error', + 'message_too_large' => 'validation_error' + }.freeze + private_constant :SDK_ERROR_NAMES + # @api private def self.name_for(status_code, code) + return status_code.to_i >= 500 ? 'service_unavailable' : 'application_error' if code == 'attachment_upload_failed' 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' diff --git a/lib/posthog/emails.rb b/lib/posthog/emails.rb index 1ea5ffde..4200de20 100644 --- a/lib/posthog/emails.rb +++ b/lib/posthog/emails.rb @@ -8,6 +8,7 @@ require 'uri' require 'zlib' +require 'posthog/email_attachments' require 'posthog/email_error' require 'posthog/logging' require 'posthog/utils' @@ -71,6 +72,7 @@ class Emails Status = Struct.new(:id, :status, :created_at, :status_at, :reason, keyword_init: true) SEND_PATH = '/api/projects/@current/messaging/send/email/' + UPLOADS_PATH = '/api/projects/@current/messaging/attachments/uploads/' STATUS_PATH = '/api/projects/@current/messaging/emails/%s/' US_APP_HOST = 'https://us.posthog.com' EU_APP_HOST = 'https://eu.posthog.com' @@ -86,6 +88,7 @@ class Emails IDEMPOTENCY_KEY_PATTERN = /\A[\x21-\x7e]{1,255}\z/ TEMPLATE_KEY_PATTERN = /\A[a-zA-Z0-9_-]{1,400}\z/ HEADER_TOKEN_PATTERN = /\A[\x21-\x7e]+\z/ + JSON_CONTENT = { 'Content-Type' => 'application/json' }.freeze SEND_KEY_NEEDED = 'Sending email needs secret_key, a project secret API key with the ' \ 'transactional_email:write scope.' READ_KEY_NEEDED = 'Reading an email status needs secret_key, a project secret API key with the ' \ @@ -94,14 +97,14 @@ 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 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, + private_constant :SEND_PATH, :UPLOADS_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, + :HEADER_TOKEN_PATTERN, :JSON_CONTENT, :SEND_KEY_NEEDED, :READ_KEY_NEEDED, :TEMPLATE_FORM_WHY, :CODE_FORM_WHY, :TEMPLATE_FIELDS, :CODE_FIELDS, :NETWORK_ERRORS # @api private @@ -140,6 +143,20 @@ def initialize(secret_key:, host:, app_host:, timeout:, max_retries:, migrated_f # @param from [String] Code form: a verified sender, such as `Acme `. Without it, the # project's default sender. # @param reply_to [String, Array] Code form: one address or a list of addresses. + # @param cc [String, Array] 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] 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] Code form: files sent with the email, at most 10 files and 40 MB per email + # after Base64 encoding, about 29 MB of files. The client hashes each file, uploads the files PostHog doesn't + # have yet straight to PostHog's file storage, then sends the email naming the files by their SHA-256. A file + # sent before by the same project uploads nothing again. Each file is a Hash with: + # - +filename+ [String] the name the recipient sees, such as `invoice.pdf`; + # - +content+ [String, Array] the file's bytes, as a binary String such as `File.binread(path)` + # returns or as a list of byte values. Any other String is taken as the file encoded as Base64, on one + # line or wrapped at 60, 64 or 76 characters. 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 `` 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 @@ -152,22 +169,32 @@ 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. + # Before any request, {EmailError#code} is `invalid_attachment` for a file the client can't send, + # `too_many_attachments` for more than 10 files, and `message_too_large` for files too large for one email. + # It is `attachment_upload_failed` when file storage refused an upload, and `not_found` with + # {EmailError#field} `attachments` when PostHog has no upload route for this project. Unless `app_host` is + # an http URL, an upload URL without https raises `api_error` on the file's field, such as + # `attachments__0`, before any file goes out. No email was sent. # @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) + files = EmailAttachments.from(fields.fetch(:attachments, []), bodies_with_derived_text(fields), fields[:subject]) + payload[:attachments] = files.references unless files.empty? payload[:distinct_id] = distinct_id unless distinct_id.nil? body = JSON.generate(payload) headers = { 'Content-Type' => 'application/json', 'Idempotency-Key' => idempotency_key || SecureRandom.uuid } result = call('no email was sent', SEND_KEY_NEEDED) do |secret_key| + upload(files, secret_key, max_retries || @max_retries) unless files.empty? uri = URI(@app_host + SEND_PATH) with_retries(max_retries || @max_retries) do parse_send_reply(request(Net::HTTP::Post.new(uri), uri, secret_key, headers, body)) @@ -235,6 +262,11 @@ def uuid?(value) value.is_a?(String) && UUID_PATTERN.match?(value) end + def bodies_with_derived_text(fields) + html, text = fields.values_at(:html, :text) + [html, text.to_s.empty? ? html : text].compact + end + def resolve_app_host(host, app_host) explicit = app_host.is_a?(String) ? app_host.strip.chomp('/') : '' return CLOUD_APP_HOSTS[host.to_s.strip.chomp('/')] if explicit.empty? @@ -310,7 +342,11 @@ 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 do |name| + list = fields.key?(name) ? addresses(name, fields[name]) : [] + code[name] = list unless list.empty? + end code[:capture_engagement] = fields.fetch(:capture_engagement, true) code end @@ -323,11 +359,11 @@ 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 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 call(nothing_done, key_needed) @@ -409,16 +445,96 @@ def retryable?(error) case error.status_code when nil then error.code == 'network_error' when 409 then error.code == 'idempotency_key_in_flight' - when 429 then error.code != 'quota_limited' + when 429 then !%w[quota_limited upload_quota_exceeded].include?(error.code) + when 500, 502, 504 then error.code == 'attachment_upload_failed' else error.status_code == 503 end end + def upload(files, secret_key, max_retries) + distinct = files.distinct + uri = URI(@app_host + UPLOADS_PATH) + body = JSON.generate(files: distinct.map { |file| { sha256: file.sha256, size: file.bytes.bytesize } }) + slots = with_retries(max_retries) do + parse_uploads_reply(request(Net::HTTP::Post.new(uri), uri, secret_key, JSON_CONTENT, body), distinct) + end + distinct.zip(slots).each { |file, slot| put_file(file, slot, max_retries) if slot['status'] == 'upload' } + end + + def parse_uploads_reply((status, payload, retry_after), files) + fields = payload.is_a?(Hash) ? payload : {} + fields = { 'code' => 'not_found', 'attr' => 'attachments' }.merge(fields.compact) if status == 404 + raise reply_error(fields, status, retry_after) unless status.between?(200, 299) + + slots = fields['files'] + unless slots.is_a?(Array) && slots.size == files.size && files.zip(slots).all? { |pair| slot?(*pair) } + raise EmailError.new(code: 'api_error', message: "PostHog answered without the files' uploads.", + status_code: status) + end + file_without_tls, = files.zip(slots).find { |_, slot| without_tls?(slot) } + raise upload_without_tls(file_without_tls, status) if file_without_tls + + slots + end + + def without_tls?(slot) + slot['status'] == 'upload' && URI(@app_host).scheme == 'https' && URI(slot['url']).scheme != 'https' + end + + def upload_without_tls(file, status) + filename = file.reference['filename'] + EmailError.new(code: 'api_error', field: file.field, status_code: status, + message: "PostHog gave an upload URL for #{filename} that isn't https, so no file was uploaded " \ + 'and no email was sent.', + why: "Your app_host uses https, and #{filename} would have gone to file storage unencrypted.", + fix: "Fix the file storage's URL so it uses https. For local development over http, set " \ + 'app_host to an http URL.') + end + + def slot?(file, slot) + return false unless slot.is_a?(Hash) && slot['sha256'] == file.sha256 + return slot['status'] == 'present' unless slot['status'] == 'upload' + + slot['method'] == 'PUT' && web_url?(slot['url'].to_s) && slot['headers'].is_a?(Hash) && + slot['headers'].all? { |name, value| name.is_a?(String) && value.is_a?(String) } + end + + def put_file(file, slot, max_retries) + uri = URI(slot['url']) + with_retries(max_retries) do + request = Net::HTTP::Put.new(uri) + request['Content-Type'] = 'application/octet-stream' + status, answer, retry_after = exchange(request, uri, slot['headers'], file.bytes) + raise upload_failed(file, status, answer, retry_after) unless status.between?(200, 299) + end + rescue EmailError => e + raise unless e.code == 'network_error' + + raise EmailError.new(code: 'network_error', field: file.field, retry_after: e.retry_after, + message: "The upload of #{file.reference['filename']} to file storage failed, so no " \ + 'email was sent.') + end + + def upload_failed(file, status, answer, retry_after) + answer = answer.to_s.dup.force_encoding(Encoding::UTF_8).scrub + storage_said = [answer[%r{([^<]*)}, 1], answer[%r{([^<]*)}, 1]].compact + EmailError.new( + code: 'attachment_upload_failed', status_code: status, field: file.field, retry_after: retry_after, + message: "File storage refused the upload of #{file.reference['filename']} with HTTP #{status}" \ + "#{" (#{storage_said.join(': ')})" unless storage_said.empty?}, so no email was sent." + ) + end + def request(request, uri, secret_key, headers, body = nil) request['Authorization'] = "Bearer #{secret_key}" request['Accept'] = 'application/json' request['User-Agent'] = @user_agent if @user_agent request['X-PostHog-Migrated-From'] = @migrated_from if @migrated_from + status, answer, retry_after = exchange(request, uri, headers, body) + [status, parse_json(answer), retry_after] + end + + def exchange(request, uri, headers, body) headers.each { |name, value| request[name] = value } request.body = body if body @@ -426,7 +542,7 @@ def request(request, uri, secret_key, headers, body = nil) response = Net::HTTP.start(uri.hostname, uri.port, connection_options(uri)) do |http| http.request(request) { |started| head = started } end - [response.code.to_i, parse_json(response.body), parse_retry_after(response['Retry-After'])] + [response.code.to_i, response.body, parse_retry_after(response['Retry-After'])] rescue *NETWORK_ERRORS # A reply whose body broke off retries with the same idempotency key, after its Retry-After when it sent one. raise EmailError.new(code: 'network_error', message: 'The request to PostHog failed.', diff --git a/public_api_snapshot.txt b/public_api_snapshot.txt index 16679caa..4417a854 100644 --- a/public_api_snapshot.txt +++ b/public_api_snapshot.txt @@ -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() diff --git a/spec/posthog/emails_spec.rb b/spec/posthog/emails_spec.rb index 31ea9d62..f25e96d0 100644 --- a/spec/posthog/emails_spec.rb +++ b/spec/posthog/emails_spec.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require 'English' require 'spec_helper' module PostHog @@ -36,21 +37,23 @@ def sent_bodies bodies 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. + # Serves each raw HTTP response once, in order, on a local socket, and points the client's app host at it. A + # response can be a lambda that takes the socket's URL. WebMock is off meanwhile: its Net::HTTP wrapper reads the + # whole body before it hands over the response. def serve(*responses, tls: nil) server = TCPServer.new('127.0.0.1', 0) listener = tls ? OpenSSL::SSL::SSLServer.new(server, tls) : server + base_url = "#{tls ? 'https' : 'http'}://127.0.0.1:#{server.addr[1]}" responder = Thread.new do Thread.current.report_on_exception = false responses.each do |response| connection = listener.accept read_request(connection) - connection.write(response) + connection.write(response.respond_to?(:call) ? response.call(base_url) : response) connection.close end end - options.merge!(host: 'http://127.0.0.1:9', app_host: "#{tls ? 'https' : 'http'}://127.0.0.1:#{server.addr[1]}") + options.merge!(host: 'http://127.0.0.1:9', app_host: base_url) WebMock.disable! yield expect(responder.join(5)).to be_truthy @@ -198,6 +201,542 @@ def send_code(**overrides) end end + describe 'copies and attachments' do + let(:pdf) { "%PDF-1.7\n\xFF\x00\xE2".b } + let(:pdf_sha256) { '99882c5503918dac954710e17022f6243565344d43c0f30ac70d19c560f857c4' } + let(:png) { "\x89PNG".b } + let(:png_sha256) { '0f4636c78f65d3639ece5a064b5ae753e3408614a14fb18ab4d7540d2c248543' } + let(:five_pdfs_sha256) { 'dfb00ae2891aa7dac580fd90519992e7a8d6914de7a683c0ecc071f65868101e' } + let(:largest_that_fits_sha256) { '28f197eae8bd57e7c2a94d2062d2cef0a04b34e7fd3e0c743d88cda2217f069c' } + let(:bucket) { {} } + let(:usage) { { quota_bytes: 0, stored_bytes: 0 } } + let(:outbox) { [] } + let(:faults) { { uploads: [], storage: [], send: [] } } + let(:clock) { { now: 0.0, storage_busy_until: 0.0 } } + let(:emails) do + client.emails.tap { |emails| allow(emails).to receive(:sleep) { |seconds| clock[:now] += seconds } } + end + + before { fake_posthog } + + def fake_posthog(app: 'https://us.posthog.com', storage: 'https://files.example.com/team') + routes = "#{app}/api/projects/@current/messaging" + stub_request(:post, "#{routes}/attachments/uploads/").to_return { |request| plan_uploads(request, storage) } + stub_request(:put, %r{/team/\h{64}\?}).to_return { |request| store(request) } + stub_request(:post, "#{routes}/send/email/").to_return { |request| accept_send(request) } + end + + def plan_uploads(request, storage) + files = JSON.parse(request.body)['files'] + return faults[:uploads].shift if faults[:uploads].any? + return refuse(401, 'authentication_failed') unless signed_in?(request) + return refuse(400, 'invalid_request', attr: 'files') unless files.all? { |file| manifest_entry?(file) } + + answer_uploads(*files.map { |file| plan(file, storage) }) + end + + def plan(file, storage) + return present_slot(file['sha256']) if bucket.key?(file['sha256']) + + usage[:quota_bytes] += file['size'] + slot(file, storage) + end + + def manifest_entry?(file) + file['sha256'].to_s.match?(/\A\h{64}\z/) && file['size'].is_a?(Integer) && file['size'].positive? + end + + def slot(file, storage) + sha256 = file['sha256'] + upload_slot(sha256, url: "#{storage}/#{sha256}?size=#{file['size']}&X-Amz-Signature=fake", + headers: { 'x-amz-checksum-sha256' => [[sha256].pack('H*')].pack('m0') }) + end + + def store(request) + sha256 = request.uri.path[/\h{64}\z/] + return faults[:storage].shift if faults[:storage].any? + return storage_busy if clock[:now] < clock[:storage_busy_until] + return storage_error(403, 'AccessDenied') if request.headers.key?('Authorization') + + bytes = request.body.b + signed = request.headers['X-Amz-Checksum-Sha256'] == [[sha256].pack('H*')].pack('m0') && + bytes.bytesize == request.uri.query_values['size'].to_i + return storage_error(403, 'SignatureDoesNotMatch') unless signed + return storage_error(400, 'BadDigest') if Digest::SHA256.hexdigest(bytes) != sha256 + + usage[:stored_bytes] += bytes.bytesize + bucket[sha256] = bytes + { status: 200 } + end + + def storage_busy + retry_after = (clock[:storage_busy_until] - clock[:now]).ceil + storage_error(503, 'SlowDown').merge(headers: { 'Retry-After' => retry_after.to_s }) + end + + def storage_error(status, code) + { status: status, body: "#{code}" } + end + + def accept_send(request) + email = JSON.parse(request.body) + return faults[:send].shift if faults[:send].any? + return refuse(401, 'authentication_failed') unless signed_in?(request) + unless email.fetch('attachments', []).all? { |file| bucket.key?(file['sha256']) } + return refuse(400, 'attachment_not_uploaded', attr: 'attachments') + end + + outbox << email + accept + end + + def signed_in?(request) + request.headers['Authorization'] == 'Bearer phs_test' + end + + def upload_slot(sha256, url: 'https://files.example.com/team/99882c55?X-Amz-Signature=abc', + headers: { 'x-amz-checksum-sha256' => 'mYgsVQORjayVRxDhcCL2JDVlNE1DwPMKxw0ZxWD4V8Q=' }) + { sha256: sha256, status: 'upload', url: url, method: 'PUT', headers: headers, + url_expires_at: '2026-11-09T12:15:00Z' } + end + + def present_slot(sha256) + { sha256: sha256, status: 'present', expires_at: '2026-12-16T12:00:00Z' } + end + + def answer_uploads(*slots) + { status: 200, body: JSON.generate(files: slots) } + end + + def refused_attachment(**overrides) + send_code(**overrides) + rescue EmailError => e + e + end + + it 'sends cc and bcc as lists' do + send_code(cc: 'finance@example.com', bcc: %w[audit@example.com archive@example.com]) + + expect(outbox.first).to include('cc' => ['finance@example.com'], + 'bcc' => %w[audit@example.com archive@example.com]) + end + + it 'uploads each file storage lacks, without the secret key, then sends the files by hash' do + bucket[png_sha256] = png + + result = send_code(attachments: [ + { filename: 'invoice.pdf', content: pdf, content_type: 'application/pdf' }, + { 'filename' => 'logo.png', 'content' => png, 'content_id' => 'logo' } + ]) + + expect(result.id).to eq('msg_1') + expect(bucket).to eq(png_sha256 => png, pdf_sha256 => pdf) + expect(usage).to eq(quota_bytes: pdf.bytesize, stored_bytes: pdf.bytesize) + expect(outbox.first['attachments']).to eq([ + { 'sha256' => pdf_sha256, 'filename' => 'invoice.pdf', + 'content_type' => 'application/pdf' }, + { 'sha256' => png_sha256, 'filename' => 'logo.png', + 'content_id' => 'logo' } + ]) + end + + it 'leaves out empty lists, so a PostHog version without these fields still sends the email' do + faults[:uploads] << refuse(404, 'not_found') + + send_code(cc: [], bcc: [], attachments: []) + + expect(outbox.first.keys).not_to include('cc', 'bcc', 'attachments') + end + + it 'uploads once and sends the same files on every attempt of the send' do + faults[:send] << refuse(503, 'service_unavailable') + + expect(send_code(attachments: [{ filename: 'invoice.pdf', content: pdf }]).id).to eq('msg_1') + + expect(bucket).to eq(pdf_sha256 => pdf) + expect(usage).to eq(quota_bytes: pdf.bytesize, stored_bytes: pdf.bytesize) + expect(outbox.first['attachments']).to eq([{ 'sha256' => pdf_sha256, 'filename' => 'invoice.pdf' }]) + end + + it 'takes the file as bytes, a list of byte values, or Base64 in any line layout' do + file = pdf * 5 + mime_lines = [file].pack('m0').scan(/.{1,76}/) + pem_lines = [file].pack('m0').scan(/.{1,64}/) + + results = [file, file.bytes, [file].pack('m0'), [file].pack('m'), "#{mime_lines.join("\n")}\n", + "#{mime_lines.join("\r\n")}\r\n", pem_lines.join("\n")].map do |content| + send_code(attachments: [{ filename: 'invoice.pdf', content: content }]) + end + + expect([mime_lines.size, pem_lines.size]).to eq([2, 2]) + expect(results.map(&:id)).to all(eq('msg_1')) + expect(bucket).to eq(five_pdfs_sha256 => file) + end + + it "reads wrapped Base64 whatever the app's input record separator" do + file = pdf * 5 + wrapped = "#{[file].pack('m0').scan(/.{1,76}/).join("\n")}\n" + separator = $INPUT_RECORD_SEPARATOR + $INPUT_RECORD_SEPARATOR = nil + + result = send_code(attachments: [{ filename: 'invoice.pdf', content: wrapped }]) + + expect(result.id).to eq('msg_1') + expect(bucket).to eq(five_pdfs_sha256 => file) + ensure + $INPUT_RECORD_SEPARATOR = separator + end + + it 'uploads a file attached twice once, and sends both references' do + send_code(attachments: [{ filename: 'a.pdf', content: pdf }, { filename: 'b.pdf', content: pdf }]) + + expect(bucket).to eq(pdf_sha256 => pdf) + expect(usage).to eq(quota_bytes: pdf.bytesize, stored_bytes: pdf.bytesize) + expect(outbox.first['attachments'].map { |file| file['filename'] }).to eq(%w[a.pdf b.pdf]) + end + + it 'sends nothing when storage refuses an upload, and says what storage answered' do + faults[:storage] << { + status: 400, + body: 'BadDigest' \ + 'The checksum did not match.' + } + + error = refused_attachment(attachments: [{ filename: 'invoice.pdf', content: pdf }]) + + expect([error.code, error.status_code, error.field]) + .to eq(['attachment_upload_failed', 400, 'attachments__0']) + expect(error.message).to eq('File storage refused the upload of invoice.pdf with HTTP 400 ' \ + '(BadDigest: The checksum did not match.), so no email was sent.') + expect(bucket).to be_empty + expect(outbox).to be_empty + end + + it 'never follows storage redirecting an upload elsewhere, and names the file but not the URL' do + elsewhere = {} + stub_request(:put, %r{\Ahttp://elsewhere\.test/}).to_return do |request| + elsewhere[request.uri.to_s] = request.body + { status: 200 } + end + faults[:storage] << { + status: 307, headers: { 'Location' => "http://elsewhere.test/stolen/#{pdf_sha256}" }, + body: 'TemporaryRedirectPlease re-send this request to the specified ' \ + 'temporary endpoint.elsewhere.test' + } + + error = refused_attachment(attachments: [{ filename: 'invoice.pdf', content: pdf }]) + + expect([error.code, error.field, error.status_code]).to eq(['attachment_upload_failed', 'attachments__0', 307]) + expect(error.message).to include('invoice.pdf') + expect([error.message, error.why, error.fix].join).not_to include('elsewhere.test') + expect(elsewhere).to be_empty + expect(bucket).to be_empty + expect(outbox).to be_empty + end + + it "names a refused upload by storage's failure, never by PostHog's categories" do + faults[:storage].push({ status: 403, body: 'AccessDenied' }, { status: 401 }, + { status: 503, body: 'SlowDown' }) + attachments = [{ filename: 'invoice.pdf', content: pdf }] + + expired = refused_attachment(max_retries: 0, attachments: attachments) + unauthorized = refused_attachment(max_retries: 0, attachments: attachments) + unavailable = refused_attachment(max_retries: 0, attachments: attachments) + + expect([expired.name, expired.status_code]).to eq(['application_error', 403]) + expect([unauthorized.name, unauthorized.status_code]).to eq(['application_error', 401]) + expect([unavailable.name, unavailable.status_code]).to eq(['service_unavailable', 503]) + expect(outbox).to be_empty + end + + it "sends nothing on a PostHog without the upload route, and passes on PostHog's answer" do + faults[:uploads].push( + refuse(404, 'not_found', detail: 'Endpoint not found.', type: 'invalid_request', attr: nil), + { status: 404, body: 'Not Found', headers: { 'Content-Type' => 'text/html' } } + ) + + error, proxied = Array.new(2) { refused_attachment(attachments: [{ filename: 'invoice.pdf', content: pdf }]) } + + expect([error.code, error.name, error.status_code, error.field]) + .to eq(['not_found', 'not_found', 404, 'attachments']) + expect(error.message).to eq('Endpoint not found.') + expect([proxied.code, proxied.name, proxied.status_code, proxied.field]) + .to eq(['not_found', 'not_found', 404, 'attachments']) + expect(bucket).to be_empty + expect(outbox).to be_empty + end + + it 'names the file storage and the file when an upload never reaches it' do + stub_request(:put, %r{/team/\h{64}\?}).to_timeout + + error = refused_attachment(max_retries: 0, attachments: [{ filename: 'invoice.pdf', content: pdf }]) + + expect([error.code, error.name, error.field]).to eq(%w[network_error service_unavailable attachments__0]) + expect(error.message).to eq('The upload of invoice.pdf to file storage failed, so no email was sent.') + expect(outbox).to be_empty + end + + it "retries an upload storage couldn't take, then sends" do + faults[:storage].push({ status: 503, body: 'SlowDown' }, + { status: 500, body: 'InternalError' }, + { status: 502 }, { status: 504 }) + + expect(send_code(max_retries: 4, attachments: [{ filename: 'invoice.pdf', content: pdf }]).id).to eq('msg_1') + expect(bucket).to eq(pdf_sha256 => pdf) + end + + it "waits for storage's Retry-After, and stops when it asks for longer than 30 seconds" do + clock[:storage_busy_until] = 3.0 + sent = send_code(max_retries: 1, attachments: [{ filename: 'invoice.pdf', content: pdf }]) + clock[:storage_busy_until] = clock[:now] + 60 + + error = refused_attachment(max_retries: 1, attachments: [{ filename: 'logo.png', content: png }]) + + expect(sent.id).to eq('msg_1') + expect([error.code, error.retry_after]).to eq(['attachment_upload_failed', 60.0]) + expect(bucket).to eq(pdf_sha256 => pdf) + end + + it "keeps storage's Retry-After when its answer to an upload was cut off" do + uploads = lambda do |base_url| + slot = upload_slot(pdf_sha256, url: "#{base_url}/team/#{pdf_sha256}") + http_response('200 OK', JSON.generate(files: [slot])) + end + cut_off = http_response('503 Service Unavailable', 'Slow', + 'Content-Length' => '60', 'Retry-After' => '60') + + serve(uploads, cut_off) do + error = refused_attachment(attachments: [{ filename: 'invoice.pdf', content: pdf }]) + + expect([error.code, error.field, error.retry_after]).to eq(['network_error', 'attachments__0', 60.0]) + expect(error.message).to eq('The upload of invoice.pdf to file storage failed, so no email was sent.') + end + end + + it "sends nothing when PostHog refuses the uploads, and passes on PostHog's reason" do + faults[:uploads] << refuse(400, 'field_not_accepted', detail: "This project can't send files yet.", + attr: 'files', why: 'Not available yet.', + fix: 'Send without files.') + + error = refused_attachment(attachments: [{ filename: 'invoice.pdf', content: pdf }]) + + expect([error.code, error.name, error.field, error.why, error.fix]) + .to eq(['field_not_accepted', 'validation_error', 'files', 'Not available yet.', 'Send without files.']) + expect(bucket).to be_empty + expect(outbox).to be_empty + end + + it "doesn't retry a project over its daily upload bytes" do + faults[:uploads] << refuse(429, 'upload_quota_exceeded') + + error = refused_attachment(attachments: [{ filename: 'invoice.pdf', content: pdf }]) + + expect(error.code).to eq('upload_quota_exceeded') + expect(outbox).to be_empty + end + + it "sends nothing when PostHog's answer doesn't say where each file goes" do + faults[:uploads].push( + answer_uploads, + answer_uploads(present_slot(png_sha256)), + answer_uploads(upload_slot(pdf_sha256).merge(method: 'POST')), + answer_uploads(upload_slot(pdf_sha256, url: 'file:///etc/passwd')), + answer_uploads(upload_slot(pdf_sha256, headers: { 'Content-Length' => 12 })) + ) + + errors = Array.new(5) { refused_attachment(attachments: [{ filename: 'invoice.pdf', content: pdf }]) } + + expect(errors.map(&:code)).to all(eq('api_error')) + expect(bucket).to be_empty + expect(outbox).to be_empty + end + + it "sends no file to storage without https, naming the file and never the storage's URL" do + fake_posthog(storage: 'http://files.example.com/team') + bucket[png_sha256] = png + + error = refused_attachment(attachments: [{ filename: 'logo.png', content: png }, + { filename: 'invoice.pdf', content: pdf }]) + + expect([error.code, error.field]).to eq(%w[api_error attachments__1]) + expect(error.message).to include('invoice.pdf') + expect(error.fix).to include("Fix the file storage's URL") + expect([error.message, error.why, error.fix].join).not_to include('files.example.com') + expect(bucket).to eq(png_sha256 => png) + expect(outbox).to be_empty + end + + it 'uploads over http when app_host is an http URL, for local development' do + options.merge!(host: 'http://127.0.0.1:9', app_host: 'http://posthog.test') + fake_posthog(app: 'http://posthog.test', storage: 'http://storage.test:8333/team') + + expect(send_code(attachments: [{ filename: 'invoice.pdf', content: pdf }]).id).to eq('msg_1') + expect(bucket).to eq(pdf_sha256 => pdf) + end + + it 'refuses more files or bytes than one email carries before any request' do + eleven = Array.new(11) { |index| { filename: "#{index}.txt", content: "file #{index}".b } } + largest_that_fits = ("\0" * 29_182_875).b + + too_many = refused_attachment(attachments: eleven) + too_large = refused_attachment(subject: '', + attachments: [{ filename: 'scan.pdf', content: "#{largest_that_fits}\0".b }]) + too_large_with_body = refused_attachment(subject: '', html: 'abcd', + attachments: [{ filename: 'scan.pdf', content: largest_that_fits }]) + + expect([too_many.code, too_many.name, too_many.field]) + .to eq(%w[too_many_attachments validation_error attachments]) + expect([too_large, too_large_with_body].map { |error| [error.code, error.name, error.field] }) + .to all(eq(%w[message_too_large validation_error attachments])) + expect(too_large.fix).to include('40 MB per email after Base64 encoding, about 29 MB of files') + expect([usage.values.sum, bucket, outbox]).to eq([0, {}, []]) + end + + it 'counts the files before it reads any of them' do + files = Array.new(10) { |index| { filename: "#{index}.txt", content: "file #{index}".b } } + + error = refused_attachment(attachments: files + [{ filename: 'scan.pdf', path: '/tmp/scan.pdf' }]) + + expect(error.code).to eq('too_many_attachments') + end + + it 'uploads the largest file one email carries' do + largest_that_fits = ("\0" * 29_182_875).b + + expect(send_code(subject: '', html: '', attachments: [{ filename: 'scan.pdf', content: largest_that_fits }]).id) + .to eq('msg_1') + expect(bucket).to eq(largest_that_fits_sha256 => largest_that_fits) + end + + it 'reads the largest file one email carries wrapped as Base64, and sends it' do + largest_that_fits = ("\0" * 29_182_875).b + wrapped = "#{[largest_that_fits].pack('m0').scan(/.{1,76}/).join("\n")}\n" + + result = send_code(subject: '', html: '', attachments: [{ filename: 'scan.pdf', content: wrapped }]) + + expect(result.id).to eq('msg_1') + expect(bucket).to eq(largest_that_fits_sha256 => largest_that_fits) + expect(outbox.first['attachments']).to eq([{ 'sha256' => largest_that_fits_sha256, 'filename' => 'scan.pdf' }]) + end + + it "counts each body like a file: its UTF-8 bytes' Base64 size with line breaks" do + leaving_159_bytes = [{ filename: 'scan.pdf', content: ("\0" * 29_182_761).b }] + ascii_that_fits = 'a' * 57 + ascii_one_too_many = 'a' * 58 + two_byte_that_fits = 'é' * 57 + two_byte_one_too_many = 'é' * 58 + + sent = [ + send_code(subject: '', html: ascii_that_fits, text: ascii_that_fits, attachments: leaving_159_bytes), + send_code(subject: '', html: '', text: two_byte_that_fits.encode(Encoding::UTF_16LE), + attachments: leaving_159_bytes) + ] + refused = [ + refused_attachment(subject: '', html: ascii_one_too_many, text: ascii_that_fits, + attachments: leaving_159_bytes), + refused_attachment(subject: '', html: '', text: two_byte_one_too_many, attachments: leaving_159_bytes) + ] + + expect(refused.map(&:code)).to eq(%w[message_too_large message_too_large]) + expect(sent.map(&:id)).to eq(%w[msg_1 msg_1]) + expect(outbox.map { |email| email['text'] }).to eq(['a' * 57, 'é' * 57]) + end + + it 'counts the HTML twice when there is no text, because PostHog derives the text from it' do + leaving_81_bytes = [{ filename: 'scan.pdf', content: ("\0" * 29_182_818).b }] + leaving_159_bytes = [{ filename: 'scan.pdf', content: ("\0" * 29_182_761).b }] + html = 'a' * 57 + + sent = send_code(subject: '', html: html, attachments: leaving_159_bytes) + refused = [nil, ''].map do |text| + refused_attachment(subject: '', html: html, text: text, attachments: leaving_81_bytes) + end + + expect(sent.id).to eq('msg_1') + expect(refused.map(&:code)).to all(eq('message_too_large')) + expect(usage[:quota_bytes]).to eq(29_182_761) + expect(outbox.size).to eq(1) + end + + it 'leaves the size of an email without files to PostHog' do + html = "

#{'a' * 20_000_000}

" + + results = [nil, []].map { |attachments| send_code(subject: '', html: html, attachments: attachments) } + + expect(results.map(&:id)).to eq(%w[msg_1 msg_1]) + expect(outbox.map { |email| email['html'].bytesize }).to eq([html.bytesize] * 2) + end + + it 'counts the subject in 75-byte steps per 42 of its UTF-8 bytes, and an empty subject not at all' do + leaving_77_bytes = [{ filename: 'scan.pdf', content: ("\0" * 29_182_821).b }] + leaving_3_bytes = [{ filename: 'scan.pdf', content: ("\0" * 29_182_875).b }] + one_ascii_word = 'a' * 42 + two_ascii_words = 'a' * 43 + one_two_byte_word = 'é' * 21 + two_two_byte_words = 'é' * 22 + + sent = [one_ascii_word, one_two_byte_word].map do |subject| + send_code(subject: subject, html: '', attachments: leaving_77_bytes) + end + sent << send_code(subject: '', html: '', attachments: leaving_3_bytes) + refused = [two_ascii_words, two_two_byte_words].map do |subject| + refused_attachment(subject: subject, html: '', attachments: leaving_77_bytes) + end + refused << refused_attachment(subject: 'a', html: '', attachments: leaving_3_bytes) + + expect(sent.map(&:id)).to eq(%w[msg_1 msg_1 msg_1]) + expect(refused.map(&:code)).to all(eq('message_too_large')) + 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: ''.b }] } => ['attachments__0__content', /can't be empty/], + { 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: 'codes.txt', content: "ABCD1234\rEFGH5678\r" }] } => + ['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([usage.values.sum, bucket, outbox]).to eq([0, {}, []]) + 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([usage.values.sum, bucket, outbox]).to eq([0, {}, []]) + 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/) @@ -213,7 +752,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