Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
156067d
feat(emails): send transactional email and read its status
Silthus Nov 2, 2026
16a2c62
fix(emails): forget the parent's requests in a forked child
Silthus Nov 2, 2026
e33feda
fix(emails): retry a send whose response body was cut off
Silthus Nov 2, 2026
d945d75
feat(emails): send cc, bcc and attachments with emails.send
Silthus Nov 4, 2026
90c1afb
fix(emails): answer the round 3 review
Silthus Nov 4, 2026
1a2acc7
Merge remote-tracking branch 'origin/feat/emails-send' into feat/emai…
Silthus Nov 4, 2026
e94874c
fix(emails): retry a refusal whose body broke off
Silthus Nov 4, 2026
0619e4d
Merge remote-tracking branch 'origin/feat/emails-send' into feat/emai…
Silthus Nov 4, 2026
c843d53
test(emails): prove shutdown waits for a send in its retry backoff
Silthus Nov 4, 2026
2171583
Merge remote-tracking branch 'origin/feat/emails-send' into feat/emai…
Silthus Nov 4, 2026
a3a1aab
fix(emails): refuse non-ASCII content as invalid_attachment, take wra…
Silthus Nov 4, 2026
e29b8a7
fix(emails): take line-broken Base64 only in Base64.encode64's layout
Silthus Nov 4, 2026
df3ff92
refactor(emails): name the attachment rules instead of commenting them
Silthus Nov 4, 2026
44c945b
Merge remote-tracking branch 'origin/main' into feat/emails-attachments
Silthus Nov 6, 2026
aa2546b
Merge remote-tracking branch 'origin/main' into feat/emails-attachments
Silthus Nov 9, 2026
294a89a
feat(emails): upload attachments, then send them by hash
Silthus Nov 9, 2026
d94b541
refactor(emails): build the files beside the payload, decode Base64 s…
Silthus Nov 9, 2026
c4d93e8
fix(emails): wait for file storage's Retry-After before retrying an u…
Silthus Nov 9, 2026
3cea49b
fix(emails): count the files before reading them, hash only files tha…
Silthus Nov 9, 2026
03b4d0b
fix(emails): keep storage's Retry-After when its upload answer breaks…
Silthus Nov 9, 2026
e56f70c
fix(emails): count each body's UTF-8 bytes towards the email's size
Silthus Nov 9, 2026
592d442
fix(emails): count each body like a file towards the email's size
Silthus Nov 9, 2026
7db66dd
fix(emails): answer QA panel findings and the upload-route 404 contract
Silthus Nov 9, 2026
484811d
perf(emails): check Base64 line widths without splitting the file
Silthus Nov 9, 2026
8b7d237
fix(emails): send files to storage only over https when PostHog is on…
Silthus Nov 10, 2026
7115f4b
fix(emails): retry storage's 500, 502 and 504, and name its 401 as st…
Silthus Nov 10, 2026
4bc4f3f
fix(emails): count the subject towards the email's size as PostHog does
Silthus Nov 10, 2026
547e466
fix(emails): answer the QA panel on the subject round
Silthus Nov 10, 2026
5f86c37
test(emails): run attachment sends against a fake PostHog and file st…
Silthus Nov 10, 2026
fe145f0
test(emails): check uploads against a fake that enforces the signed s…
Silthus Nov 10, 2026
ab93bd5
test(emails): assert stored files, upload quota and accepted email, n…
Silthus Nov 10, 2026
ef6f0ad
test(emails): meter the bytes storage writes, so a repeated upload shows
Silthus Nov 10, 2026
8e2b20a
test(emails): prove storage's Retry-After by storage staying busy, no…
Silthus Nov 10, 2026
011bdd9
fix(emails): count derived text, and name the file an http upload URL…
Silthus Nov 10, 2026
8e2bc63
fix(emails): leave the size of an email without files to PostHog
Silthus Nov 10, 2026
5659e3e
test(emails): show an upload never follows storage's redirect
Silthus Nov 10, 2026
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; 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.
188 changes: 188 additions & 0 deletions lib/posthog/email_attachments.rb
Original file line number Diff line number Diff line change
@@ -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<String>] 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<Hash>] The files as the send names them.
def references
@attachments.map { |attachment| { 'sha256' => attachment.sha256 }.merge(attachment.reference) }
end

# @return [Array<Attachment>] 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
13 changes: 11 additions & 2 deletions lib/posthog/email_error.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'
Comment thread
Silthus marked this conversation as resolved.
Resolved
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
Loading
Loading