Repository navigation
Extract detection into a testable module, fix an over-matching bug - #4
Conversation
The detection rules were tangled up with the Gmail glue and chrome.storage inside one IIFE, so the part most likely to be wrong was also the part that could only be exercised by loading the extension into Chrome and composing a real email. signatureOnly() and signatureBlock() decide what gets deleted out of somebody's message and had no coverage at all. src/detect.js now holds the rules with no chrome.* and no reliance on globals beyond the node passed in, which lets the same code run under jsdom. strip.js keeps the observer, the send hooks and the settings. Writing the tests turned up a real over-matching bug. signatureBlock() started at the promo link and climbed while each parent looked signature-only, but when no parent qualified it fell back to returning the anchor itself, so a sentence like "I have been using <a>Mailsuite</a> to track replies" lost its link. The immediate parent now has to be signature-only before anything is removed, and the function returns null otherwise. Failing safe is the right default while detection is still inferred rather than observed, see #1. Eight tests, including the two failures that matter most: a link the user wrote themselves must survive, and the tracking beacon must survive. Closes #2 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rTepGiDUe15iPn6XqmKjy
EnesYilmazcode
left a comment
There was a problem hiding this comment.
Read this against the case that actually matters, which is not "does the
signature go" but "can this destroy something the user wrote". The new
signatureBlock() guard closes the text version of that hole. There is still an
image-shaped one.
Blocking: signatureOnly() ignores images, so a user's inline image can be deleted
signatureOnly() decides on textContent alone:
let text = el.textContent || '';
...
return text.replace(PUNCTUATION, '') === '';An <img> contributes no textContent. So this element reads as
signature-only:
<div><img src="cid:screenshot.png">Sender notified by <a href="https://mailsuite.com">Mailsuite</a></div>The whole block is removed. rescueBeacons() does not save the screenshot,
because it only rescues things that look like tracking pixels, and a real
screenshot correctly fails looksLikeBeacon().
Whether Mailsuite ever wraps its signature around a user image is unknown, which
is exactly the problem. This is the same class of failure the PR just fixed for
text, and it should fail the same way: leave it alone when unsure.
Suggested fix, in signatureOnly() before the text check:
for (const img of el.querySelectorAll('img')) {
if (!looksLikeBeacon(img)) return false;
}An element holding a real image is not just a signature, whatever its text says.
Worth a test with an inline image next to the signature.
Non-blocking
rescueBeacons() does root.appendChild(img), which parks the beacon at the
very end of the compose body. If the signature was not last, the pixel jumps
past whatever followed it. Invisible either way, so cosmetic, but
block.parentNode.insertBefore(img, block) keeps it where it was for the same
effort.
settings.removed counts scrub passes rather than distinct signatures. The
observer can fire more than once on the same compose window, so the number in
the popup drifts high. Fine as a "this is working" indicator, which is all it
claims to be, but it should not be read as a real count.
No test covers the climb in signatureBlock(). Every fixture here has the
signature exactly one level deep, so the while loop never runs more than once.
A nested <div><div>…signature…</div></div> fixture would pin the behaviour the
loop exists for.
Otherwise
Moving promoAnchor() onto a.hostname is the right call, and the two
rejection cases in the last test (notmailsuite.com, and mailsuite.com
appearing only in a query string) are the ones a hand-rolled string check would
have got wrong.
signatureOnly() judged on textContent alone, and an <img> contributes none. So a block wrapping somebody's screenshot next to the signature read as signature-only and was deleted whole. rescueBeacons() did not save it, correctly, since a real screenshot fails looksLikeBeacon(). That is the image-shaped version of the over-matching bug this branch already fixed for text, and it now fails the same way: an element holding a real image is never just a signature. Also from review: rescueBeacons() parked the pixel at the end of the compose body, which sent it past anything that followed the signature. It now goes back where the block stood. That reorders scrubRoot, since the rescued pixel would otherwise sit between the block and the blank line above it and stop trimBefore seeing it. Three more tests: an inline image survives, the climb in signatureBlock() is actually exercised by a nested wrapper rather than every fixture being one level deep, and the beacon keeps its position while the blank line above it still goes. 11 tests, all passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rTepGiDUe15iPn6XqmKjy
|
Pushed b122b11. All three addressed. The image hole. Fixed as suggested, Beacon position. Now That one had a tail. Worth noting this only surfaced because a test already pinned the blank-line Climb coverage. Added a nested Not doing in this PR:
11 tests, all green. |
Read gmail.end.bundle.js out of the installed extension (12.87.0). Mailsuite
labels its signature, so all the inference was unnecessary. Every one of the
seven templates renders:
<div id="mt-signature" contenteditable="false" g_editable="false">
<table data-signature-template="senderNotified" data-signature-version="N">
Detection is now that selector, plus the mt-signature / mt-old-signature class
variants, taking only the outermost match since mt-signature-logo sits inside
the wrapper and matches the same selector. The promo-link heuristic stays as a
fallback for markup carrying no marker.
This fixes a bug that made the extension worse than useless on real mail. The
signature contains a 24x20 logo served from s3.amazonaws.com, and the image
guard added in #4 treats a non-beacon image as a sign the block belongs to the
user. Against the real markup the climb stopped at the row holding the logo, so
the inner cell was deleted and the wrapper, the table and the logo were left
sitting in the message. Measured before the fix:
logo img present: false -> removed: 1 | signature gone: true
logo img present: true -> removed: 1 | signature gone: false
The guard was right for the fallback path and wrong for the marked path. Marked
blocks are Mailsuite's by their own admission, so the image inside is theirs
too. The guard now only applies to the heuristic.
Six new tests built from transcribed real markup, covering every live signature
version, the logo going while a user's inline image beside it stays, a beacon
inside the block surviving, and outermost-only selection.
15 tests, all passing.
Closes #1
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rTepGiDUe15iPn6XqmKjy
Closes #2.
Why
The detection rules were tangled up with the Gmail glue and
chrome.storageinside one IIFE, so the part most likely to be wrong was also the only part that
could not be run outside Chrome.
signatureOnly()andsignatureBlock()decidewhat gets deleted out of somebody's message, and they had no coverage at all.
What changed
src/detect.jsnow holds the rules, with nochrome.*and no reliance onglobals beyond the node passed in, so the same file runs inside Gmail and under
jsdom.
src/strip.jskeeps the observer, the send hooks and the settings. Themanifest loads
detect.jsfirst.promoAnchor()also stopped parsing hrefs by hand and now readsa.hostnameoff the DOM, which is both shorter and correct for the two cases in the test at
the bottom of the file.
The bug the tests found
signatureBlock()started at the promo link and climbed while each parentlooked signature-only. When no parent qualified it fell through and returned the
anchor itself, so this:
lost its link. The sentence survived, the
<a>did not.The immediate parent now has to be signature-only before anything is removed,
and the function returns
nullotherwise. That trades a bit of under-matchingfor never eating something a person wrote, which is the right way round while
detection is still inferred rather than observed (#1).
Tests
keepUnsubscribeis offpromoAnchorrejectsnotmailsuite.comandexample.com/?ref=mailsuite.comAlso adds a GitHub Actions workflow so this runs on every PR.
Still open
Every selector here is still inference. Nothing has been checked against the
real injected markup, which is #1 and needs a sample from a live compose window.