Skip to content

Extract detection into a testable module, fix an over-matching bug - #4

Merged
EnesYilmazcode merged 2 commits into
mainfrom
test/extract-detection
Aug 8, 2026
Merged

EnesYilmazcode merged 2 commits into
mainfrom
test/extract-detection

Conversation

@EnesYilmazcode

Copy link
Copy Markdown
Owner

Closes #2.

Why

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 only part that
could not be run outside Chrome. signatureOnly() and signatureBlock() decide
what gets deleted out of somebody's message, and they had no coverage at all.

What changed

src/detect.js now holds the rules, with no chrome.* and no reliance on
globals beyond the node passed in, so the same file runs inside Gmail and under
jsdom. src/strip.js keeps the observer, the send hooks and the settings. The
manifest loads detect.js first.

promoAnchor() also stopped parsing hrefs by hand and now reads a.hostname
off 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 parent
looked signature-only. When no parent qualified it fell through and returned the
anchor itself, so this:

<div>I have been using <a href="https://mailsuite.com">Mailsuite</a> to track
replies and it works well.</div>

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 null otherwise. That trades a bit of under-matching
for never eating something a person wrote, which is the right way round while
detection is still inferred rather than observed (#1).

Tests

# tests 8
# pass 8
# fail 0
  • signature removed, message untouched
  • blank line above the signature cleaned up
  • tracking beacon survives
  • campaign signature with an unsubscribe link left in place, and removed when
    keepUnsubscribe is off
  • a link the user wrote themselves is never touched
  • a message with no signature comes out byte-identical
  • localised signatures (es, de, ja) recognised
  • promoAnchor rejects notmailsuite.com and
    example.com/?ref=mailsuite.com

Also 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.

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 EnesYilmazcode left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@EnesYilmazcode

Copy link
Copy Markdown
Owner Author

Pushed b122b11. All three addressed.

The image hole. Fixed as suggested, signatureOnly() now bails on any
non-beacon <img> before it looks at text at all. Test added with an 800x600
cid: image sitting next to the signature: block count 0, image intact.

Beacon position. Now parent.insertBefore(img, block).

That one had a tail. rescueBeacons() ran before trimBefore(), so the pixel
landed between the block and the blank line above it, previousSibling became
an IMG, and trimBefore() stopped on the first iteration. Moving the beacon
fixed the cosmetic problem and quietly broke the blank-line cleanup. The two are
swapped in scrubRoot() now, with a comment saying why the order matters, and
the new test asserts both properties at once so they cannot drift apart again.

Worth noting this only surfaced because a test already pinned the blank-line
behaviour. Without it the fix would have looked clean.

Climb coverage. Added a nested .outer > .inner fixture. Every previous
fixture was one level deep, so the while loop had never actually run.

Not doing in this PR:

11 tests, all green.

@EnesYilmazcode
EnesYilmazcode merged commit 6c86b72 into main Aug 8, 2026
1 check passed
@EnesYilmazcode
EnesYilmazcode deleted the test/extract-detection branch August 8, 2026 20:10
EnesYilmazcode added a commit that referenced this pull request Aug 8, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Detection logic is untestable: extract it from the IIFE and add fixtures

1 participant