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' +