Repository navigation
feat(emails): send cc, bcc and attachments #325
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Silthus
wants to merge
36
commits into
main
Choose a base branch
from
feat/emails-attachments
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+876
−19
Open
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 16a2c62
fix(emails): forget the parent's requests in a forked child
Silthus e33feda
fix(emails): retry a send whose response body was cut off
Silthus d945d75
feat(emails): send cc, bcc and attachments with emails.send
Silthus 90c1afb
fix(emails): answer the round 3 review
Silthus 1a2acc7
Merge remote-tracking branch 'origin/feat/emails-send' into feat/emai…
Silthus e94874c
fix(emails): retry a refusal whose body broke off
Silthus 0619e4d
Merge remote-tracking branch 'origin/feat/emails-send' into feat/emai…
Silthus c843d53
test(emails): prove shutdown waits for a send in its retry backoff
Silthus 2171583
Merge remote-tracking branch 'origin/feat/emails-send' into feat/emai…
Silthus a3a1aab
fix(emails): refuse non-ASCII content as invalid_attachment, take wra…
Silthus e29b8a7
fix(emails): take line-broken Base64 only in Base64.encode64's layout
Silthus df3ff92
refactor(emails): name the attachment rules instead of commenting them
Silthus 44c945b
Merge remote-tracking branch 'origin/main' into feat/emails-attachments
Silthus aa2546b
Merge remote-tracking branch 'origin/main' into feat/emails-attachments
Silthus 294a89a
feat(emails): upload attachments, then send them by hash
Silthus d94b541
refactor(emails): build the files beside the payload, decode Base64 s…
Silthus c4d93e8
fix(emails): wait for file storage's Retry-After before retrying an u…
Silthus 3cea49b
fix(emails): count the files before reading them, hash only files tha…
Silthus 03b4d0b
fix(emails): keep storage's Retry-After when its upload answer breaks…
Silthus e56f70c
fix(emails): count each body's UTF-8 bytes towards the email's size
Silthus 592d442
fix(emails): count each body like a file towards the email's size
Silthus 7db66dd
fix(emails): answer QA panel findings and the upload-route 404 contract
Silthus 484811d
perf(emails): check Base64 line widths without splitting the file
Silthus 8b7d237
fix(emails): send files to storage only over https when PostHog is on…
Silthus 7115f4b
fix(emails): retry storage's 500, 502 and 504, and name its 401 as st…
Silthus 4bc4f3f
fix(emails): count the subject towards the email's size as PostHog does
Silthus 547e466
fix(emails): answer the QA panel on the subject round
Silthus 5f86c37
test(emails): run attachment sends against a fake PostHog and file st…
Silthus fe145f0
test(emails): check uploads against a fake that enforces the signed s…
Silthus ab93bd5
test(emails): assert stored files, upload quota and accepted email, n…
Silthus ef6f0ad
test(emails): meter the bytes storage writes, so a repeated upload shows
Silthus 8e2b20a
test(emails): prove storage's Retry-After by storage staying busy, no…
Silthus 011bdd9
fix(emails): count derived text, and name the file an http upload URL…
Silthus 8e2bc63
fix(emails): leave the size of an email without files to PostHog
Silthus 5659e3e
test(emails): show an upload never follows storage's redirect
Silthus File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.