├── .eslintignore ├── .eslintrc.js ├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── FUNDING.yml └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── assets ├── icons │ ├── Icon-16.png │ ├── Icon-48.png │ └── Icon.png └── manifest.json ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── background.ts ├── loader.ts ├── services │ ├── indexeddb.ts │ ├── messenger.ts │ └── trackers.ts ├── uglyemail.ts └── utils │ ├── database.ts │ ├── dom.ts │ └── gmail.ts ├── tests ├── services │ ├── indexeddb.ts │ ├── messenger.ts │ └── trackers.ts └── utils │ ├── database.ts │ ├── dom.ts │ └── gmail.ts ├── tsconfig.json └── vendor └── gmail-js.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/* 2 | build/* 3 | node_modules -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'airbnb-typescript/base' 4 | ], 5 | parserOptions: { 6 | project: './tsconfig.json', 7 | } 8 | }; -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of the level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, age, or religion. 6 | 7 | Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct. 8 | 9 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team. 10 | 11 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 12 | 13 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/) 14 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Ugly Email Contributing Guide 2 | 3 | Hi! I'm really excited that you are interested in contributing to Ugly Email. Before submitting your contribution, please make sure to take a moment and read through the following guidelines: 4 | 5 | - [Code of Conduct](https://github.com/OneClickLab/ugly-email-extension/blob/dev/.github/CODE_OF_CONDUCT.md) 6 | - [Pull Request Guidelines](#pull-request-guidelines) 7 | - [Development Setup](#development-setup) 8 | - [Project Structure](#project-structure) 9 | 10 | ## Pull Request Guidelines 11 | 12 | - The `master` branch is just a snapshot of the latest stable release. All development should be done in dedicated branches. **Do not submit PRs against the `master` branch.** 13 | 14 | - Checkout a topic branch from the relevant branch, e.g. `dev`, and merge back against that branch. 15 | 16 | - Work in the `src` folder and **DO NOT** checkin `dist` in the commits. 17 | 18 | - It's OK to have multiple small commits as you work on the PR - GitHub will automatically squash it before merging. 19 | 20 | - Make sure `npm test` passes. (see [development setup](#development-setup)) 21 | 22 | - If adding a new feature: 23 | - Add accompanying test case. 24 | - Provide a convincing reason to add this feature. Ideally, you should open a suggestion issue first and have it approved before working on it. 25 | 26 | - If fixing bug: 27 | - If you are resolving a special issue, add `(fix #xxxx[,#xxxx])` (#xxxx is the issue id) in your PR title for a better release log, e.g. `update entities encoding/decoding (fix #3899)`. 28 | - Provide a detailed description of the bug in the PR. Live demo preferred. 29 | - Add appropriate test coverage if applicable. 30 | 31 | ## Development Setup 32 | You will need [Node.js](http://nodejs.org) **version 8+**. 33 | 34 | After cloning the repo, run: 35 | 36 | ``` bash 37 | $ npm install # install the dependencies of the project 38 | ``` 39 | 40 | ### Commonly used NPM scripts 41 | 42 | ``` bash 43 | # watch and auto re-build dist directory 44 | $ npm run dev 45 | 46 | # build all dist files, including npm packages 47 | $ npm run build 48 | 49 | # run the full test suite, including linting/type checking 50 | $ npm test 51 | ``` 52 | 53 | There are some other scripts available in the `scripts` section of the `package.json` file. 54 | 55 | The default test script will do the following: lint with ESLint -> unit tests with coverage . **Please make sure to have this pass successfully before submitting a PR.** Although the same tests will be run against your PR on the CI server, it is better to have it working locally. 56 | 57 | ## Project Structure 58 | - **`assets`** - contains assets used for building the final extension packages. 59 | - **`assets/icons`** - contains icons that used only for packaging. 60 | - **`scripts`** - contains build/package related scripts. 61 | - **`src`** - contains the source code. 62 | - **`utils`** - contains utilities shared across the entire codebase. 63 | - **`services`** - contains services shared across the entire codebase. 64 | - **`src`** - contains the test code. 65 | - **`vendor`** - contains the 3rd party vendor related code. 66 | - `vendor/gmail-js.ts` - extending the gmail.js NPM package. 67 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: OneClickLab 4 | custom: ['https://www.paypal.com/donate/?hosted_button_id=JZPWYRGC778U4'] 5 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Lint & Tests 2 | 3 | on: push 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: actions/setup-node@v1 12 | with: 13 | node-version: 10.x 14 | - run: npm ci 15 | - name: Lint & Test 16 | run: npm run test 17 | - name: Codecov 18 | uses: codecov/codecov-action@v1.0.13 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | .cache 21 | dist 22 | 23 | # Dependency directory 24 | # Deployed apps should consider commenting this line out: 25 | # see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git 26 | node_modules 27 | 28 | # Third Party 29 | .DS_Store 30 | .sass-cache 31 | .vscode -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 OneClick Lab 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ugly Email Extension ![Lint & Tests](https://github.com/OneClickLab/ugly-email-extension/workflows/Lint%20&%20Tests/badge.svg) ![GitHub tag (latest SemVer pre-release)](https://img.shields.io/github/v/tag/OneClickLab/ugly-email-extension?include_prereleases) ![GitHub](https://img.shields.io/github/license/OneClickLab/ugly-email-extension) [![codecov](https://codecov.io/gh/OneClickLab/ugly-email-extension/branch/master/graph/badge.svg?token=OZIPPDM0LB)](https://codecov.io/gh/OneClickLab/ugly-email-extension) 2 | Gmail extension for blocking read receipts and other email tracking pixels. 3 | 4 | ## Contributing 5 | The main purpose of this repository is to continue to evolve Ugly Email, making it faster and easier to use. Development of Ugly Email happens in the open on GitHub, and we are grateful to the community for contributing bug fixes and improvements. Read below to learn how you can take part in improving Ugly Email. 6 | 7 | Please make sure to read the [Contributing Guide](https://github.com/OneClickLab/ugly-email-extension/blob/dev/.github/CONTRIBUTING.md) before making a pull request. 8 | -------------------------------------------------------------------------------- /assets/icons/Icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OneClickLab/ugly-email-extension/a3725fc5efe34fe0d17bb8056649b7810136a3a0/assets/icons/Icon-16.png -------------------------------------------------------------------------------- /assets/icons/Icon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OneClickLab/ugly-email-extension/a3725fc5efe34fe0d17bb8056649b7810136a3a0/assets/icons/Icon-48.png -------------------------------------------------------------------------------- /assets/icons/Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OneClickLab/ugly-email-extension/a3725fc5efe34fe0d17bb8056649b7810136a3a0/assets/icons/Icon.png -------------------------------------------------------------------------------- /assets/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Ugly Email", 3 | "short_name": "Ugly Email", 4 | "version": "4.1.2", 5 | "description": "Get Back Your Email Privacy, Block Email Tracking.", 6 | "author": "OneClick Lab", 7 | "homepage_url": "http://uglyemail.com", 8 | "manifest_version": 2, 9 | "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'", 10 | "content_scripts": [ 11 | { 12 | "matches": ["https://mail.google.com/*"], 13 | "js": ["loader.js"], 14 | "run_at": "document_end" 15 | } 16 | ], 17 | "web_accessible_resources": [ 18 | "uglyemail.js" 19 | ], 20 | "permissions": [ 21 | "webRequest", 22 | "webRequestBlocking", 23 | "https://mail.google.com/*", 24 | "*://*.googleusercontent.com/proxy/*" 25 | ], 26 | "background": { 27 | "scripts": ["background.js"], 28 | "persistent": true 29 | }, 30 | "icons": { 31 | "16": "icons/Icon-16.png", 32 | "48": "icons/Icon-48.png", 33 | "128": "icons/Icon.png" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | collectCoverage: true, 4 | testMatch: ['/tests/**/*.ts'], 5 | roots: ['/src', '/tests'], 6 | transform: { 7 | "^.+\\.ts?$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ugly-email-extension", 3 | "version": "4.1.2", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "clean": "rm -R dist", 8 | "build": "parcel build src/uglyemail.ts src/background.ts src/loader.ts", 9 | "dev": "parcel watch src/uglyemail.ts src/background.ts src/loader.ts", 10 | "dev:test": "npx jest --watch --passWithNoTests", 11 | "test": "npm run lint && npm run test:unit", 12 | "test:unit": "jest --passWithNoTests", 13 | "lint": "eslint . --ext .ts" 14 | }, 15 | "staticFiles": { 16 | "staticPath": "assets", 17 | "watcherGlob": "**" 18 | }, 19 | "browserslist": [ 20 | "last 2 Chrome versions", 21 | "last 2 Firefox versions" 22 | ], 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/OneClickLab/ugly-email-extension.git" 26 | }, 27 | "author": "Sonny T.", 28 | "license": "ISC", 29 | "bugs": { 30 | "url": "https://github.com/OneClickLab/ugly-email-extension/issues" 31 | }, 32 | "homepage": "https://uglyemail.com", 33 | "dependencies": { 34 | "gmail-js": "^1.1.12", 35 | "jquery": "^4.0.0-beta" 36 | }, 37 | "devDependencies": { 38 | "@types/chrome": "^0.0.262", 39 | "@types/jest": "^26.0.19", 40 | "@types/jquery": "^3.5.29", 41 | "@typescript-eslint/eslint-plugin": "^4.9.1", 42 | "eslint": "^7.15.0", 43 | "eslint-config-airbnb-typescript": "^12.0.0", 44 | "eslint-plugin-import": "^2.22.1", 45 | "fake-indexeddb": "^3.1.2", 46 | "jest": "^26.6.3", 47 | "jest-fetch-mock": "^3.0.3", 48 | "parcel-bundler": "^1.12.4", 49 | "parcel-plugin-static-files-copy": "^2.5.0", 50 | "ts-jest": "^26.4.4", 51 | "typescript": "^4.1.3" 52 | }, 53 | "overrides": { 54 | "gmail-js": { 55 | "jquery": "^4.0.0-beta" 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/background.ts: -------------------------------------------------------------------------------- 1 | import trackers from './services/trackers'; 2 | 3 | (async () => { 4 | await trackers.init(); 5 | 6 | type RequestDetails = { 7 | url: string 8 | }; 9 | 10 | chrome.webRequest.onBeforeRequest.addListener((details: RequestDetails) => { 11 | const pixel = trackers.match(details.url); 12 | return { cancel: !!pixel }; 13 | }, { 14 | urls: ['*://*.googleusercontent.com/*'], 15 | types: ['image'], 16 | }, ['blocking']); 17 | 18 | chrome.runtime.onConnect.addListener((port: any) => { 19 | port.onMessage.addListener((data: { id: string, body: string }) => { 20 | const pixel = trackers.match(data.body); 21 | port.postMessage({ pixel, id: data.id }); 22 | }); 23 | }); 24 | })(); 25 | -------------------------------------------------------------------------------- /src/loader.ts: -------------------------------------------------------------------------------- 1 | const u = document.createElement('script'); 2 | u.src = chrome.extension.getURL('uglyemail.js'); 3 | 4 | (document.head || document.documentElement).appendChild(u); 5 | 6 | const connection = chrome.runtime.connect({ name: 'ugly-email' }); 7 | 8 | connection.onMessage.addListener((message: { id: string, pixel: string }) => { 9 | window.postMessage({ ...message, from: 'ugly-email-response' }, window.origin); 10 | }); 11 | 12 | window.addEventListener('message', ({ data }) => { 13 | if (data.from && data.from === 'ugly-email-check') { 14 | connection.postMessage(data); 15 | } 16 | }); 17 | 18 | const s = document.createElement('style'); 19 | s.appendChild(document.createTextNode(` 20 | .J-J5-Ji .ugly-email-track-icon { 21 | height: 18px; 22 | width: 18px; 23 | margin-top: 4px; 24 | margin-right: 8px; 25 | } 26 | 27 | .ugly-email-track-icon { 28 | text-align: center; 29 | line-height: 22px; 30 | background: white; 31 | padding: 0 1px; 32 | border-radius: 100%; 33 | height: 16px; 34 | width: 16px; 35 | float: left; 36 | margin-right: 5px; 37 | position: relative; 38 | } 39 | `)); 40 | 41 | (document.head || document.documentElement).appendChild(s); 42 | -------------------------------------------------------------------------------- /src/services/indexeddb.ts: -------------------------------------------------------------------------------- 1 | export class IndexedDB { 2 | name = 'ugly-email'; 3 | 4 | version = 1; 5 | 6 | db: IDBDatabase; 7 | 8 | findByKey(table: string, key: string): Promise { 9 | return new Promise((resolve, reject) => { 10 | const request = this.store(table).get(key); 11 | 12 | request.onerror = reject; 13 | request.onsuccess = () => resolve(request.result); 14 | }); 15 | } 16 | 17 | find(table: string, query: any): Promise { 18 | return new Promise((resolve, reject) => { 19 | const request = this.store(table).getAll(query); 20 | 21 | request.onerror = reject; 22 | request.onsuccess = ({ target }: any) => resolve(target.result); 23 | }); 24 | } 25 | 26 | create(table: string, data: any = {}): Promise { 27 | return new Promise((resolve, reject) => { 28 | const request = this.store(table).add(data); 29 | 30 | request.onerror = reject; 31 | request.onsuccess = () => resolve(request.result); 32 | }); 33 | } 34 | 35 | async updateByKey(table: string, key: string, data: any = {}): Promise { 36 | const record = await this.findByKey(table, key); 37 | 38 | return new Promise((resolve, reject) => { 39 | const request = this.store(table).put({ ...record, ...data }); 40 | request.onerror = reject; 41 | request.onsuccess = () => resolve(request.result); 42 | }); 43 | } 44 | 45 | removeByKey(table: string, key: string): Promise { 46 | return new Promise((resolve, reject) => { 47 | const request = this.store(table).delete(key); 48 | 49 | request.onerror = reject; 50 | request.onsuccess = resolve; 51 | }); 52 | } 53 | 54 | init(): Promise { 55 | return new Promise((resolve, reject) => { 56 | const request = indexedDB.open(this.name, this.version); 57 | 58 | request.onerror = reject; 59 | 60 | request.onsuccess = () => { 61 | this.db = request.result; 62 | resolve(); 63 | }; 64 | 65 | request.onupgradeneeded = async () => { 66 | this.db = request.result; 67 | await this.upgrade(); 68 | resolve(); 69 | }; 70 | }); 71 | } 72 | 73 | private upgrade(): Promise { 74 | const createEmails = () => new Promise((resolve) => { 75 | const store = this.db.createObjectStore('emails', { keyPath: 'id' }); 76 | 77 | store.createIndex('tracker', 'value', { unique: false }); 78 | 79 | store.transaction.oncomplete = resolve; 80 | }); 81 | 82 | const createMeta = () => new Promise((resolve) => { 83 | const store = this.db.createObjectStore('meta', { keyPath: 'key' }); 84 | store.transaction.oncomplete = resolve; 85 | }); 86 | 87 | return Promise.all([createEmails(), createMeta()]); 88 | } 89 | 90 | private store(table: string, role: IDBTransactionMode = 'readwrite'): IDBObjectStore { 91 | return this.db.transaction([table], role).objectStore(table); 92 | } 93 | } 94 | 95 | export default new IndexedDB(); 96 | -------------------------------------------------------------------------------- /src/services/messenger.ts: -------------------------------------------------------------------------------- 1 | type Resolver = { 2 | [id: string]: (val: string | null) => void 3 | }; 4 | 5 | export class UglyMessenger { 6 | private resolvers: Resolver = {}; 7 | 8 | constructor() { 9 | window.addEventListener('message', ({ data }) => { 10 | if (data && data.from && data.from === 'ugly-email-response') { 11 | this.resolvers[data.id].call(this, data.pixel); 12 | } 13 | }); 14 | } 15 | 16 | postMessage(id: string, body: string): Promise { 17 | window.postMessage({ id, body, from: 'ugly-email-check' }, window.origin); 18 | 19 | return new Promise((resolve) => { 20 | this.resolvers[id] = resolve; 21 | }); 22 | } 23 | } 24 | 25 | export default new UglyMessenger(); 26 | -------------------------------------------------------------------------------- /src/services/trackers.ts: -------------------------------------------------------------------------------- 1 | export class Trackers { 2 | version: number; 3 | 4 | identifiers: string[] = []; 5 | 6 | pixels = new Map(); 7 | 8 | async init() { 9 | const trackers = await Trackers.fetchTrackers(); 10 | 11 | this.version = await Trackers.fetchVersion(); 12 | 13 | trackers.forEach(({ name, pattern }) => { 14 | this.identifiers.push(pattern); 15 | this.pixels.set(pattern, name); 16 | }); 17 | } 18 | 19 | static async fetchTrackers(): Promise<{ name: string, pattern: string }[]> { 20 | const response = await fetch(`https://trackers.uglyemail.com/list.txt?ts=${new Date().getTime()}`); 21 | const text = await response.text(); 22 | return text.split('\n').map((row) => { 23 | const [name, pattern] = row.split('@@='); 24 | return { name, pattern }; 25 | }); 26 | } 27 | 28 | static async fetchVersion(): Promise { 29 | const response = await fetch(`https://trackers.uglyemail.com/version.txt?ts=${new Date().getTime()}`); 30 | const text = await response.text(); 31 | return parseInt(text, 10); 32 | } 33 | 34 | match(body: string): string | null { 35 | const pixel = this.identifiers.find((p) => new RegExp(p, 'gi').test(body)); 36 | return pixel ? this.pixels.get(pixel) : null; 37 | } 38 | } 39 | 40 | export default new Trackers(); 41 | -------------------------------------------------------------------------------- /src/uglyemail.ts: -------------------------------------------------------------------------------- 1 | import Gmailjs from '../vendor/gmail-js'; 2 | import * as gmail from './utils/dom'; 3 | import * as database from './utils/database'; 4 | import indexedDB from './services/indexeddb'; 5 | import trackers from './services/trackers'; 6 | import './services/messenger'; 7 | 8 | (async () => { 9 | await Promise.all([ 10 | indexedDB.init(), 11 | trackers.init(), 12 | ]); 13 | 14 | const currentVersion = await database.getCurrentVersion(); 15 | 16 | if (!currentVersion) { // first time setup 17 | await database.setup(trackers.version); 18 | } else if (currentVersion !== trackers.version) { 19 | await database.upgrade(trackers.version); 20 | await database.flushUntracked(); 21 | } 22 | 23 | /** 24 | * Runs every 2500ms 25 | */ 26 | let timer: any; 27 | 28 | async function observe() { 29 | clearTimeout(timer); 30 | 31 | if (Gmailjs.check.is_inside_email()) { 32 | await gmail.checkThread(); 33 | } else { 34 | await gmail.checkList(); 35 | } 36 | 37 | timer = setTimeout(observe, 2500); 38 | } 39 | 40 | Gmailjs.observe.on('load', observe); 41 | })(); 42 | -------------------------------------------------------------------------------- /src/utils/database.ts: -------------------------------------------------------------------------------- 1 | import indexedDB from '../services/indexeddb'; 2 | 3 | type Email = { 4 | id: string 5 | value: string | null 6 | }; 7 | 8 | export async function setup(version: number):Promise { 9 | await indexedDB.create('meta', { key: 'version', value: version }); 10 | await indexedDB.create('meta', { key: 'updated-on', value: new Date().toString() }); 11 | } 12 | 13 | export async function upgrade(version: number):Promise { 14 | await indexedDB.updateByKey('meta', 'version', { value: version }); 15 | await indexedDB.updateByKey('meta', 'updated-on', { value: new Date().toString() }); 16 | } 17 | 18 | export async function getCurrentVersion():Promise { 19 | const record = await indexedDB.findByKey('meta', 'version'); 20 | return record ? record.value : null; 21 | } 22 | 23 | export function createEmail(id: string, tracker?: string | null): Promise { 24 | return indexedDB.create('emails', { id, value: tracker }); 25 | } 26 | 27 | export async function findEmailById(id: string): Promise { 28 | const record = await indexedDB.findByKey('emails', id); 29 | return record; 30 | } 31 | 32 | export async function findAllEmails(): Promise { 33 | const emails = await indexedDB.find('emails', null); 34 | return emails || []; 35 | } 36 | 37 | export async function flushUntracked() { 38 | const emails = await findAllEmails(); 39 | 40 | // loop through each email in the db and remove the ones that are not tracked. 41 | const removedEmails = emails.reduce((arr: Array>, email: any) => { 42 | if (!email.value) { 43 | arr.push(indexedDB.removeByKey('emails', email.id)); 44 | } 45 | 46 | return arr; 47 | }, []); 48 | 49 | return Promise.all(removedEmails); 50 | } 51 | -------------------------------------------------------------------------------- /src/utils/dom.ts: -------------------------------------------------------------------------------- 1 | import { findTracker } from './gmail'; 2 | 3 | function getListOfEmails() { 4 | const elements = document.querySelectorAll('.bog span:not([data-ugly-checked="yes"]'); 5 | return Array.from(elements); 6 | } 7 | 8 | function getThread() { 9 | return document.querySelector('h2.hP:not([data-ugly-checked="yes"]'); 10 | } 11 | 12 | function markElementUgly(element: Element, tracker: string): void { 13 | const icon = element.querySelector('img.ugly-email-track-icon'); 14 | 15 | // if icon is already set, no need to do it again. 16 | if (icon) { 17 | return; 18 | } 19 | 20 | // create image element 21 | const img = document.createElement('img'); 22 | 23 | img.src = 'data:image/svg+xml;utf8,'; 24 | img.className = 'ugly-email-track-icon'; 25 | img.dataset.tooltip = tracker; 26 | 27 | element.prepend(img); 28 | } 29 | 30 | function markListItemUgly(element: HTMLSpanElement, tracker: string): void { 31 | const parent = element.closest('.xT'); 32 | 33 | if (parent) { 34 | markElementUgly(parent, tracker); 35 | } 36 | } 37 | 38 | function markThreadUgly(element: HTMLHeadElement, tracker: string): void { 39 | const parent = element.nextElementSibling; 40 | 41 | if (parent) { 42 | markElementUgly(parent, tracker); 43 | } 44 | } 45 | 46 | export async function checkThread(): Promise { 47 | const email = getThread(); 48 | 49 | if (email) { 50 | const id = email.dataset.legacyThreadId; 51 | 52 | if (id) { 53 | const tracker = await findTracker(id); 54 | 55 | if (tracker) { 56 | markThreadUgly(email, tracker); 57 | } 58 | } 59 | 60 | // mark checked 61 | email.dataset.uglyChecked = 'yes'; // eslint-disable-line no-param-reassign 62 | } 63 | } 64 | 65 | export function checkList(): Promise { 66 | const emails = getListOfEmails(); 67 | 68 | const checkedEmails = emails.map(async (email) => { 69 | const id = email.dataset.legacyThreadId; 70 | 71 | if (id) { 72 | const tracker = await findTracker(id); 73 | 74 | if (tracker) { 75 | markListItemUgly(email, tracker); 76 | } 77 | } 78 | 79 | // mark checked 80 | email.dataset.uglyChecked = 'yes'; // eslint-disable-line no-param-reassign 81 | }); 82 | 83 | return Promise.all(checkedEmails); 84 | } 85 | -------------------------------------------------------------------------------- /src/utils/gmail.ts: -------------------------------------------------------------------------------- 1 | import Gmail from '../../vendor/gmail-js'; 2 | import messenger from '../services/messenger'; 3 | import { findEmailById, createEmail } from './database'; 4 | 5 | /** 6 | * Fetch email thread by id 7 | * it will try 3 times before giving up. 8 | */ 9 | export function fetchEmailById(id: string): Promise { 10 | return new Promise((resolve, reject) => { 11 | let count = 0; 12 | let timer: NodeJS.Timeout; 13 | 14 | // fetch data 15 | const fetchData = () => { 16 | count += 1; 17 | 18 | Gmail.get.email_data_async(id, (email: any) => { 19 | clearTimeout(timer); 20 | 21 | if (email.thread_id) { 22 | const last = email.last_email; 23 | const thread = email.threads[last]; 24 | 25 | return resolve(thread); 26 | } 27 | 28 | // if thread is not there, try again. 29 | if (count < 3) { 30 | timer = setTimeout(fetchData, 1000); 31 | return null; 32 | } 33 | 34 | return reject(); 35 | }); 36 | }; 37 | 38 | // start 39 | fetchData(); 40 | }); 41 | } 42 | 43 | export async function findTracker(id: string): Promise { 44 | const record = await findEmailById(id); 45 | 46 | if (record) { 47 | return record.value; 48 | } 49 | 50 | try { 51 | // fetch email 52 | const email = await fetchEmailById(id); 53 | 54 | // check if email is tracked 55 | const tracker = await messenger.postMessage(id, email.content_html); 56 | 57 | // create a new record 58 | await createEmail(id, tracker); 59 | 60 | return tracker; 61 | } catch { 62 | return null; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /tests/services/indexeddb.ts: -------------------------------------------------------------------------------- 1 | import 'fake-indexeddb/auto'; 2 | import instance, { IndexedDB } from '../../src/services/indexeddb'; 3 | 4 | describe('IndexedDB service', () => { 5 | it('exports instance by default', () => { 6 | expect(instance).toBeInstanceOf(IndexedDB); 7 | expect(instance.name).toEqual('ugly-email'); 8 | expect(instance.version).toEqual(1); 9 | }); 10 | 11 | it('sucessfully opens connection', async () => { 12 | expect(instance.db).toBeUndefined(); 13 | 14 | await instance.init(); 15 | 16 | expect(instance.db).not.toBeNull(); 17 | }); 18 | 19 | it('creates record', async () => { 20 | const record = await instance.create('emails', { 21 | id: '12345', 22 | value: 'testing', 23 | }); 24 | 25 | expect(record).toEqual('12345'); 26 | }); 27 | 28 | it('finds record by key', async () => { 29 | const record = await instance.findByKey('emails', '12345'); 30 | expect(record).toEqual({ id: '12345', value: 'testing' }); 31 | }); 32 | 33 | it('removes by key', async () => { 34 | const firstTest = await instance.findByKey('emails', '12345'); 35 | expect(firstTest).toBeDefined(); 36 | 37 | await instance.removeByKey('emails', '12345'); 38 | 39 | const secondTest = await instance.findByKey('emails', '12345'); 40 | expect(secondTest).toBeUndefined(); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /tests/services/messenger.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/dot-notation */ 2 | import messengerInstance, { UglyMessenger } from '../../src/services/messenger'; 3 | 4 | describe('Worker service', () => { 5 | it('exports instance by default', () => { 6 | expect(messengerInstance).toBeInstanceOf(UglyMessenger); 7 | }); 8 | 9 | it('sends a message', () => { 10 | const postMessage = jest.spyOn(window, 'postMessage'); 11 | 12 | expect(messengerInstance['resolvers']).toMatchObject({}); 13 | 14 | messengerInstance.postMessage('12345', '
'); 15 | 16 | expect(messengerInstance['resolvers']['12345']).toBeDefined(); 17 | 18 | expect(postMessage).toBeCalledWith({ 19 | id: '12345', 20 | body: '
', 21 | from: 'ugly-email-check', 22 | }, 'http://localhost'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /tests/services/trackers.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-useless-escape */ 2 | import fetchMock from 'jest-fetch-mock'; 3 | import trackerInstance, { Trackers } from '../../src/services/trackers'; 4 | 5 | describe('Trackers service', () => { 6 | afterAll(() => { 7 | jest.resetAllMocks(); 8 | }); 9 | 10 | it('exports instance by default', () => { 11 | expect(trackerInstance).toBeInstanceOf(Trackers); 12 | }); 13 | 14 | it('sucessfully initializes', async () => { 15 | const version = jest.spyOn(Trackers, 'fetchVersion').mockResolvedValueOnce(5); 16 | const trackers = jest.spyOn(Trackers, 'fetchTrackers').mockResolvedValueOnce([{ 17 | name: 'SendGrid', pattern: '\/wf\/open\\?upn=', 18 | }]); 19 | 20 | expect(trackerInstance.version).toBeUndefined(); 21 | expect(trackerInstance.identifiers).toEqual([]); 22 | expect(trackerInstance.pixels).toEqual(new Map()); 23 | 24 | await trackerInstance.init(); 25 | 26 | expect(trackers).toHaveBeenCalled(); 27 | expect(version).toHaveBeenCalled(); 28 | 29 | expect(trackerInstance.version).toEqual(5); 30 | expect(trackerInstance.identifiers.length).toEqual(1); 31 | expect(trackerInstance.identifiers).toEqual(['\/wf\/open\\?upn=']); 32 | expect(trackerInstance.pixels.get('\/wf\/open\\?upn=')).toEqual('SendGrid'); 33 | }); 34 | 35 | it('fetches pixels and formats them', async () => { 36 | jest.spyOn(Date.prototype, 'getTime').mockImplementationOnce(() => 12345); 37 | 38 | fetchMock.enableMocks(); 39 | const fetch = fetchMock.mockResponseOnce('SendGrid@@=\/wf\/open\?upn=\nMailChimp@@=\/track\/open\.php\?u='); 40 | 41 | const response = await Trackers.fetchTrackers(); 42 | 43 | expect(fetch).toHaveBeenCalled(); 44 | expect(fetch).toBeCalledWith('https://trackers.uglyemail.com/list.txt?ts=12345'); 45 | expect(response).toEqual([ 46 | { name: 'SendGrid', pattern: '/wf/open?upn=' }, 47 | { name: 'MailChimp', pattern: '/track/open.php?u=' }, 48 | ]); 49 | }); 50 | 51 | it('fetches version and formats them', async () => { 52 | jest.spyOn(Date.prototype, 'getTime').mockImplementationOnce(() => 12345); 53 | 54 | fetchMock.enableMocks(); 55 | const fetch = fetchMock.mockResponseOnce('5'); 56 | 57 | const response = await Trackers.fetchVersion(); 58 | 59 | expect(fetch).toHaveBeenCalled(); 60 | expect(fetch).toBeCalledWith('https://trackers.uglyemail.com/version.txt?ts=12345'); 61 | expect(response).toEqual(5); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /tests/utils/database.ts: -------------------------------------------------------------------------------- 1 | import 'fake-indexeddb/auto'; 2 | import * as database from '../../src/utils/database'; 3 | import indexeddb from '../../src/services/indexeddb'; 4 | 5 | describe('database util', () => { 6 | it('setups the version', async () => { 7 | await indexeddb.init(); 8 | 9 | const testOne = await database.getCurrentVersion(); 10 | expect(testOne).toBeNull(); 11 | 12 | await database.setup(5); 13 | 14 | const testTwo = await database.getCurrentVersion(); 15 | expect(testTwo).toEqual(5); 16 | }); 17 | 18 | it('upgrades the version', async () => { 19 | const testOne = await database.getCurrentVersion(); 20 | expect(testOne).toEqual(5); 21 | 22 | await database.upgrade(7); 23 | 24 | const testTwo = await database.getCurrentVersion(); 25 | expect(testTwo).toEqual(7); 26 | }); 27 | 28 | it('stores email', async () => { 29 | await Promise.all([ 30 | database.createEmail('54321', 'MailChimp'), 31 | database.createEmail('12345', 'SendGrid'), 32 | database.createEmail('33333'), 33 | ]); 34 | 35 | expect(true).toBeTruthy(); 36 | }); 37 | 38 | it('finds email by id', async () => { 39 | const email = await database.findEmailById('12345'); 40 | expect(email).toEqual({ id: '12345', value: 'SendGrid' }); 41 | }); 42 | 43 | it('finds all emails', async () => { 44 | const emails = await database.findAllEmails(); 45 | expect(emails.length).toEqual(3); 46 | expect(emails).toEqual([ 47 | { id: '12345', value: 'SendGrid' }, 48 | { id: '33333' }, 49 | { id: '54321', value: 'MailChimp' }, 50 | ]); 51 | }); 52 | 53 | it('flushes all untracked emails', async () => { 54 | const testOne = await database.findAllEmails(); 55 | expect(testOne.length).toEqual(3); 56 | 57 | await database.flushUntracked(); 58 | 59 | const testTwo = await database.findAllEmails(); 60 | expect(testTwo.length).toEqual(2); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /tests/utils/dom.ts: -------------------------------------------------------------------------------- 1 | import * as dom from '../../src/utils/dom'; 2 | import * as gmail from '../../src/utils/gmail'; 3 | 4 | jest.mock('../../vendor/gmail-js', () => ({})); 5 | 6 | describe('dom util', () => { 7 | it('marks list elements ugly', async () => { 8 | jest 9 | .spyOn(gmail, 'findTracker') 10 | .mockResolvedValueOnce('SendGrid') 11 | .mockResolvedValueOnce(null); 12 | 13 | document.body.innerHTML = ` 14 |
15 |

16 | 17 |

18 |

19 | 20 |

21 |

22 |
23 | 24 |
25 |

26 |

27 | 28 |

29 |

30 | 31 |

32 |
33 | `; 34 | 35 | await dom.checkList(); 36 | 37 | const icon = document.body.querySelector('.ugly-email-track-icon'); 38 | 39 | if (icon) { 40 | expect(icon).toBeDefined(); 41 | expect(icon.dataset.tooltip).toEqual('SendGrid'); 42 | } 43 | }); 44 | 45 | it('marks thread ugly', async () => { 46 | jest.spyOn(gmail, 'findTracker').mockResolvedValueOnce('MailChimp'); 47 | 48 | document.body.innerHTML = ` 49 |
50 |
51 |
52 |

53 |
54 |
55 | `; 56 | 57 | await dom.checkThread(); 58 | 59 | const icon = document.body.querySelector('.ugly-email-track-icon'); 60 | 61 | if (icon) { 62 | expect(icon).toBeDefined(); 63 | expect(icon.dataset.tooltip).toEqual('MailChimp'); 64 | } 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /tests/utils/gmail.ts: -------------------------------------------------------------------------------- 1 | import * as gmail from '../../src/utils/gmail'; 2 | import * as database from '../../src/utils/database'; 3 | 4 | jest.mock('../../vendor/gmail-js', () => ({})); 5 | 6 | describe('gmail util', () => { 7 | it('finds a tracker by id', async () => { 8 | const findEmailById = jest.spyOn(database, 'findEmailById').mockResolvedValue({ 9 | id: '12345', 10 | value: 'SendGrid', 11 | }); 12 | 13 | const tracker = await gmail.findTracker('12345'); 14 | expect(tracker).toEqual('SendGrid'); 15 | expect(findEmailById).toBeCalledWith('12345'); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "sourceMap": true, 5 | "strict": true, 6 | "noImplicitReturns": true, 7 | "noImplicitAny": true, 8 | "strictPropertyInitialization": false, 9 | "module": "es6", 10 | "moduleResolution": "node", 11 | "target": "ES6", 12 | "allowJs": true, 13 | "esModuleInterop": true 14 | } 15 | } -------------------------------------------------------------------------------- /vendor/gmail-js.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * gmail.js 3 | */ 4 | import jQuery from 'jquery'; 5 | 6 | const service = require('gmail-js'); 7 | 8 | /** 9 | * Override the jQuery object 10 | */ 11 | 12 | jQuery.isArray = Array.isArray; 13 | 14 | if ((window as any).trustedTypes && (window as any).trustedTypes.createPolicy) { 15 | const htmlPrefilter = (window as any).trustedTypes.createPolicy('myEscapePolicy', { 16 | createHTML: (string: string) => string.replace(/ { 33 | let result = {}; 34 | 35 | try { 36 | result = SuperEmailDataPost(data); 37 | } catch { 38 | // SHHHHH! 39 | } 40 | 41 | return result; 42 | }; 43 | 44 | export default Gmail; 45 | --------------------------------------------------------------------------------