Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules/
package-lock.json
*.zip
.DS_Store
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
13 changes: 13 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
168 changes: 168 additions & 0 deletions src/detect.js
Original file line number Diff line number Diff line change
@@ -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 <img> 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
* <a> 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,
};
});
125 changes: 9 additions & 116 deletions src/strip.js
Original file line number Diff line number Diff line change
@@ -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 = [
Expand All @@ -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 <a>. 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 });
Expand All @@ -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 = () => {
Expand All @@ -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;
Expand Down
Loading
Loading