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
67 changes: 39 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,47 +31,58 @@ 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 `<a>` 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
<div id="mt-signature" contenteditable="false" g_editable="false">
<table data-signature-template="senderNotified" data-signature-version="17">
```

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 `<a>` 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/<hash>.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
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

Expand Down
61 changes: 54 additions & 7 deletions src/detect.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:
*
* <div id="mt-signature" contenteditable="false" g_editable="false">
* <table data-signature-template="senderNotified" data-signature-version="N">
*
* 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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
68 changes: 68 additions & 0 deletions test/detect.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => `
<div id="mt-signature" contenteditable="false" g_editable="false">
<table border="0" cellpadding="8" cellspacing="0" contenteditable="false" g_editable="false"
data-signature-template="senderNotified" data-signature-version="${version}"
style="user-select: none;">
<tr style="display:flex;">
<td style="padding:0 4px 0 0">
<img src="https://s3.amazonaws.com/mailtrack-signature/logo-grey.png" alt="Mailsuite"
class="mt-no-pointer-events" width="24" height="20" g_editable="false">
</td>
<td style="padding:0 10px 0 0">
<span style="color:#333;font-size:12px">Sent with Mailtrack &nbsp;·&nbsp;
<a href="https://mailsuite.com/en/pricing" target="_blank">Mailsuite</a></span>
</td>
<td class="mt-remove-signature-button-container" style="padding:4px 0 0 0"></td>
</tr>
</table>
</div>`;

test('removes the real signature whole, logo and all', () => {
for (const version of [12, 13, 15, 16, 17, 18]) {
const root = compose('<div>Hi, notes below.</div>' + 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(
'<div>Screenshot attached</div>' +
'<div><img src="cid:screenshot.png" width="800" height="600"></div>' +
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(
'</table>',
'</table><img src="https://mailtrack.io/trace/mail/' + 'a'.repeat(40) + '.png" width="1" height="1">',
);
const root = compose('<div>Body</div>' + 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 id="a" href="https://mailsuite.com/x">a</a>' +
Expand Down
Loading