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
52 changes: 50 additions & 2 deletions app/lib/email_processor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,12 @@
@bcc = email.bcc
@subject = to_utf8(email.subject)
@stripped_html = email.vendor_specific.try(:[], :stripped_html)
@body = clean_message(email.body).presence || "No entry provided."
@griddler_body = to_utf8(email.body&.dup)
@raw_html = email.raw_html
@body = clean_message(@griddler_body&.dup).presence || "No entry provided."

@html = clean_html_version(@stripped_html)
@authored_indentation_html = clean_authored_indentation_html(@raw_html, @griddler_body)
@message_id = email.headers&.dig("Message-ID")&.gsub("<", "")&.gsub(">", "")

@raw_body = to_utf8(email.raw_body)
Expand Down Expand Up @@ -108,7 +111,11 @@
date = parse_subject_for_date(@subject)
existing_entry = @user.existing_entry(date.to_s)
inspiration_id = parse_body_for_inspiration_id(@raw_body)
@body = preferred_pro_body if @user.is_pro?
if @user.is_pro?
@body = preferred_pro_body
elsif @user.is_free? && @authored_indentation_html.present?
@body = clean_message(without_leading_quote_markers(@griddler_body)).presence || @body
end

if existing_entry.present?
existing_entry.original_email = @inbound_email_params
Expand Down Expand Up @@ -414,7 +421,48 @@
fragment.text.unicode_normalize(:nfkc).gsub(/\s+/, ' ').strip
end

def clean_authored_indentation_html(raw_html, griddler_body)
return unless raw_html.present? && griddler_body.present?

has_quote_markers = griddler_body.lines.any? { |line| line.match?(/\A[[:blank:]]*>/) }
fragment = Nokogiri::HTML5.fragment(raw_html)
fragment.css('.gmail_quote, .gmail_signature').remove

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

View check run for this annotation

codefactor.io / CodeFactor

app/lib/email_processor.rb#L429

Prefer double-quoted strings unless you need single quotes to avoid extra backslashes for escaping. (Style/StringLiterals)
blockquote_count = fragment.css('blockquote').count

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

View check run for this annotation

codefactor.io / CodeFactor

app/lib/email_processor.rb#L430

Prefer double-quoted strings unless you need single quotes to avoid extra backslashes for escaping. (Style/StringLiterals)
unsafe_content = unsafe_raw_html?(fragment)
cleaned_html = clean_html_version(fragment.to_html) if has_quote_markers && blockquote_count.positive? && !unsafe_content
text_matches = cleaned_html.present? &&
normalized_visible_text(cleaned_html) == normalized_unquoted_plain_text(griddler_body)
accepted = has_quote_markers && blockquote_count.positive? && !unsafe_content && text_matches

cleaned_html if accepted
end

def normalized_unquoted_plain_text(body)
without_leading_quote_markers(body)
.unicode_normalize(:nfkc)

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

View check run for this annotation

codefactor.io / CodeFactor

app/lib/email_processor.rb#L442

Use 2 (not 4) spaces for indenting an expression spanning multiple lines. (Layout/MultilineMethodCallIndentation)
.gsub(/\s+/, ' ')

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

View check run for this annotation

codefactor.io / CodeFactor

app/lib/email_processor.rb#L443

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

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

View check run for this annotation

codefactor.io / CodeFactor

app/lib/email_processor.rb#L443

Use 2 (not 4) spaces for indenting an expression spanning multiple lines. (Layout/MultilineMethodCallIndentation)
.strip

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

View check run for this annotation

codefactor.io / CodeFactor

app/lib/email_processor.rb#L444

Use 2 (not 4) spaces for indenting an expression spanning multiple lines. (Layout/MultilineMethodCallIndentation)
end

def without_leading_quote_markers(body)
body.to_s.lines.map { |line| line.sub(/\A[[:blank:]]*(?:>[[:blank:]]*)+/, '') }.join

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

View check run for this annotation

codefactor.io / CodeFactor

app/lib/email_processor.rb#L448

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

def unsafe_raw_html?(fragment)
unsafe_elements = %w[script iframe object embed form input button textarea select option link meta base svg math]
return true if fragment.css(unsafe_elements.join(',')).any?

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

View check run for this annotation

codefactor.io / CodeFactor

app/lib/email_processor.rb#L453

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

fragment.css('*').any? do |node|

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

View check run for this annotation

codefactor.io / CodeFactor

app/lib/email_processor.rb#L455

Prefer double-quoted strings unless you need single quotes to avoid extra backslashes for escaping. (Style/StringLiterals)
node.attribute_nodes.any? do |attribute|
attribute.name.match?(/\Aon/i) ||
(%w[href src xlink:href formaction].include?(attribute.name.downcase) &&
attribute.value.match?(/\A\s*(?:javascript|vbscript|data):/i))
end
end
end

def preferred_pro_body
return @authored_indentation_html if @authored_indentation_html.present?
return @body unless @html.present?

plain_text = normalized_visible_text(@body)
Expand Down
25 changes: 25 additions & 0 deletions spec/features/entries_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,31 @@
expect(rendered_entry).not_to have_text('Sender signature')
end

it 'renders Gmail-authored indentation as a blockquote without quote history' 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\n> Second intentionally indented paragraph",
raw_html: '<div dir="ltr"><div>First paragraph</div><blockquote style="margin:0 0 0 40px;border:none;padding:0"><div>Second intentionally indented paragraph</div></blockquote></div><div class="gmail_quote"><div>On Aug 2, 2026, Dabble Me wrote:</div><blockquote><div>Actual quoted history</div></blockquote></div>',
vendor_specific: {
stripped_html: '<div>First paragraph</div>'
}
)

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

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')
expect(rendered_entry).to have_text('First paragraph')
expect(rendered_entry.find('blockquote')).to have_text('Second intentionally indented paragraph')
expect(rendered_entry).not_to have_text('Actual quoted history')
expect(rendered_entry).not_to have_text('Dabble Me wrote')
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
153 changes: 152 additions & 1 deletion spec/models/email_processor_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,158 @@
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
it "preserves a trailing Gmail-authored indentation block" 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\n> Second intentionally indented paragraph",
raw_html: '<div dir="ltr"><div>First paragraph</div><blockquote style="margin:0 0 0 40px;border:none;padding:0"><div>Second intentionally indented paragraph</div></blockquote></div>',
vendor_specific: {
stripped_html: '<div>First paragraph</div>'
}
)

EmailProcessor.new(email).process

expect(paid_user.entries.reload.first.body).to eq(
'<div><div>First paragraph</div><blockquote><div>Second intentionally indented paragraph</div></blockquote></div>'
)
end

it "preserves trailing Gmail-authored indentation for a free user as plain formatting" do
user.entries.destroy_all
email = FactoryBot.build(
:email,
to: [{ token: user.user_key, host: ENV['SMTP_DOMAIN'], email: "#{user.user_key}@#{ENV['SMTP_DOMAIN']}"}],
body: "First paragraph\n\n> Second intentionally indented paragraph",
raw_html: '<div dir="ltr"><div>First paragraph</div><blockquote style="margin:0 0 0 40px;border:none;padding:0"><div>Second intentionally indented paragraph</div></blockquote></div><div class="gmail_quote"><div>On Aug 2, 2026, Dabble Me wrote:</div><blockquote><div>Actual quoted history</div></blockquote></div>',
vendor_specific: {
stripped_html: '<div>First paragraph</div>'
}
)

EmailProcessor.new(email).process

expect(user.entries.reload.first.body).to eq(
'First paragraph<br><br>Second intentionally indented paragraph'
)
end

it "preserves a whole Gmail-authored indented entry with multiple quote lines" 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 indented paragraph\n>\n> Second indented paragraph",
raw_html: '<blockquote style="margin:0 0 0 40px;border:none;padding:0"><div>First indented paragraph</div><div><br></div><div>Second indented paragraph</div></blockquote>',
vendor_specific: {
stripped_html: nil
}
)

EmailProcessor.new(email).process

expect(paid_user.entries.reload.first.body).to eq(
'<blockquote><div>First indented paragraph</div><br><div>Second indented paragraph</div></blockquote>'
)
end

it "preserves normal text after a Gmail-authored indentation block" 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: "Before indentation\n\n> Intentionally indented\n\nAfter indentation",
raw_html: '<div dir="ltr"><div>Before indentation</div><blockquote style="margin:0 0 0 40px;border:none;padding:0"><div>Intentionally indented</div></blockquote><div>After indentation</div></div>',
vendor_specific: {
stripped_html: '<div>Before indentation</div><div>After indentation</div>'
}
)

EmailProcessor.new(email).process

expect(paid_user.entries.reload.first.body).to eq(
'<div><div>Before indentation</div><blockquote><div>Intentionally indented</div></blockquote><div>After indentation</div></div>'
)
end

it "preserves authored indentation while excluding actual Gmail quote history" 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\n> Authored indentation",
raw_html: '<div dir="ltr"><div>First paragraph</div><blockquote style="margin:0 0 0 40px;border:none;padding:0"><div>Authored indentation</div></blockquote></div><div class="gmail_quote"><div>On Aug 2, 2026, Dabble Me wrote:</div><blockquote><div>Actual quoted history</div></blockquote></div>',
vendor_specific: {
stripped_html: '<div>First paragraph</div>'
}
)

EmailProcessor.new(email).process

saved_body = paid_user.entries.reload.first.body
expect(saved_body).to eq(
'<div><div>First paragraph</div><blockquote><div>Authored indentation</div></blockquote></div>'
)
expect(saved_body).not_to include('Actual quoted history')
expect(saved_body).not_to include('Dabble Me wrote')
end

it "rejects a generic raw quote when an attribution reveals quoted history" 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: "New response\n\n> Previous entry",
raw_html: '<div>New response</div><div>On Aug 2, 2026, Someone wrote:</div><blockquote><div>Previous entry</div></blockquote>',
vendor_specific: {
stripped_html: '<div>New response</div>'
}
)

EmailProcessor.new(email).process

expect(paid_user.entries.reload.first.body).to eq('<div>New response</div>')
end

it "falls back safely when raw indentation HTML contains active content" 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\n> Second paragraph",
raw_html: '<div>First paragraph</div><blockquote style="margin:0 0 0 40px;border:none;padding:0" onclick="alert(1)"><div>Second paragraph</div></blockquote>',
vendor_specific: {
stripped_html: '<div>First paragraph</div>'
}
)

EmailProcessor.new(email).process

saved_body = paid_user.entries.reload.first.body
expect(saved_body).to eq('<div>First paragraph</div>')
expect(saved_body).not_to include('onclick')
end

it "falls back safely when raw indentation text does not match the Griddler body" 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\n> Expected second paragraph",
raw_html: '<div>First paragraph</div><blockquote style="margin:0 0 0 40px;border:none;padding:0"><div>Different second paragraph</div></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</div>')
end

it "keeps the complete plain fallback for #144 malformed authored content inside .gmail_quote" do
paid_user.entries.destroy_all
email = FactoryBot.build(
:email,
Expand Down
Loading