From a8049b11aee530a929ec39b448e3177d36dc44ac Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Sat, 8 Aug 2026 16:06:47 -0400 Subject: [PATCH 1/2] refactor: extract detection into src/detect.js and cover it with tests 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 Mailsuite 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) Claude-Session: https://claude.ai/code/session_013rTepGiDUe15iPn6XqmKjy --- .github/workflows/test.yml | 17 +++++ .gitignore | 4 + manifest.json | 2 +- package.json | 13 ++++ src/detect.js | 153 +++++++++++++++++++++++++++++++++++++ src/strip.js | 125 +++--------------------------- test/detect.test.js | 99 ++++++++++++++++++++++++ 7 files changed, 296 insertions(+), 117 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 package.json create mode 100644 src/detect.js create mode 100644 test/detect.test.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..7bd4ede --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,17 @@ +name: test + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm install --no-fund --no-audit + - run: npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..27daa77 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +package-lock.json +*.zip +.DS_Store diff --git a/manifest.json b/manifest.json index 97596a0..b5633fa 100644 --- a/manifest.json +++ b/manifest.json @@ -8,7 +8,7 @@ "content_scripts": [ { "matches": ["https://mail.google.com/*"], - "js": ["src/strip.js"], + "js": ["src/detect.js", "src/strip.js"], "run_at": "document_idle", "all_frames": true } diff --git a/package.json b/package.json new file mode 100644 index 0000000..ba7c059 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "mailsuitefree", + "version": "0.1.0", + "private": true, + "description": "Chrome extension that removes the Mailsuite promotional signature from outgoing Gmail messages", + "license": "MIT", + "scripts": { + "test": "node --test \"test/**/*.test.js\"" + }, + "devDependencies": { + "jsdom": "^24.1.0" + } +} diff --git a/src/detect.js b/src/detect.js new file mode 100644 index 0000000..ff82af9 --- /dev/null +++ b/src/detect.js @@ -0,0 +1,153 @@ +/* + * Detection rules for the Mailsuite signature. + * + * Deliberately free of chrome.* APIs and of globals beyond the nodes handed in, + * so the same code runs inside Gmail and under jsdom in test/. src/strip.js + * holds the Gmail glue. + */ +(function (root, factory) { + const api = factory(); + if (typeof module === 'object' && module.exports) module.exports = api; + else root.MailSuiteFreeDetect = api; +})(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + + const ELEMENT_NODE = 1; + const TEXT_NODE = 3; + + const PROMO_HOST = /(^|\.)(mailtrack\.io|mailsuite\.com)$/i; + + /* 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 + while deciding whether a block is nothing but signature. Never on its own + as grounds to delete something. */ + const PHRASES = [ + 'sender notified by', 'sender notified with mailtrack', + 'email delivery certified by', 'sent with mailtrack', 'sent with', + 'remitente notificado con', 'entrega de email certificada por', 'enviado con mailtrack', + 'remetente notificado com', 'enviado com mailtrack', + 'expéditeur notifié par', "livraison d'e-mail certifiée par", 'envoyé avec mailtrack', + 'absender benachrichtigt von', 'e-mail-zustellung zertifiziert von', 'gesendet mit mailtrack', + 'mittente notificato da', 'consegna email certificata da', 'inviato con mailtrack', + 'nadawca powiadomiony przez', 'dostarczenie e-maila potwierdzone przez', 'wysłano z mailtrack', + '送信者への通知', 'メール配信証明', 'mailtrackで送信', + '발신자 알림', '이메일 수신 확인', 'mailtrack로 전송', + '寄件者已透過', '已透過mailtrack寄出', + 'प्रेषक को सूचित किया गया', + ]; + + const UNSUB = /unsubscribe|opt[-\s]?out|darse de baja|se désabonner|abmelden|annulla iscrizione|cancelar subscri|wypisz|取消訂閱|配信停止|수신 거부|सदस्यता समाप्त/i; + + const PUNCTUATION = /[\s .,;:·|—–()[\]-]/g; + + /** A link out to Mailsuite's own site, which is what the signature always is. */ + const promoAnchor = (a) => Boolean(a.getAttribute('href')) && PROMO_HOST.test(a.hostname || ''); + + /** The read-tracking pixel, which has to survive whatever else we delete. */ + const looksLikeBeacon = (img) => { + const src = img.getAttribute('src') || ''; + if (/mailtrack\.io|mailsuite\.com/i.test(src)) return true; + const w = parseInt(img.getAttribute('width') || '', 10); + const h = parseInt(img.getAttribute('height') || '', 10); + return (w > 0 && w <= 3) || (h > 0 && h <= 3); + }; + + /** True when this element holds the signature and none of the user's own text. */ + const signatureOnly = (el) => { + let text = el.textContent || ''; + for (const a of el.querySelectorAll('a')) { + const label = a.textContent; + if (label && promoAnchor(a)) text = text.split(label).join(' '); + } + text = text.toLowerCase(); + for (const phrase of PHRASES) text = text.split(phrase).join(' '); + return text.replace(PUNCTUATION, '') === ''; + }; + + /** + * The block to delete for a given promo link, or null to leave it alone. + * + * The link's immediate parent has to be signature-only. That single check is + * what stops the extension eating a sentence in which somebody genuinely + * linked to mailsuite.com. From there it climbs as far as it can while every + * parent is still signature-only, so the whole block goes rather than a bare + * stranded in leftover text. + */ + const signatureBlock = (anchor, root) => { + const parent = anchor.parentElement; + if (!parent || parent === root || !signatureOnly(parent)) return null; + let block = parent; + while ( + block.parentElement && + block.parentElement !== root && + signatureOnly(block.parentElement) + ) { + block = block.parentElement; + } + return block; + }; + + /** Move any tracking beacon out of the block before the block is deleted. */ + const rescueBeacons = (block, root) => { + let rescued = 0; + for (const img of Array.from(block.querySelectorAll('img'))) { + if (!looksLikeBeacon(img)) continue; + root.appendChild(img); + rescued += 1; + } + return rescued; + }; + + /** Drop the blank lines Mailsuite leaves sitting above its signature. */ + const trimBefore = (block) => { + let prev = block.previousSibling; + while (prev) { + const blank = + (prev.nodeType === TEXT_NODE && !prev.textContent.trim()) || + (prev.nodeType === ELEMENT_NODE && + (prev.tagName === 'BR' || + (/^(DIV|P)$/.test(prev.tagName) && + !prev.textContent.trim() && + !prev.querySelector('img')))); + if (!blank) break; + const doomed = prev; + prev = prev.previousSibling; + doomed.remove(); + } + }; + + /** + * Strip every signature under `root`, returning how many were removed. + * `keepUnsubscribe` leaves blocks carrying an opt-out link alone, since bulk + * mail is required to carry one under CAN-SPAM and GDPR. + */ + const scrubRoot = (root, options) => { + const keepUnsubscribe = !options || options.keepUnsubscribe !== false; + let removed = 0; + 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; + rescueBeacons(block, root); + trimBefore(block); + block.remove(); + removed += 1; + } + return removed; + }; + + return { + PROMO_HOST, + PHRASES, + UNSUB, + promoAnchor, + looksLikeBeacon, + signatureOnly, + signatureBlock, + rescueBeacons, + trimBefore, + scrubRoot, + }; +}); diff --git a/src/strip.js b/src/strip.js index 03bfa07..aa0daa7 100644 --- a/src/strip.js +++ b/src/strip.js @@ -1,42 +1,14 @@ /* - * Mailsuite Signature Stripper - * - * Removes the promotional signature that Mailsuite (formerly Mailtrack) appends - * to outgoing Gmail messages. - * - * Two things it deliberately does NOT remove: - * 1. The read-tracking beacon. It is lifted out of the signature block before - * the block is deleted, so open tracking keeps working. - * 2. Any block containing an unsubscribe / opt-out link. Stripping those from - * bulk mail is a CAN-SPAM and GDPR problem. Off by default, see settings. + * Gmail glue. The rules for what counts as a signature live in src/detect.js, + * which is loaded first by the manifest. * * Nothing leaves the browser. This only edits the compose window's DOM. */ (() => { 'use strict'; - const PROMO_HOST = /(^|\.)(mailtrack\.io|mailsuite\.com)$/i; - - /* Visible signature wording, lifted from Mailsuite's own popup.bundle.js i18n - table (senderNotifiedSignatureText / 12 / 13 / 15 / 17 / 18, every locale - the extension ships). Used to decide whether a block is nothing but the - signature, never on its own to delete something. */ - const PHRASES = [ - 'sender notified by', 'sender notified with mailtrack', - 'email delivery certified by', 'sent with mailtrack', 'sent with', - 'remitente notificado con', 'entrega de email certificada por', 'enviado con mailtrack', - 'remetente notificado com', 'enviado com mailtrack', - 'expéditeur notifié par', "livraison d'e-mail certifiée par", 'envoyé avec mailtrack', - 'absender benachrichtigt von', 'e-mail-zustellung zertifiziert von', 'gesendet mit mailtrack', - 'mittente notificato da', 'consegna email certificata da', 'inviato con mailtrack', - 'nadawca powiadomiony przez', 'dostarczenie e-maila potwierdzone przez', 'wysłano z mailtrack', - '送信者への通知', 'メール配信証明', 'mailtrackで送信', - '발신자 알림', '이메일 수신 확인', 'mailtrack로 전송', - '寄件者已透過', '已透過mailtrack寄出', - 'प्रेषक को सूचित किया गया', - ]; - - const UNSUB = /unsubscribe|opt[-\s]?out|darse de baja|se désabonner|abmelden|annulla iscrizione|cancelar subscri|wypisz|取消訂閱|配信停止|수신 거부|सदस्यता समाप्त/i; + const detect = globalThis.MailSuiteFreeDetect; + if (!detect) return; /* Gmail's compose body, across layouts and locales. */ const COMPOSE = [ @@ -53,91 +25,12 @@ for (const key of Object.keys(changes)) settings[key] = changes[key].newValue; }); - const promoAnchor = (a) => { - const href = a.getAttribute('href'); - if (!href) return false; - try { - return PROMO_HOST.test(new URL(href, location.href).hostname); - } catch { - return false; - } - }; - - const looksLikeBeacon = (img) => { - const src = img.getAttribute('src') || ''; - if (/mailtrack\.io|mailsuite\.com/i.test(src)) return true; - const w = parseInt(img.getAttribute('width') || '', 10); - const h = parseInt(img.getAttribute('height') || '', 10); - return (w > 0 && w <= 3) || (h > 0 && h <= 3); - }; - - /* True when this element holds the signature and none of the user's own text. */ - const signatureOnly = (el) => { - let text = el.textContent || ''; - for (const a of el.querySelectorAll('a')) { - const label = a.textContent; - if (label && promoAnchor(a)) text = text.split(label).join(' '); - } - text = text.toLowerCase(); - for (const phrase of PHRASES) text = text.split(phrase).join(' '); - return text.replace(/[\s .,;:·|—–()[\]-]/g, '') === ''; - }; - - /* Climb from the promo link to the outermost wrapper that is still nothing but - signature, so the whole block goes and not just the . Stops the moment a - parent contains something the user wrote. */ - const signatureBlock = (anchor, root) => { - let block = anchor; - while (block.parentElement && block.parentElement !== root) { - if (!signatureOnly(block.parentElement)) break; - block = block.parentElement; - } - return block; - }; - - /* Move the read-tracking beacon out of the block before deleting it. */ - const rescueBeacons = (block, root) => { - for (const img of [...block.querySelectorAll('img')]) { - if (looksLikeBeacon(img)) root.appendChild(img); - } - }; - - /* Drop the blank lines Mailsuite leaves sitting above its signature. */ - const trimBefore = (block) => { - let prev = block.previousSibling; - while (prev) { - const blank = - (prev.nodeType === Node.TEXT_NODE && !prev.textContent.trim()) || - (prev.nodeType === Node.ELEMENT_NODE && - (prev.tagName === 'BR' || - (/^(DIV|P)$/.test(prev.tagName) && - !prev.textContent.trim() && - !prev.querySelector('img')))); - if (!blank) break; - const doomed = prev; - prev = prev.previousSibling; - doomed.remove(); - } - }; - - const scrubRoot = (root) => { - let removed = 0; - for (const a of [...root.querySelectorAll('a')]) { - if (!root.contains(a) || !promoAnchor(a)) continue; - const block = signatureBlock(a, root); - if (settings.keepUnsubscribe && UNSUB.test(block.textContent || '')) continue; - rescueBeacons(block, root); - trimBefore(block); - block.remove(); - removed += 1; - } - return removed; - }; - const scrubAll = () => { if (!settings.enabled) return 0; let removed = 0; - for (const root of document.querySelectorAll(COMPOSE)) removed += scrubRoot(root); + for (const root of document.querySelectorAll(COMPOSE)) { + removed += detect.scrubRoot(root, { keepUnsubscribe: settings.keepUnsubscribe }); + } if (removed) { settings.removed += removed; chrome.storage.local.set({ removed: settings.removed }); @@ -146,7 +39,7 @@ }; /* Mailsuite drops the signature into the compose box while you are still - writing, so watching for it is the main mechanism. Debounced because Gmail + writing, so watching for it is the main mechanism. Debounced, because Gmail mutates its DOM constantly. */ let timer = 0; const schedule = () => { @@ -161,7 +54,7 @@ }).observe(document.body, { childList: true, subtree: true }); /* Belt and braces for a signature injected at send time. Capture fires before - every other click handler, the document-level bubble listener fires after + every other click handler and the document-level bubble listener fires after them, so one of the two lands on the right side of Mailsuite's own handler whichever order they run in. */ const SEND_LABEL = /^(send|enviar|envoyer|senden|invia|wyślij|送信|보내기|傳送|发送|भेजें)\b/i; diff --git a/test/detect.test.js b/test/detect.test.js new file mode 100644 index 0000000..279b3ed --- /dev/null +++ b/test/detect.test.js @@ -0,0 +1,99 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { JSDOM } = require('jsdom'); + +const detect = require('../src/detect.js'); + +/** Build a Gmail-shaped compose body around the given inner HTML. */ +const compose = (inner) => { + const dom = new JSDOM(`
${inner}
`, { + url: 'https://mail.google.com/mail/u/0/', + }); + return dom.window.document.querySelector('[g_editable="true"]'); +}; + +const SIGNATURE = + '

' + + '
Sender notified by Mailsuite
'; + +test('removes the signature and leaves the message alone', () => { + const root = compose('
Hi Dhruv, following up on the runner fix.
' + SIGNATURE); + + assert.equal(detect.scrubRoot(root), 1); + assert.match(root.textContent, /following up on the runner fix/); + assert.doesNotMatch(root.textContent, /Sender notified by/); +}); + +test('clears the blank line Mailsuite leaves above the signature', () => { + const root = compose('
Body
' + SIGNATURE); + + detect.scrubRoot(root); + + assert.equal(root.querySelectorAll('br').length, 0); + assert.equal(root.children.length, 1); +}); + +test('the read-tracking beacon survives', () => { + const root = compose( + '
Body
' + + '
Sender notified by Mailsuite' + + '
', + ); + + assert.equal(detect.scrubRoot(root), 1); + assert.equal(root.querySelectorAll('img[src*="mailtrack.io"]').length, 1); + assert.doesNotMatch(root.textContent, /Sender notified/); +}); + +test('a campaign signature carrying an unsubscribe link is left in place', () => { + const inner = + '
Campaign body
' + + '
Sender notified by Mailsuite · ' + + 'Unsubscribe
'; + + assert.equal(detect.scrubRoot(compose(inner), { keepUnsubscribe: true }), 0); + assert.equal(detect.scrubRoot(compose(inner), { keepUnsubscribe: false }), 1); +}); + +test('a link the user wrote themselves is never touched', () => { + const root = compose( + '
I have been using Mailsuite to track replies ' + + 'and it works well.
', + ); + + assert.equal(detect.scrubRoot(root), 0); + assert.match(root.textContent, /it works well/); + assert.equal(root.querySelectorAll('a').length, 1); +}); + +test('a message with no signature is left exactly as it was', () => { + const root = compose('
Just a normal email.
'); + const before = root.innerHTML; + + assert.equal(detect.scrubRoot(root), 0); + assert.equal(root.innerHTML, before); +}); + +test('localised signatures are recognised', () => { + for (const phrase of ['Remitente notificado con', 'Absender benachrichtigt von', '送信者への通知']) { + const root = compose( + `
Body
${phrase} Mailtrack
`, + ); + assert.equal(detect.scrubRoot(root), 1, `failed for: ${phrase}`); + } +}); + +test('promoAnchor only matches Mailsuite hosts', () => { + const root = compose( + 'a' + + 'b' + + 'c' + + 'd', + ); + const at = (id) => detect.promoAnchor(root.querySelector(`#${id}`)); + + assert.equal(at('a'), true); + assert.equal(at('b'), true); + assert.equal(at('c'), false, 'suffix match must not count as the host'); + assert.equal(at('d'), false, 'the host is what matters, not the query string'); +}); From b122b113b9493fa876f38ae81726003552aace8d Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Sat, 8 Aug 2026 16:09:28 -0400 Subject: [PATCH 2/2] fix: stop signatureOnly() treating a user's inline image as deletable signatureOnly() judged on textContent alone, and an 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) Claude-Session: https://claude.ai/code/session_013rTepGiDUe15iPn6XqmKjy --- src/detect.js | 23 +++++++++++++++++++---- test/detect.test.js | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/src/detect.js b/src/detect.js index ff82af9..0c9277b 100644 --- a/src/detect.js +++ b/src/detect.js @@ -53,8 +53,16 @@ return (w > 0 && w <= 3) || (h > 0 && h <= 3); }; - /** True when this element holds the signature and none of the user's own text. */ + /** True when this element holds the signature and nothing of the user's own. */ const signatureOnly = (el) => { + /* An contributes no textContent, so without this an element wrapping + somebody's inline screenshot next to the signature would read as + signature-only and take the screenshot with it. A real image is never + just a signature, whatever the text around it says. */ + for (const img of el.querySelectorAll('img')) { + if (!looksLikeBeacon(img)) return false; + } + let text = el.textContent || ''; for (const a of el.querySelectorAll('a')) { const label = a.textContent; @@ -88,12 +96,17 @@ return block; }; - /** Move any tracking beacon out of the block before the block is deleted. */ + /** + * Move any tracking beacon out of the block before the block is deleted. + * Parked where the block stood rather than at the end of the body, so a + * signature that was not last does not send the pixel past what followed it. + */ const rescueBeacons = (block, root) => { + const parent = block.parentNode || root; let rescued = 0; for (const img of Array.from(block.querySelectorAll('img'))) { if (!looksLikeBeacon(img)) continue; - root.appendChild(img); + parent.insertBefore(img, block); rescued += 1; } return rescued; @@ -130,8 +143,10 @@ const block = signatureBlock(a, root); if (!block) continue; if (keepUnsubscribe && UNSUB.test(block.textContent || '')) continue; - rescueBeacons(block, root); + /* 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; } diff --git a/test/detect.test.js b/test/detect.test.js index 279b3ed..c996342 100644 --- a/test/detect.test.js +++ b/test/detect.test.js @@ -83,6 +83,50 @@ test('localised signatures are recognised', () => { } }); +test("an inline image beside the signature is not collateral", () => { + const root = compose( + '
See attached
' + + '
' + + 'Sender notified by Mailsuite
', + ); + + assert.equal(detect.scrubRoot(root), 0, 'a block holding a real image is not just a signature'); + assert.equal(root.querySelectorAll('img[src="cid:screenshot.png"]').length, 1); +}); + +test('climbs through nested wrappers to take the whole block', () => { + const root = compose( + '
Body
' + + '
' + + 'Sender notified by Mailsuite' + + '
', + ); + + assert.equal(detect.scrubRoot(root), 1); + assert.equal(root.querySelectorAll('.outer').length, 0, 'the outer wrapper should go too'); + assert.match(root.textContent, /Body/); +}); + +test('the beacon stays where the signature was, not at the end of the body', () => { + const root = compose( + '
Body
' + + '

' + + '
Sender notified by Mailsuite' + + '
' + + '
Trailing quoted thread
', + ); + + assert.equal(detect.scrubRoot(root), 1); + + const kids = Array.from(root.children); + const beacon = kids.findIndex((el) => el.tagName === 'IMG'); + const trailing = kids.findIndex((el) => /Trailing/.test(el.textContent)); + + assert.notEqual(beacon, -1, 'beacon survived'); + assert.ok(beacon < trailing, 'beacon did not jump past the trailing content'); + assert.equal(root.querySelectorAll('br').length, 0, 'blank line still trimmed'); +}); + test('promoAnchor only matches Mailsuite hosts', () => { const root = compose( 'a' +