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..0c9277b --- /dev/null +++ b/src/detect.js @@ -0,0 +1,168 @@ +/* + * 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 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; + 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. + * 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; + parent.insertBefore(img, block); + 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; + /* 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; + } + 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..c996342 --- /dev/null +++ b/test/detect.test.js @@ -0,0 +1,143 @@ +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("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' + + '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'); +});