From 5aad29f84f2a306d5707b6a3497fbf41cfe78d0a Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Sat, 8 Aug 2026 16:19:31 -0400 Subject: [PATCH] fix: detect the signature by Mailsuite's own markers instead of guessing 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:
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) Claude-Session: https://claude.ai/code/session_013rTepGiDUe15iPn6XqmKjy --- README.md | 67 +++++++++++++++++++++++++------------------- src/detect.js | 61 +++++++++++++++++++++++++++++++++++----- test/detect.test.js | 68 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 161 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index f08c6f4..ca619b1 100644 --- a/README.md +++ b/README.md @@ -31,20 +31,38 @@ been removed, which is the quickest way to confirm it is working. ## How it finds the signature -Detection anchors on the promotional link rather than on Mailsuite's markup, -which makes it survive their releases: +Mailsuite labels its own signature, so there is nothing to guess. Every one of +the seven signature templates in `gmail.end.bundle.js` (release 12.87.0) renders +the same wrapper: -1. Find an `` inside the Gmail compose body pointing at `mailtrack.io` or - `mailsuite.com`. -2. Walk up from that link to the outermost wrapper that still contains *nothing - but* signature, stopping the instant a parent holds text you wrote. A block - is "nothing but signature" when removing the promo link text and the known - signature wording leaves it empty. -3. Rescue any tracking beacon inside, drop the blank lines above, delete. +```html +
+
+``` -The signature wording is taken from Mailsuite's own `popup.bundle.js` i18n table -(`senderNotifiedSignatureText` and its `12` / `13` / `15` / `17` / `18` -variants), covering all eleven locales the extension ships. +So the primary rule is a plain selector: + +``` +#mt-signature, [data-signature-template], +[class*="mt-signature"], [class*="mt-old-signature"] +``` + +Only the outermost match is taken, because `mt-signature-logo` sits inside +`#mt-signature` and matches the same selector. + +**Fallback.** If nothing carries a marker, it looks for an `` pointing at +`mailtrack.io` or `mailsuite.com` and climbs to the outermost wrapper that holds +*nothing but* signature, stopping the instant a parent contains text you wrote +or an image you inserted. A block counts as signature-only when removing the +promo link text and the known signature wording leaves it empty. That wording +comes from Mailsuite's own i18n table and covers all eleven shipped locales. + +The fallback is deliberately quick to give up. Leaving a signature behind is a +far better failure than deleting a paragraph. + +Either way it rescues the tracking beacon +(`https://mailtrack.io/trace/mail/.png`), drops the blank line above, then +deletes. It runs on a debounced `MutationObserver`, since Mailsuite inserts the signature while you are still composing, plus a scrub on the Send button in both capture @@ -52,26 +70,19 @@ and bubble phase to cover a signature injected at send time. ## Status -Version 0.1.0, works from inference rather than observation. The selectors were -derived from Mailsuite's popup bundle and from how Gmail structures a compose -body, not from a captured sample of the real injected signature. Tightening that -up is the next step, see below. +Version 0.1.0. Detection is verified against the real markup rather than +inferred from it. 15 tests, including fixtures transcribed from all six live +signature versions. + +Not yet verified: nobody has loaded this into Chrome and watched it work against +live Gmail. The compose-body and Send-button selectors are still inference. ## Before you rely on it Mailsuite already ships a built-in opt-out: *"Don't add the Mailsuite signature -to my emails"*, in its signature settings. If that toggle sticks for you, you do -not need this extension. It is worth thirty seconds to check first. - -## Contributing a real sample - -To make detection exact rather than inferred, capture the actual markup: - -1. Compose an email to yourself with Mailsuite enabled. -2. Before sending, right-click the signature line in the compose box, Inspect. -3. Copy the outer HTML of the element wrapping the whole signature. - -Open an issue with that snippet, with your address redacted. +to my emails"*, in its signature settings, and it renders a Remove button inside +the signature itself. If either sticks for you, you do not need this extension. +Worth thirty seconds to check first. ## Licence diff --git a/src/detect.js b/src/detect.js index 0c9277b..536188e 100644 --- a/src/detect.js +++ b/src/detect.js @@ -17,6 +17,23 @@ const PROMO_HOST = /(^|\.)(mailtrack\.io|mailsuite\.com)$/i; + /* + * How Mailsuite marks its own signature, read out of gmail.end.bundle.js in + * release 12.87.0. Every one of the seven signature templates renders: + * + *
+ *
+ * + * They label it themselves, so there is nothing to infer. The heuristic below + * is only a fallback for markup that does not carry these. + */ + const SIGNATURE_MARKERS = [ + '#mt-signature', + '[data-signature-template]', + '[class*="mt-signature"]', + '[class*="mt-old-signature"]', + ].join(','); + /* Visible signature wording, lifted from Mailsuite's own popup.bundle.js i18n table (senderNotifiedSignatureText and its 12 / 13 / 15 / 17 / 18 variants, every locale the extension ships). Only ever used to subtract known text @@ -130,6 +147,29 @@ } }; + /** + * Elements Mailsuite has labelled as its own signature. + * + * Only the outermost are returned. `mt-signature-logo` sits inside + * `#mt-signature` and matches the same selector, and removing the child on + * its own would leave a gutted wrapper behind. + */ + const markedBlocks = (root) => { + const all = Array.from(root.querySelectorAll(SIGNATURE_MARKERS)); + return all.filter((el) => !all.some((other) => other !== el && other.contains(el))); + }; + + /** Shared teardown: keep the beacon, close the gap, drop the block. */ + const removeBlock = (block, root, keepUnsubscribe) => { + if (keepUnsubscribe && UNSUB.test(block.textContent || '')) return false; + /* Trim first. rescueBeacons parks the pixel immediately before the block, + which would otherwise stop trimBefore seeing the blank line above it. */ + trimBefore(block); + rescueBeacons(block, root); + block.remove(); + return true; + }; + /** * Strip every signature under `root`, returning how many were removed. * `keepUnsubscribe` leaves blocks carrying an opt-out link alone, since bulk @@ -138,25 +178,32 @@ const scrubRoot = (root, options) => { const keepUnsubscribe = !options || options.keepUnsubscribe !== false; let removed = 0; + + /* Mailsuite's own markers first. These are exact, so none of the + signature-only guesswork below applies: the logo image inside the block + is theirs, not the user's, and must not stop the removal. */ + for (const block of markedBlocks(root)) { + if (!root.contains(block)) continue; + if (removeBlock(block, root, keepUnsubscribe)) removed += 1; + } + + /* Fallback for markup that carries no marker. Conservative on purpose. */ for (const a of Array.from(root.querySelectorAll('a'))) { if (!root.contains(a) || !promoAnchor(a)) continue; const block = signatureBlock(a, root); if (!block) continue; - if (keepUnsubscribe && UNSUB.test(block.textContent || '')) continue; - /* Trim first. rescueBeacons parks the pixel immediately before the block, - which would otherwise stop trimBefore seeing the blank line above it. */ - trimBefore(block); - rescueBeacons(block, root); - block.remove(); - removed += 1; + if (removeBlock(block, root, keepUnsubscribe)) removed += 1; } + return removed; }; return { PROMO_HOST, + SIGNATURE_MARKERS, PHRASES, UNSUB, + markedBlocks, promoAnchor, looksLikeBeacon, signatureOnly, diff --git a/test/detect.test.js b/test/detect.test.js index c996342..ba0effa 100644 --- a/test/detect.test.js +++ b/test/detect.test.js @@ -127,6 +127,74 @@ test('the beacon stays where the signature was, not at the end of the body', () assert.equal(root.querySelectorAll('br').length, 0, 'blank line still trimmed'); }); +/* + * Real markup, transcribed from gmail.end.bundle.js in Mailsuite 12.87.0. All + * seven signature templates share the #mt-signature wrapper and the + * data-signature-template attribute; the inner layout differs per version. + */ +const realSignature = (version) => ` +
+
+ + + + + +
+ Mailsuite + + Sent with Mailtrack  ยท  + Mailsuite +
+
`; + +test('removes the real signature whole, logo and all', () => { + for (const version of [12, 13, 15, 16, 17, 18]) { + const root = compose('
Hi, notes below.
' + realSignature(version)); + + assert.equal(detect.scrubRoot(root), 1, `version ${version}`); + assert.equal(root.querySelector('#mt-signature'), null, `version ${version}: wrapper left behind`); + assert.equal(root.querySelectorAll('table').length, 0, `version ${version}: table left behind`); + assert.equal(root.querySelectorAll('img').length, 0, `version ${version}: logo left behind`); + assert.match(root.textContent, /Hi, notes below/); + } +}); + +test("their logo goes, the user's own image next to it stays", () => { + const root = compose( + '
Screenshot attached
' + + '
' + + realSignature(17), + ); + + assert.equal(detect.scrubRoot(root), 1); + assert.equal(root.querySelectorAll('img[src="cid:screenshot.png"]').length, 1); + assert.equal(root.querySelectorAll('img[src*="mailtrack-signature"]').length, 0); +}); + +test('a beacon inside the real signature survives it', () => { + const withBeacon = realSignature(17).replace( + '', + '', + ); + const root = compose('
Body
' + withBeacon); + + assert.equal(detect.scrubRoot(root), 1); + assert.equal(root.querySelector('#mt-signature'), null); + assert.equal(root.querySelectorAll('img[src*="/trace/mail/"]').length, 1); +}); + +test('markedBlocks returns only the outermost match', () => { + const root = compose(realSignature(17).replace('alt="Mailsuite"', 'alt="Mailsuite" class="mt-signature-logo"')); + const blocks = detect.markedBlocks(root); + + assert.equal(blocks.length, 1, 'the nested logo must not count as its own block'); + assert.equal(blocks[0].id, 'mt-signature'); +}); + test('promoAnchor only matches Mailsuite hosts', () => { const root = compose( '
a' +