Skip to content
Merged
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
24 changes: 23 additions & 1 deletion app/lib/email_processor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@
date = parse_subject_for_date(@subject)
existing_entry = @user.existing_entry(date.to_s)
inspiration_id = parse_body_for_inspiration_id(@raw_body)
@body = @html.presence if @html.present? && @user.is_pro?
@body = preferred_pro_body if @user.is_pro?

if existing_entry.present?
existing_entry.original_email = @inbound_email_params
Expand Down Expand Up @@ -405,6 +405,28 @@
html
end

def normalized_visible_text(html)
fragment = Nokogiri::HTML5.fragment(html.to_s)
fragment.css('br').each { |node| node.replace(Nokogiri::XML::Text.new(' ', node.document)) }
fragment.css(HTML_BLOCK_ELEMENTS.join(',')).each do |node|

Check notice on line 411 in app/lib/email_processor.rb

View check run for this annotation

codefactor.io / CodeFactor

app/lib/email_processor.rb#L411

Prefer double-quoted strings unless you need single quotes to avoid extra backslashes for escaping. (Style/StringLiterals)

Check notice on line 411 in app/lib/email_processor.rb

View check run for this annotation

codefactor.io / CodeFactor

app/lib/email_processor.rb#L411

Prefer double-quoted strings unless you need single quotes to avoid extra backslashes for escaping. (Style/StringLiterals)
node.add_next_sibling(Nokogiri::XML::Text.new(' ', node.document))

Check notice on line 412 in app/lib/email_processor.rb

View check run for this annotation

codefactor.io / CodeFactor

app/lib/email_processor.rb#L412

Prefer double-quoted strings unless you need single quotes to avoid extra backslashes for escaping. (Style/StringLiterals)
end

Check notice on line 413 in app/lib/email_processor.rb

View check run for this annotation

codefactor.io / CodeFactor

app/lib/email_processor.rb#L413

Prefer double-quoted strings unless you need single quotes to avoid extra backslashes for escaping. (Style/StringLiterals)
fragment.text.unicode_normalize(:nfkc).gsub(/\s+/, ' ').strip
end

Check notice on line 415 in app/lib/email_processor.rb

View check run for this annotation

codefactor.io / CodeFactor

app/lib/email_processor.rb#L415

Prefer double-quoted strings unless you need single quotes to avoid extra backslashes for escaping. (Style/StringLiterals)

def preferred_pro_body
return @body unless @html.present?

plain_text = normalized_visible_text(@body)
stripped_html_text = normalized_visible_text(@html)
stripped_html_is_strict_prefix = plain_text.start_with?(stripped_html_text) &&
plain_text != stripped_html_text

return @html unless stripped_html_is_strict_prefix

@body
end

def collage_from_attachments(attachments, existing_image_url: nil)
return nil unless attachments.present?
add_dev = "/development" unless Rails.env.production?
Expand Down
32 changes: 32 additions & 0 deletions spec/features/entries_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,38 @@
)
end

it 'renders all authored paragraphs when Mailgun stripped HTML is truncated' do
paid_user.entries.destroy_all
email = FactoryBot.build(
:email,
to: [{ token: paid_user.user_key, host: ENV['SMTP_DOMAIN'], email: "#{paid_user.user_key}@#{ENV['SMTP_DOMAIN']}"}],
body: "First paragraph\n\nSecond paragraph\n\nThird paragraph\n\nFourth paragraph",
raw_html: '<div dir="ltr"><div>First paragraph<br><div class="gmail_quote"><div dir="ltr"><div><br></div><div>Second paragraph</div><div><br></div><div>Third paragraph</div><div><br></div><div>Fourth paragraph</div></div></div></div><div class="gmail_signature"><div><br>--<br></div><div>Sender signature</div></div></div>',
vendor_specific: {
stripped_html: '<div><div>First paragraph</div></div>'
}
)

EmailProcessor.new(email).process
processed_entry = paid_user.entries.reload.first

expect(processed_entry.body).to eq(
'<div>First paragraph<br><br>Second paragraph<br><br>Third paragraph<br><br>Fourth paragraph</div>'
)

sign_in paid_user
visit day_entry_url(year: processed_entry.date.year, month: processed_entry.date.month, day: processed_entry.date.day)

rendered_entry = page.find('.s-scrollable')
rendered_body = rendered_entry.find(:xpath, './div')
expect(rendered_body.all(:xpath, './br').map(&:tag_name)).to eq(%w[br br br br br br])
expect(rendered_entry).to have_text('First paragraph')
expect(rendered_entry).to have_text('Second paragraph')
expect(rendered_entry).to have_text('Third paragraph')
expect(rendered_entry).to have_text('Fourth paragraph')
expect(rendered_entry).not_to have_text('Sender signature')
end

it 'should show an entry stored at a non-midnight datetime' do
sign_in user
entry.update_columns(date: Time.utc(2026, 7, 17, 15, 30, 0), body: '<p>Afternoon journal entry</p>')
Expand Down
89 changes: 89 additions & 0 deletions spec/models/email_processor_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,95 @@
expect(paid_user.entries.reload.first.body).to eq("<p>I am great</p><p>Here's a link: <a href=\"https://www.google.com\" target=\"_blank\">https://www.google.com</a></p>")
end

it "recovers a complete paid entry when Mailgun stripped HTML loses nested authored paragraphs" do
paid_user.entries.destroy_all
email = FactoryBot.build(
:email,
to: [{ token: paid_user.user_key, host: ENV['SMTP_DOMAIN'], email: "#{paid_user.user_key}@#{ENV['SMTP_DOMAIN']}"}],
body: "FIRST PARAGRAPH\n\nSECOND PARAGRAPH\n\nTHIRD PARAGRAPH with NBSP\n\nFOURTH PARAGRAPH with NBSP",
raw_html: '<div dir="ltr"><div>FIRST PARAGRAPH<br><div class="gmail_quote"><div dir="ltr"><div class="gmail_quote"><div dir="ltr"><div><br></div><div>SECOND PARAGRAPH</div><div><br></div><div>THIRD PARAGRAPH with&nbsp;NBSP</div><div><br></div><div>FOURTH PARAGRAPH with&nbsp;NBSP</div></div></div></div></div></div><div><div dir="ltr" class="gmail_signature" data-smartmail="gmail_signature"><div dir="ltr"><div><br>--<br></div><div><b>Paul Arterburn</b></div></div></div></div></div>',
vendor_specific: {
stripped_html: '<div><div>FIRST PARAGRAPH</div></div>'
}
)

EmailProcessor.new(email).process

saved_body = paid_user.entries.reload.first.body
expect(saved_body).to eq(
'<div>FIRST PARAGRAPH<br><br>SECOND PARAGRAPH<br><br>THIRD PARAGRAPH with NBSP<br><br>FOURTH PARAGRAPH with NBSP</div>'
)
expect(saved_body.scan('<br><br>').size).to eq(3)
expect(saved_body).not_to include('Paul Arterburn')
end

it "prefers complete stripped HTML for a normal paid entry" do
paid_user.entries.destroy_all
email = FactoryBot.build(
:email,
to: [{ token: paid_user.user_key, host: ENV['SMTP_DOMAIN'], email: "#{paid_user.user_key}@#{ENV['SMTP_DOMAIN']}"}],
body: "Complete rich reply",
raw_html: "<div>Complete rich reply</div>",
vendor_specific: {
stripped_html: "<div>Complete <strong>rich</strong> reply</div>"
}
)

EmailProcessor.new(email).process

expect(paid_user.entries.reload.first.body).to eq("<div>Complete <strong>rich</strong> reply</div>")
end

it "rejects raw HTML with quoted history and preserves the complete plain reply" do
paid_user.entries.destroy_all
email = FactoryBot.build(
:email,
to: [{ token: paid_user.user_key, host: ENV['SMTP_DOMAIN'], email: "#{paid_user.user_key}@#{ENV['SMTP_DOMAIN']}"}],
body: "FIRST PARAGRAPH\n\nSECOND PARAGRAPH",
raw_html: '<div>FIRST PARAGRAPH</div><div>SECOND PARAGRAPH</div><blockquote>QUOTED HISTORY</blockquote>',
vendor_specific: {
stripped_html: '<div>FIRST PARAGRAPH</div>'
}
)

EmailProcessor.new(email).process

expect(paid_user.entries.reload.first.body).to eq("<div>FIRST PARAGRAPH<br><br>SECOND PARAGRAPH</div>")
end

it "does not fall back to raw HTML when stripped HTML is truncated" do
paid_user.entries.destroy_all
email = FactoryBot.build(
:email,
to: [{ token: paid_user.user_key, host: ENV['SMTP_DOMAIN'], email: "#{paid_user.user_key}@#{ENV['SMTP_DOMAIN']}"}],
body: "FIRST PARAGRAPH\n\nSECOND PARAGRAPH",
raw_html: '<div><strong>FIRST PARAGRAPH</strong></div><div>SECOND PARAGRAPH</div>',
vendor_specific: {
stripped_html: '<div>FIRST PARAGRAPH</div>'
}
)

EmailProcessor.new(email).process

expect(paid_user.entries.reload.first.body).to eq("<div>FIRST PARAGRAPH<br><br>SECOND PARAGRAPH</div>")
end

it "preserves the complete plain reply when raw HTML is unavailable" do
paid_user.entries.destroy_all
email = FactoryBot.build(
:email,
to: [{ token: paid_user.user_key, host: ENV['SMTP_DOMAIN'], email: "#{paid_user.user_key}@#{ENV['SMTP_DOMAIN']}"}],
body: "FIRST PARAGRAPH\n\nSECOND PARAGRAPH",
vendor_specific: {
stripped_html: '<div>FIRST PARAGRAPH</div>'
}
)

EmailProcessor.new(email).process

expect(paid_user.entries.reload.first.body).to eq("<div>FIRST PARAGRAPH<br><br>SECOND PARAGRAPH</div>")
end

it "preserves blank lines between HTML email paragraphs" do
paid_user.entries.destroy_all
email = FactoryBot.build(
Expand Down
Loading