├── .github ├── funding.yml └── workflows │ ├── playwright.yml │ ├── release.yml │ └── lint-test.yml ├── .gitleaks.toml ├── src ├── vite-env.d.ts ├── presentation │ ├── apps │ │ ├── tutorial │ │ │ ├── assets │ │ │ │ ├── top-senders.gif │ │ │ │ ├── unsubscribe.gif │ │ │ │ └── extension-button.png │ │ │ ├── components │ │ │ │ ├── modal.tsx │ │ │ │ ├── modal.css │ │ │ │ ├── successIcon.tsx │ │ │ │ └── steps.tsx │ │ │ ├── main.tsx │ │ │ ├── index.css │ │ │ ├── index.html │ │ │ ├── Tutorial.tsx │ │ │ └── Tutorial.css │ │ ├── popup │ │ │ ├── main.tsx │ │ │ ├── index.html │ │ │ ├── Popup.css │ │ │ └── Popup.tsx │ │ └── sidebar │ │ │ ├── components │ │ │ ├── loadingBar.css │ │ │ ├── reloadButton.css │ │ │ ├── searchBar.css │ │ │ ├── toggleOption.tsx │ │ │ ├── searchBar.tsx │ │ │ ├── header.css │ │ │ ├── toggleSwitch.tsx │ │ │ ├── reloadButton.tsx │ │ │ ├── actionButton.css │ │ │ ├── senderLineSkeleton.tsx │ │ │ ├── emptySenders.tsx │ │ │ ├── themeToggle.tsx │ │ │ ├── themeToggle.css │ │ │ ├── header.tsx │ │ │ ├── loadingBar.tsx │ │ │ ├── emptySenders.css │ │ │ ├── senderLine.css │ │ │ ├── searchInput.css │ │ │ ├── searchInput.tsx │ │ │ ├── toggle.css │ │ │ ├── fetchProgress.tsx │ │ │ ├── sendersContainer.tsx │ │ │ ├── actionButton.tsx │ │ │ ├── senderLine.tsx │ │ │ ├── modalPopup.css │ │ │ ├── fetchProgress.css │ │ │ └── modalPopup.tsx │ │ │ ├── main.tsx │ │ │ ├── index.html │ │ │ ├── providers │ │ │ └── modalContext.tsx │ │ │ ├── assets │ │ │ └── logo.svg │ │ │ ├── App.tsx │ │ │ ├── utils │ │ │ └── unsubscribeFlow.tsx │ │ │ └── App.css │ └── providers │ │ ├── theme_context.ts │ │ ├── theme_provider.tsx │ │ └── app_provider.tsx ├── domain │ ├── types │ │ └── progress.ts │ ├── entities │ │ └── sender.ts │ └── repositories │ │ ├── page_interaction_repo.ts │ │ ├── storage_repo.ts │ │ └── email_repo.ts ├── index.css ├── data │ ├── repositories │ │ ├── mocks │ │ │ ├── mock_page_interaction_repo.ts │ │ │ ├── mock_storage_repo.ts │ │ │ └── mock_email_repo.ts │ │ ├── chrome_page_interaction_repo.ts │ │ ├── chrome_local_storage_repo.ts │ │ └── browser_email_repo.ts │ ├── ports │ │ └── port_manager.ts │ ├── services │ │ ├── page_interaction_service.ts │ │ └── browser_email_service.ts │ └── content_scripts │ │ └── content.ts └── index.html ├── extras ├── logo.png ├── promo_tile.png ├── cws_screenshots │ ├── 1.png │ ├── 2.png │ ├── Screenshot 2025-05-05 193116.png │ ├── Screenshot 2025-05-05 194352.png │ └── Screenshot 2025-05-05 194830.png └── logo.svg ├── .jscpd.json ├── public ├── images │ ├── icon-128.png │ ├── icon-16.png │ ├── icon-32.png │ └── icon-48.png ├── assets │ ├── top-senders.gif │ ├── unsubscribe.gif │ ├── extension-button.png │ └── logo.svg ├── manifest.json └── background.js ├── jest.config.json ├── docs └── architecture_structure.jpg ├── stylelint.config.js ├── tsconfig.json ├── .gitignore ├── tsconfig.node.json ├── tsconfig.app.json ├── eslint.config.js ├── playwright.config.ts ├── package.json ├── vite.config.ts ├── test └── ui │ └── sidebar │ ├── delete.spec.ts │ ├── helpers.ts │ ├── senderManagement.spec.ts │ ├── progressiveLoading.spec.ts │ ├── search.spec.ts │ └── unsubscribe.spec.ts ├── README.md ├── CONTRIBUTING.md └── project-managment └── epics.md /.github/funding.yml: -------------------------------------------------------------------------------- 1 | buy_me_a_coffee: inboxwhiz 2 | -------------------------------------------------------------------------------- /.gitleaks.toml: -------------------------------------------------------------------------------- 1 | [allowlist] 2 | paths = ["manifest.json"] -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /extras/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InboxWhiz/gmail-declutter-extension/HEAD/extras/logo.png -------------------------------------------------------------------------------- /.jscpd.json: -------------------------------------------------------------------------------- 1 | { 2 | "threshold": 1, 3 | "ignore": ["**/dist/**", "**/node_modules/**", "**.html"] 4 | } 5 | -------------------------------------------------------------------------------- /extras/promo_tile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InboxWhiz/gmail-declutter-extension/HEAD/extras/promo_tile.png -------------------------------------------------------------------------------- /public/images/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InboxWhiz/gmail-declutter-extension/HEAD/public/images/icon-128.png -------------------------------------------------------------------------------- /public/images/icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InboxWhiz/gmail-declutter-extension/HEAD/public/images/icon-16.png -------------------------------------------------------------------------------- /public/images/icon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InboxWhiz/gmail-declutter-extension/HEAD/public/images/icon-32.png -------------------------------------------------------------------------------- /public/images/icon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InboxWhiz/gmail-declutter-extension/HEAD/public/images/icon-48.png -------------------------------------------------------------------------------- /extras/cws_screenshots/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InboxWhiz/gmail-declutter-extension/HEAD/extras/cws_screenshots/1.png -------------------------------------------------------------------------------- /extras/cws_screenshots/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InboxWhiz/gmail-declutter-extension/HEAD/extras/cws_screenshots/2.png -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "ts-jest", 3 | "testEnvironment": "node", 4 | "testMatch": ["**/*.test.ts"] 5 | } 6 | -------------------------------------------------------------------------------- /docs/architecture_structure.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InboxWhiz/gmail-declutter-extension/HEAD/docs/architecture_structure.jpg -------------------------------------------------------------------------------- /public/assets/top-senders.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InboxWhiz/gmail-declutter-extension/HEAD/public/assets/top-senders.gif -------------------------------------------------------------------------------- /public/assets/unsubscribe.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InboxWhiz/gmail-declutter-extension/HEAD/public/assets/unsubscribe.gif -------------------------------------------------------------------------------- /stylelint.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | extends: ["stylelint-config-standard"], 3 | ignoreFiles: ["dist/**/*.css"], 4 | }; 5 | -------------------------------------------------------------------------------- /public/assets/extension-button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InboxWhiz/gmail-declutter-extension/HEAD/public/assets/extension-button.png -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /src/presentation/apps/tutorial/assets/top-senders.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InboxWhiz/gmail-declutter-extension/HEAD/src/presentation/apps/tutorial/assets/top-senders.gif -------------------------------------------------------------------------------- /src/presentation/apps/tutorial/assets/unsubscribe.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InboxWhiz/gmail-declutter-extension/HEAD/src/presentation/apps/tutorial/assets/unsubscribe.gif -------------------------------------------------------------------------------- /extras/cws_screenshots/Screenshot 2025-05-05 193116.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InboxWhiz/gmail-declutter-extension/HEAD/extras/cws_screenshots/Screenshot 2025-05-05 193116.png -------------------------------------------------------------------------------- /extras/cws_screenshots/Screenshot 2025-05-05 194352.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InboxWhiz/gmail-declutter-extension/HEAD/extras/cws_screenshots/Screenshot 2025-05-05 194352.png -------------------------------------------------------------------------------- /extras/cws_screenshots/Screenshot 2025-05-05 194830.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InboxWhiz/gmail-declutter-extension/HEAD/extras/cws_screenshots/Screenshot 2025-05-05 194830.png -------------------------------------------------------------------------------- /src/presentation/apps/tutorial/assets/extension-button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InboxWhiz/gmail-declutter-extension/HEAD/src/presentation/apps/tutorial/assets/extension-button.png -------------------------------------------------------------------------------- /src/domain/types/progress.ts: -------------------------------------------------------------------------------- 1 | export interface FetchProgress { 2 | currentPage: number; 3 | totalPages: number; 4 | processedEmails: number; 5 | totalEmails: number; 6 | percentage: number; 7 | } 8 | -------------------------------------------------------------------------------- /src/presentation/apps/tutorial/components/modal.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import "./modal.css"; 3 | 4 | export const Modal = ({ children }: { children: ReactNode }) => { 5 | return
{children}
; 6 | }; 7 | -------------------------------------------------------------------------------- /src/presentation/apps/tutorial/components/modal.css: -------------------------------------------------------------------------------- 1 | .modal { 2 | position: fixed; 3 | z-index: 50; 4 | left: 0; 5 | top: 0; 6 | width: 100%; 7 | height: 100%; 8 | background-color: rgb(0 0 0 / 40%); 9 | display: flex; 10 | align-items: center; 11 | justify-content: center; 12 | } 13 | -------------------------------------------------------------------------------- /src/presentation/apps/popup/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import "../../../index.css"; 4 | import App from "./Popup.tsx"; 5 | 6 | createRoot(document.getElementById("root")!).render( 7 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /src/presentation/apps/sidebar/components/loadingBar.css: -------------------------------------------------------------------------------- 1 | #loading-container { 2 | width: 80%; 3 | height: 8px; 4 | background-color: var(--bg-primary, #000); 5 | border-radius: 10px; 6 | margin: 27px auto; 7 | } 8 | 9 | #progress-bar { 10 | height: 100%; 11 | background-color: var(--bg-primary); 12 | border-radius: 10px; 13 | } 14 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | color: #213547; 6 | background-color: #fff; 7 | font-synthesis: none; 8 | text-rendering: optimizelegibility; 9 | -webkit-font-smoothing: antialiased; 10 | -moz-osx-font-smoothing: grayscale; 11 | } 12 | 13 | body { 14 | margin: 0; 15 | padding: 0; 16 | } 17 | -------------------------------------------------------------------------------- /src/presentation/apps/sidebar/components/reloadButton.css: -------------------------------------------------------------------------------- 1 | .reload-button { 2 | border: none; 3 | background-color: var(--bg-primary); 4 | color: var(--text-primary); 5 | box-shadow: none; 6 | cursor: pointer; 7 | } 8 | 9 | .reload-button .i { 10 | font-size: 18px; 11 | } 12 | 13 | .reload-button:hover { 14 | transform: scale(1.2); 15 | transition: transform 0.2s ease-in-out; 16 | } 17 | -------------------------------------------------------------------------------- /src/presentation/apps/sidebar/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import "../../../index.css"; 4 | import App from "./App.tsx"; 5 | import { PortManager } from "../../../data/ports/port_manager.ts"; 6 | 7 | createRoot(document.getElementById("root")!).render( 8 | 9 | 10 | , 11 | ); 12 | 13 | PortManager.openPort(); 14 | -------------------------------------------------------------------------------- /src/presentation/apps/popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | InboxWhiz 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/presentation/apps/sidebar/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | InboxWhiz 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/presentation/apps/sidebar/components/searchBar.css: -------------------------------------------------------------------------------- 1 | .search-bar-container { 2 | display: flex; 3 | flex-grow: 1; 4 | margin: 0 1rem; 5 | } 6 | 7 | .search-input { 8 | width: 100%; 9 | padding: 0.5rem; 10 | border-radius: 4px; 11 | border: 1px solid var(--border-color); 12 | background-color: var(--bg-color); 13 | color: var(--text-color); 14 | } 15 | 16 | .search-input::placeholder { 17 | color: var(--text-color-secondary); 18 | } 19 | -------------------------------------------------------------------------------- /src/presentation/apps/tutorial/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import "../../../index.css"; 4 | import Tutorial from "./Tutorial.tsx"; 5 | import { ThemeProvider } from "../../providers/theme_provider.tsx"; 6 | 7 | createRoot(document.getElementById("root")!).render( 8 | 9 | 10 | 11 | 12 | , 13 | ); 14 | -------------------------------------------------------------------------------- /src/domain/entities/sender.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents an email sender with their identification and frequency information. 3 | * 4 | * @property {string} email - The email address of the sender 5 | * @property {Set} names - All known display names of the sender 6 | * @property {number} emailCount - The number of emails received from this sender 7 | */ 8 | export interface Sender { 9 | email: string; 10 | names: Set; 11 | emailCount: number; 12 | } 13 | -------------------------------------------------------------------------------- /src/presentation/apps/tutorial/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* Default light mode */ 3 | --bg-primary: #fff; 4 | --text-primary: #000; 5 | } 6 | 7 | :root.dark { 8 | /* Dark mode vars */ 9 | --bg-primary: #121212; 10 | --text-primary: #fff; 11 | } 12 | 13 | @media (prefers-color-scheme: dark) { 14 | :root:not(.light) { 15 | --bg-primary: #121212; 16 | --text-primary: #fff; 17 | } 18 | } 19 | 20 | :root, 21 | body { 22 | background: transparent !important; 23 | } 24 | -------------------------------------------------------------------------------- /src/presentation/apps/tutorial/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | InboxWhiz 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # Playwright 27 | /test-results/ 28 | /tests-examples/ 29 | /playwright-report/ 30 | /blob-report/ 31 | /playwright/.cache/ 32 | 33 | # Video files 34 | *.mp4 -------------------------------------------------------------------------------- /src/data/repositories/mocks/mock_page_interaction_repo.ts: -------------------------------------------------------------------------------- 1 | import { PageInteractionRepo } from "../../../domain/repositories/page_interaction_repo"; 2 | 3 | export class MockPageInteractionRepo implements PageInteractionRepo { 4 | getActiveTabEmailAccount(): Promise { 5 | console.log("[MOCK] Getting active tab email account..."); 6 | return Promise.resolve("mock@example.com"); 7 | } 8 | 9 | searchEmailSenders(emails: string[]): void { 10 | console.log("[MOCK] Searching email senders:", emails); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/presentation/apps/sidebar/components/toggleOption.tsx: -------------------------------------------------------------------------------- 1 | import { ToggleSwitch } from "./toggleSwitch"; 2 | 3 | export const ToggleOption = ({ 4 | label, 5 | defaultChecked, 6 | onChange, 7 | }: { 8 | label: string | React.ReactElement; 9 | defaultChecked: boolean; 10 | onChange: (checked: boolean) => void; 11 | }) => { 12 | return ( 13 |
14 | 15 |

{label}

16 |
17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/presentation/apps/sidebar/components/searchBar.tsx: -------------------------------------------------------------------------------- 1 | import { useApp } from "../../../providers/app_provider"; 2 | import "./searchBar.css"; 3 | 4 | export const SearchBar = () => { 5 | const { searchTerm, setSearchTerm } = useApp(); 6 | 7 | return ( 8 |
9 | setSearchTerm(e.target.value)} 14 | className="search-input" 15 | /> 16 |
17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/presentation/apps/sidebar/components/header.css: -------------------------------------------------------------------------------- 1 | .declutter-header { 2 | text-align: center; 3 | padding: 10px; 4 | color: var(--text-secondary); 5 | display: flex; 6 | flex-direction: column; 7 | justify-content: center; 8 | align-items: center; 9 | } 10 | 11 | .header-icon { 12 | width: 25px; 13 | height: 25px; 14 | margin-bottom: 10px; 15 | background-color: var(--text-primary); 16 | color: var(--bg-primary); 17 | display: inline-flex; 18 | justify-content: center; 19 | align-items: center; 20 | border-radius: 50%; 21 | } 22 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | InboxWhiz 8 | 9 | 10 | 11 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/presentation/apps/sidebar/components/toggleSwitch.tsx: -------------------------------------------------------------------------------- 1 | import "./toggle.css"; 2 | 3 | interface ToggleSwitchProps { 4 | defaultChecked?: boolean; 5 | onChange?: (checked: boolean) => void; 6 | } 7 | 8 | export const ToggleSwitch = ({ 9 | defaultChecked, 10 | onChange, 11 | }: ToggleSwitchProps) => { 12 | return ( 13 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /src/presentation/apps/sidebar/components/reloadButton.tsx: -------------------------------------------------------------------------------- 1 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 2 | import { faRotate } from "@fortawesome/free-solid-svg-icons"; 3 | import { useApp } from "../../../providers/app_provider"; 4 | import "./reloadButton.css"; 5 | 6 | export const ReloadButton = () => { 7 | const { reloadSenders } = useApp(); 8 | 9 | return ( 10 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/data/ports/port_manager.ts: -------------------------------------------------------------------------------- 1 | export class PortManager { 2 | static gmailPort: chrome.runtime.Port | null = null; 3 | 4 | static async openPort() { 5 | const [currentTab] = await chrome.tabs.query({ 6 | active: true, 7 | lastFocusedWindow: true, 8 | }); 9 | if (currentTab && currentTab.id) { 10 | const port = chrome.tabs.connect(currentTab.id, { name: "gmail-port" }); 11 | this.gmailPort = port; 12 | 13 | this.gmailPort.postMessage({ message: "Sidepanel connected" }); 14 | this.gmailPort.onMessage.addListener((msg) => { 15 | console.log("content script said: ", msg); 16 | }); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/presentation/apps/sidebar/components/actionButton.css: -------------------------------------------------------------------------------- 1 | .action-button { 2 | border: none; 3 | border-radius: 20px; 4 | cursor: pointer; 5 | font-size: 14px; 6 | height: 32px; 7 | margin: 4px; 8 | padding: 0 16px; 9 | box-shadow: 0 3px 5px rgb(0 0 0 / 20%); 10 | } 11 | 12 | #unsubscribe-button { 13 | background-color: #233b86; 14 | color: white; 15 | } 16 | 17 | #unsubscribe-button:hover { 18 | background-color: #1a4c9b; 19 | } 20 | 21 | #delete-button { 22 | background-color: #bb1826; 23 | color: white; 24 | } 25 | 26 | #delete-button:hover { 27 | background-color: #ca2633; 28 | } 29 | 30 | .action-button .i { 31 | margin-right: 5px; 32 | } 33 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /src/presentation/apps/sidebar/components/senderLineSkeleton.tsx: -------------------------------------------------------------------------------- 1 | import Skeleton from "react-loading-skeleton"; 2 | import "react-loading-skeleton/dist/skeleton.css"; 3 | 4 | const SenderLineSkeleton = () => { 5 | return ( 6 |
7 |
8 |
9 |
10 | 11 | 12 |
13 |
14 |
15 | 16 |
17 |
18 | ); 19 | }; 20 | 21 | export default SenderLineSkeleton; 22 | -------------------------------------------------------------------------------- /src/domain/repositories/page_interaction_repo.ts: -------------------------------------------------------------------------------- 1 | export interface PageInteractionRepo { 2 | /** 3 | * Retrieves the Gmail account associated with the currently active browser tab. 4 | * 5 | * @returns {Promise} A promise that resolves to the email address string. 6 | * @throws Will reject the promise if there is no active tab or if a messaging error occurs. 7 | */ 8 | getActiveTabEmailAccount(): Promise; 9 | 10 | /** 11 | * Invokes a search for emails from the specified sender email addresses in the currently active Gmail tab. 12 | * 13 | * @param senderEmailAddresses - An array of sender email addresses to search for in Gmail. 14 | */ 15 | searchEmailSenders(emails: string[]): void; 16 | } 17 | -------------------------------------------------------------------------------- /src/presentation/apps/sidebar/components/emptySenders.tsx: -------------------------------------------------------------------------------- 1 | import { useApp } from "../../../../presentation/providers/app_provider"; 2 | import "./emptySenders.css"; 3 | 4 | export const EmptySenders = () => { 5 | const { reloadSenders } = useApp(); 6 | return ( 7 |
8 |
9 |

No senders yet

10 |

Load senders to get started

11 |
12 | 19 |
20 |
21 |
22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true 24 | }, 25 | "include": ["src"] 26 | } 27 | -------------------------------------------------------------------------------- /src/presentation/apps/sidebar/components/themeToggle.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useTheme } from "../../../providers/theme_provider"; 3 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 4 | import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons"; 5 | import "./themeToggle.css"; 6 | 7 | const ThemeToggle: React.FC = () => { 8 | const { theme, toggleTheme } = useTheme(); 9 | 10 | return ( 11 | 21 | ); 22 | }; 23 | 24 | export default ThemeToggle; 25 | -------------------------------------------------------------------------------- /src/presentation/providers/theme_context.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | 3 | export type ThemeMode = "light" | "dark"; 4 | export type ThemeSetting = ThemeMode | "system"; 5 | 6 | export interface ThemeContextType { 7 | theme: ThemeMode; 8 | setting: ThemeSetting; 9 | setSetting: (s: ThemeSetting) => void; 10 | toggleTheme: () => void; 11 | resetToSystem: () => void; 12 | } 13 | 14 | export const defaultThemeContext: ThemeContextType = { 15 | theme: "light", 16 | setting: "system", 17 | setSetting: () => console.warn("ThemeProvider not initialized"), 18 | toggleTheme: () => console.warn("ThemeProvider not initialized"), 19 | resetToSystem: () => console.warn("ThemeProvider not initialized"), 20 | }; 21 | 22 | export const ThemeContext = 23 | createContext(defaultThemeContext); 24 | -------------------------------------------------------------------------------- /.github/workflows/playwright.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: playwright tests 3 | 4 | on: 5 | push: 6 | branches: [main] 7 | pull_request: 8 | branches: [main] 9 | 10 | jobs: 11 | test: 12 | timeout-minutes: 60 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: lts/* 19 | - name: Install dependencies 20 | run: npm ci 21 | - name: Install Playwright Browsers 22 | run: npx playwright install --with-deps 23 | - name: Run Playwright tests 24 | run: npx playwright test 25 | - uses: actions/upload-artifact@v4 26 | if: ${{ !cancelled() }} 27 | with: 28 | name: playwright-report 29 | path: playwright-report/ 30 | retention-days: 30 31 | -------------------------------------------------------------------------------- /src/presentation/apps/sidebar/components/themeToggle.css: -------------------------------------------------------------------------------- 1 | .theme-toggle { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | width: 36px; 6 | height: 36px; 7 | border-radius: 50%; 8 | border: none; 9 | background-color: var(--toggle-bg); 10 | color: var(--toggle-color); 11 | cursor: pointer; 12 | transition: all 0.3s ease; 13 | font-size: 1rem; 14 | position: relative; 15 | } 16 | 17 | .theme-toggle:hover { 18 | transform: scale(1.1); 19 | background-color: var(--toggle-hover-bg); 20 | } 21 | 22 | .theme-toggle .icon { 23 | transition: 24 | transform 0.4s ease, 25 | opacity 0.4s ease; 26 | font-size: 18px; 27 | } 28 | 29 | .theme-toggle.light .icon { 30 | color: #f39c12; 31 | transform: rotate(0deg); 32 | } 33 | 34 | .theme-toggle.dark .icon { 35 | color: #f1c40f; 36 | transform: rotate(180deg); 37 | } 38 | -------------------------------------------------------------------------------- /src/presentation/apps/sidebar/components/header.tsx: -------------------------------------------------------------------------------- 1 | import { faUser } from "@fortawesome/free-solid-svg-icons"; 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 3 | import "./header.css"; 4 | import { useEffect, useState } from "react"; 5 | import { useApp } from "../../../providers/app_provider"; 6 | 7 | export function DeclutterHeader() { 8 | const { getEmailAccount } = useApp(); 9 | const [email, setEmail] = useState(null); 10 | 11 | useEffect(() => { 12 | (async () => { 13 | const email = await getEmailAccount(); 14 | setEmail(email); 15 | })(); 16 | }, [getEmailAccount]); 17 | 18 | return ( 19 |
20 |
21 | 22 |
23 | {email} 24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/presentation/apps/sidebar/components/loadingBar.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import "./loadingBar.css"; 3 | 4 | const LoadingBar = () => { 5 | // const [progress, setProgress] = useState(0); 6 | // const { checkFetchProgress } = useApp(); 7 | 8 | useEffect(() => { 9 | // Check progress every 500ms 10 | // const interval = setInterval(() => checkFetchProgress(setProgress), 500); 11 | // Clean up the interval when the component unmounts 12 | // return () => clearInterval(interval); 13 | }); 14 | 15 | return ( 16 |
17 |
18 |
26 |
27 |
28 | ); 29 | }; 30 | 31 | export default LoadingBar; 32 | -------------------------------------------------------------------------------- /src/presentation/apps/tutorial/Tutorial.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import "./Tutorial.css"; 3 | import { Step1, Step2, Step3, Success, WelcomeStep } from "./components/steps"; 4 | import { Modal } from "./components/modal"; 5 | 6 | const Tutorial = () => { 7 | const [step, setStep] = useState(0); 8 | 9 | const handleNext = () => { 10 | setStep(step + 1); 11 | }; 12 | 13 | return ( 14 | 15 |
16 | {step === 0 ? ( 17 | 18 | ) : step === 1 ? ( 19 | 20 | ) : step === 2 ? ( 21 | 22 | ) : step === 3 ? ( 23 | 24 | ) : step === 4 ? ( 25 | 26 | ) : null} 27 |
28 |
29 | ); 30 | }; 31 | 32 | export default Tutorial; 33 | -------------------------------------------------------------------------------- /src/presentation/apps/sidebar/components/emptySenders.css: -------------------------------------------------------------------------------- 1 | .e-container { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | background-color: var(--bg-primary); 6 | height: 80%; 7 | } 8 | 9 | .e-card { 10 | text-align: center; 11 | } 12 | 13 | .e-icon { 14 | font-size: 40px; 15 | margin-bottom: 16px; 16 | color: var(--text-secondary); 17 | } 18 | 19 | .e-title { 20 | font-size: 20px; 21 | font-weight: 600; 22 | margin: 0; 23 | } 24 | 25 | .e-subtitle { 26 | font-size: 14px; 27 | color: var(--text-secondary); 28 | margin: 8px 0 24px; 29 | } 30 | 31 | .e-buttons { 32 | display: flex; 33 | justify-content: center; 34 | gap: 12px; 35 | } 36 | 37 | .btn { 38 | padding: 10px 20px; 39 | font-size: 14px; 40 | font-weight: 500; 41 | border: none; 42 | cursor: pointer; 43 | border-radius: 6px; 44 | background-color: var(--plain-button); 45 | color: #fff; 46 | } 47 | 48 | .btn:hover { 49 | background-color: var(--plain-button-hover); 50 | } 51 | -------------------------------------------------------------------------------- /src/presentation/apps/tutorial/Tutorial.css: -------------------------------------------------------------------------------- 1 | .tutorial-popup { 2 | z-index: 75; 3 | background: #fff; 4 | border-radius: 12px; 5 | width: 400px; 6 | padding: 0; 7 | font-family: "Segoe UI", Arial, sans-serif; 8 | border: 1px solid #e0e0e0; 9 | } 10 | 11 | .step { 12 | display: flex; 13 | flex-direction: column; 14 | align-items: center; 15 | justify-content: center; 16 | } 17 | 18 | .logo { 19 | margin-top: 20px; 20 | } 21 | 22 | .tutorial-header { 23 | padding: 0; 24 | margin: 0; 25 | margin-top: 20px; 26 | text-align: center; 27 | } 28 | 29 | .tutorial-note { 30 | margin-top: 15px; 31 | } 32 | 33 | .tutorial-gif { 34 | margin: 10px; 35 | } 36 | 37 | .tutorial-btn { 38 | background: #233b86; 39 | color: white; 40 | border: none; 41 | border-radius: 6px; 42 | width: 40%; 43 | padding: 8px 32px; 44 | margin: 20px 0; 45 | font-weight: bold; 46 | font-size: 0.9rem; 47 | cursor: pointer; 48 | transition: background 0.2s; 49 | } 50 | 51 | .tutorial-btn:hover { 52 | background: #1a4c9b; 53 | } 54 | -------------------------------------------------------------------------------- /src/presentation/apps/sidebar/components/senderLine.css: -------------------------------------------------------------------------------- 1 | .sender-line { 2 | background-color: var(--bg-primary); 3 | color: var(--text-primary); 4 | font-size: 0.875rem; 5 | display: flex; 6 | align-items: center; 7 | justify-content: space-between; 8 | border-bottom: 1px solid var(--line-color); 9 | padding-bottom: 5px; 10 | padding-top: 5px; 11 | } 12 | 13 | .sender-line.selected { 14 | background-color: var(--selected-bg); 15 | color: var(--text-primary); 16 | } 17 | 18 | .begin { 19 | display: flex; 20 | align-items: center; 21 | padding: 0 7px; 22 | } 23 | 24 | .sender-details { 25 | display: inline-flex; 26 | flex-direction: column; 27 | padding: 7px 10px; 28 | max-width: 65vw; 29 | } 30 | 31 | .sender-email { 32 | color: var(--text-secondary); 33 | overflow: hidden; 34 | text-overflow: ellipsis; 35 | font-size: 0.75rem; 36 | margin-top: 3px; 37 | cursor: pointer; 38 | } 39 | 40 | .sender-email:hover { 41 | color: #007bff; 42 | } 43 | 44 | .email-count span { 45 | font-weight: bold; 46 | padding: 0 7px; 47 | } 48 | -------------------------------------------------------------------------------- /src/domain/repositories/storage_repo.ts: -------------------------------------------------------------------------------- 1 | import { Sender } from "../entities/sender"; 2 | 3 | export interface StorageRepo { 4 | /** 5 | * Stores a list of senders for a specific account. 6 | * 7 | * @param senders - An object mapping sender email addresses to their corresponding SenderData. 8 | * @param accountEmail - The email address of the account to associate the stored senders with. 9 | */ 10 | storeSenders(senders: Sender[], accountEmail: string): Promise; 11 | 12 | /** 13 | * Retrieves a list of senders for a specific account. 14 | * 15 | * @param accountEmail - The email address of the account to retrieve the stored senders for. 16 | */ 17 | readSenders(accountEmail: string): Promise; 18 | 19 | /** 20 | * Deletes a list of senders for a specific account. 21 | * 22 | * @param senderEmails - An array of email addresses of the senders to delete. 23 | * @param accountEmail - The email address of the account to associate the deletion with. 24 | */ 25 | deleteSenders(senderEmails: string[], accountEmail: string): Promise; 26 | } 27 | -------------------------------------------------------------------------------- /src/presentation/apps/sidebar/providers/modalContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useState } from "react"; 2 | 3 | type ModalState = null | { 4 | action?: "delete" | "unsubscribe"; 5 | type: "confirm" | "pending" | "continue" | "success" | "error" | "no-sender"; 6 | subtype?: "working" | "finding-link" | "blocking"; 7 | extras?: any; 8 | }; 9 | 10 | interface ModalContextType { 11 | modal: ModalState; 12 | setModal: React.Dispatch>; 13 | } 14 | 15 | const ModalContext = createContext(undefined); 16 | 17 | export const useModal = () => { 18 | const context = useContext(ModalContext); 19 | if (!context) { 20 | throw new Error("useModal must be used within a ModalProvider"); 21 | } 22 | return context; 23 | }; 24 | 25 | export const ModalProvider: React.FC<{ children: React.ReactNode }> = ({ 26 | children, 27 | }) => { 28 | const [modal, setModal] = useState(null); 29 | 30 | return ( 31 | 32 | {children} 33 | 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /src/presentation/apps/tutorial/components/successIcon.tsx: -------------------------------------------------------------------------------- 1 | export const SuccessIcon = () => { 2 | return ( 3 | 11 | 12 | Check Mark Button Flat Streamline Emoji: https://streamlinehq.com 13 | 14 | 19 | 24 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: release 3 | 4 | on: 5 | workflow_dispatch: 6 | inputs: 7 | version: 8 | description: "Tag name (e.g., v1.2.3)" 9 | required: true 10 | type: string 11 | 12 | jobs: 13 | release: 14 | name: Build and Release 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout repo 19 | uses: actions/checkout@v4 20 | 21 | - name: Setup Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 20 25 | 26 | - name: Install dependencies 27 | run: npm ci 28 | 29 | - name: Build project 30 | run: npm run build 31 | 32 | - name: Zip dist folder 33 | run: | 34 | cd dist 35 | zip -r ../dist.zip . 36 | cd .. 37 | 38 | - name: Create GitHub Release 39 | uses: softprops/action-gh-release@v2.2.2 40 | with: 41 | tag_name: ${{ github.event.inputs.version }} 42 | name: ${{ github.event.inputs.version }} 43 | files: dist.zip 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | -------------------------------------------------------------------------------- /src/presentation/apps/sidebar/components/searchInput.css: -------------------------------------------------------------------------------- 1 | .search-input-container { 2 | position: relative; 3 | display: flex; 4 | align-items: center; 5 | margin: 10px 15px; 6 | background-color: var(--bg-primary); 7 | border: 1px solid var(--border-color); 8 | border-radius: 8px; 9 | padding: 0 12px; 10 | height: 36px; 11 | min-height: 36px; 12 | } 13 | 14 | .search-icon { 15 | color: var(--text-secondary); 16 | font-size: 14px; 17 | margin-right: 8px; 18 | flex-shrink: 0; 19 | } 20 | 21 | .search-input { 22 | flex: 1; 23 | background: transparent; 24 | border: none; 25 | outline: none; 26 | color: var(--text-primary); 27 | font-size: 14px; 28 | padding: 0; 29 | line-height: 36px; 30 | height: 36px; 31 | } 32 | 33 | .search-input::placeholder { 34 | color: var(--text-secondary); 35 | } 36 | 37 | .clear-button { 38 | background: none; 39 | border: none; 40 | color: var(--text-secondary); 41 | cursor: pointer; 42 | padding: 4px; 43 | display: flex; 44 | align-items: center; 45 | justify-content: center; 46 | font-size: 12px; 47 | transition: color 0.2s; 48 | } 49 | 50 | .clear-button:hover { 51 | color: var(--text-primary); 52 | } 53 | -------------------------------------------------------------------------------- /src/presentation/apps/sidebar/components/searchInput.tsx: -------------------------------------------------------------------------------- 1 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 2 | import { faSearch, faTimes } from "@fortawesome/free-solid-svg-icons"; 3 | import "./searchInput.css"; 4 | 5 | interface SearchInputProps { 6 | value: string; 7 | onChange: (value: string) => void; 8 | placeholder?: string; 9 | } 10 | 11 | export const SearchInput = ({ 12 | value, 13 | onChange, 14 | placeholder = "Search senders...", 15 | }: SearchInputProps) => { 16 | const handleClear = () => { 17 | onChange(""); 18 | }; 19 | 20 | return ( 21 |
22 | 23 | onChange(e.target.value)} 29 | aria-label="Search senders" 30 | /> 31 | {value && ( 32 | 39 | )} 40 |
41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /extras/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /public/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/presentation/apps/sidebar/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/data/repositories/mocks/mock_storage_repo.ts: -------------------------------------------------------------------------------- 1 | import { StorageRepo } from "../../../domain/repositories/storage_repo"; 2 | import { Sender } from "../../../domain/entities/sender"; 3 | 4 | export class MockStorageRepo implements StorageRepo { 5 | private mockSenders: Sender[] = []; 6 | 7 | constructor(initialSenders: Sender[] = this.mockSenders) { 8 | this.mockSenders = initialSenders; 9 | } 10 | 11 | setSenders(senders: Sender[]) { 12 | this.mockSenders = senders; 13 | } 14 | 15 | // - Mock implementations - 16 | 17 | storeSenders(senders: Sender[], accountEmail: string): Promise { 18 | console.log(`[MOCK] Storing senders for account: ${accountEmail}`); 19 | senders.forEach((sender) => { 20 | console.log(`[MOCK] Storing sender: ${sender}`); 21 | }); 22 | return Promise.resolve(); 23 | } 24 | 25 | readSenders(accountEmail: string): Promise { 26 | console.log(`[MOCK] Reading senders for account: ${accountEmail}`); 27 | return Promise.resolve(this.mockSenders); 28 | } 29 | 30 | deleteSenders(senderEmails: string[], accountEmail: string): Promise { 31 | console.log(`[MOCK] Deleting senders for account: ${accountEmail}`); 32 | senderEmails.forEach((email) => { 33 | console.log(`[MOCK] Deleting sender: ${email}`); 34 | }); 35 | return Promise.resolve(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/presentation/apps/sidebar/components/toggle.css: -------------------------------------------------------------------------------- 1 | .toggle-option { 2 | display: flex; 3 | align-items: center; 4 | width: 90%; 5 | margin: 0 auto; 6 | } 7 | 8 | /* The switch - the box around the slider */ 9 | .switch { 10 | font-size: 12px; 11 | position: relative; 12 | display: inline-block; 13 | min-width: 3.5em; 14 | height: 2em; 15 | } 16 | 17 | /* Hide default HTML checkbox */ 18 | .switch input { 19 | opacity: 0; 20 | width: 0; 21 | height: 0; 22 | } 23 | 24 | /* The slider */ 25 | .slider { 26 | position: absolute; 27 | cursor: pointer; 28 | inset: 0; 29 | background-color: var(--bg-primary); 30 | border: 1px solid #adb5bd; 31 | transition: 0.4s; 32 | border-radius: 30px; 33 | } 34 | 35 | .slider::before { 36 | position: absolute; 37 | content: ""; 38 | height: 1.4em; 39 | width: 1.4em; 40 | border-radius: 20px; 41 | left: 0.27em; 42 | bottom: 0.24em; 43 | background-color: #adb5bd; 44 | transition: 0.4s; 45 | } 46 | 47 | input:checked + .slider { 48 | background-color: #233b86; 49 | border: 1px solid #233b86; 50 | } 51 | 52 | input:focus + .slider { 53 | box-shadow: 0 0 1px #233b86; 54 | } 55 | 56 | input:checked + .slider::before { 57 | transform: translateX(1.4em); 58 | background-color: #fff; 59 | } 60 | 61 | .label { 62 | font-size: 14px; 63 | text-align: left; 64 | margin-left: 20px; 65 | } 66 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "InboxWhiz - Bulk Unsubscribe & Clean Gmail", 4 | "version": "2.1.0", 5 | "description": "Declutter your Gmail in seconds - mass unsubscribe and remove emails in bulk effortlessly.", 6 | "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA9Cv0qfOMUXfojZCsia00e9MU65oVIjZOWAaVyzWbywpsaNXfAz9r7mEa0wLyrlE9AOn5Yqb+J8NUknT1ntHL4yUOAeOZ71d1kCT20Ox+8RroRwnQOP7SKHqbia4TvKVvSGlsI/IL879yNGTbrrZ7Er4vd6UsNEyVK5Sb7CcceTmaZ9WmXi695u4ynXVTbun74Qgm3aDjRFcw+901J0lpHisCMzRhdAz2yuaZ0WTSQ4HB4cYDOe+wOPb2z02fU600JfKMS6B0vNJ6wPlv0onnpjh+7wjMMYwT4wyP4V6arg+v+4wUK9t42HyqVSxZ3bTfh22IwDiEKo4ibahTH+vxfwIDAQAB", 7 | "icons": { 8 | "16": "images/icon-16.png", 9 | "32": "images/icon-32.png", 10 | "48": "images/icon-48.png", 11 | "128": "images/icon-128.png" 12 | }, 13 | "permissions": ["sidePanel", "storage", "tabs"], 14 | "action": { 15 | "default_popup": "popup/index.html" 16 | }, 17 | "background": { 18 | "service_worker": "background.js", 19 | "type": "module" 20 | }, 21 | "content_scripts": [ 22 | { 23 | "js": ["content_script.js"], 24 | "matches": ["https://mail.google.com/*"], 25 | "type": "module" 26 | } 27 | ], 28 | "web_accessible_resources": [ 29 | { 30 | "resources": ["tutorial/*", "assets/*"], 31 | "matches": ["https://mail.google.com/*"] 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /src/presentation/apps/sidebar/components/fetchProgress.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { FetchProgress } from "../../../../domain/types/progress"; 3 | import "./fetchProgress.css"; 4 | 5 | interface FetchProgressProps { 6 | progress: FetchProgress; 7 | onCancel: () => void; 8 | } 9 | 10 | export const FetchProgressBar: React.FC = ({ 11 | progress, 12 | onCancel, 13 | }) => { 14 | return ( 15 |
16 |
17 |

Scanning inbox...

18 | 25 |
26 | 27 |
28 | 29 | Page {progress.currentPage} of {progress.totalPages} 30 | 31 | 32 | {progress.processedEmails.toLocaleString()} emails processed 33 | 34 |
35 | 36 |
37 |
41 |
42 | 43 |
{progress.percentage}%
44 |
45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /src/presentation/apps/popup/Popup.css: -------------------------------------------------------------------------------- 1 | @media (prefers-color-scheme: dark) { 2 | :root { 3 | --bg-primary: #1a1a1a; 4 | --text-primary: #f0f0f0; 5 | --button-bg: #333; 6 | --border-color: #444; 7 | --link-color: #9dbdff; 8 | } 9 | } 10 | 11 | @media (prefers-color-scheme: light) { 12 | :root { 13 | --bg-primary: #fff; 14 | --text-primary: #000; 15 | --button-bg: #f0f0f0; 16 | --border-color: #e0e0e0; 17 | --link-color: #1a0dab; 18 | } 19 | } 20 | 21 | * { 22 | background-color: var(--bg-primary); 23 | color: var(--text-primary); 24 | box-sizing: border-box; 25 | margin: 0; 26 | padding: 0; 27 | } 28 | 29 | .popup-content { 30 | background-color: var(--bg-primary); 31 | color: var(--text-primary); 32 | padding: 15px 0; 33 | margin: 0 20px; 34 | width: 300px; 35 | text-align: center; 36 | } 37 | 38 | .version-info { 39 | margin-top: 10px; 40 | font-size: 0.85rem; 41 | } 42 | 43 | .open-gmail-button { 44 | padding: 15px 30px; 45 | font-size: 0.95rem; 46 | background-color: #233b86; 47 | color: white; 48 | font-weight: bold; 49 | border: none; 50 | border-radius: 20px; 51 | cursor: pointer; 52 | display: flex; 53 | align-items: center; 54 | gap: 8px; 55 | } 56 | 57 | .open-gmail-button img { 58 | background-color: #233b86; 59 | } 60 | 61 | .open-gmail-button:hover { 62 | background-color: #1a4c9b; 63 | } 64 | 65 | a { 66 | color: var(--link-color); 67 | } 68 | 69 | .spacer { 70 | height: 5px; 71 | } 72 | -------------------------------------------------------------------------------- /.github/workflows/lint-test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: lint & test 3 | 4 | on: 5 | push: 6 | branches: [main] 7 | pull_request: 8 | branches: [main] 9 | 10 | jobs: 11 | lint: 12 | name: lint 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout Code 16 | uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Install dependencies 21 | run: npm ci 22 | 23 | - name: Lint Code Base 24 | uses: super-linter/super-linter/slim@v7 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | VALIDATE_CHECKOV: false 28 | VALIDATE_JAVASCRIPT_STANDARD: false 29 | VALIDATE_TYPESCRIPT_STANDARD: false 30 | VALIDATE_MARKDOWN: false 31 | VALIDATE_JSON: false 32 | VALIDATE_NATURAL_LANGUAGE: false 33 | VALIDATE_JSCPD: false 34 | LINTER_RULES_PATH: . 35 | JAVASCRIPT_ES_CONFIG_FILE: eslint.config.js 36 | TYPESCRIPT_ES_CONFIG_FILE: eslint.config.js 37 | 38 | test: 39 | name: test 40 | runs-on: ubuntu-latest 41 | steps: 42 | - name: Checkout code 43 | uses: actions/checkout@v4 44 | 45 | - name: Set up Node.js 46 | uses: actions/setup-node@v4 47 | with: 48 | node-version: "20" 49 | 50 | - name: Install dependencies 51 | run: | 52 | npm install 53 | 54 | - name: Run Jest tests 55 | run: | 56 | npm test 57 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import globals from "globals"; 3 | import reactHooks from "eslint-plugin-react-hooks"; 4 | import reactRefresh from "eslint-plugin-react-refresh"; 5 | import tseslint from "typescript-eslint"; 6 | 7 | export default tseslint.config( 8 | { ignores: ["dist"] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ["**/*.{ts,tsx}"], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | "react-hooks": reactHooks, 18 | "react-refresh": reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | "react-refresh/only-export-components": [ 23 | "warn", 24 | { allowConstantExport: true }, 25 | ], 26 | "@typescript-eslint/no-explicit-any": "off", 27 | "@typescript-eslint/no-unused-expressions": [ 28 | "error", 29 | { 30 | allowShortCircuit: true, 31 | allowTernary: true, 32 | allowTaggedTemplates: true, 33 | }, 34 | ], 35 | "@typescript-eslint/no-unused-vars": [ 36 | "error", 37 | { 38 | args: "all", 39 | argsIgnorePattern: "^_", 40 | caughtErrors: "all", 41 | caughtErrorsIgnorePattern: "^_", 42 | destructuredArrayIgnorePattern: "^_", 43 | varsIgnorePattern: "^_", 44 | }, 45 | ], 46 | }, 47 | }, 48 | ); 49 | -------------------------------------------------------------------------------- /src/data/repositories/chrome_page_interaction_repo.ts: -------------------------------------------------------------------------------- 1 | import { PageInteractionRepo } from "../../domain/repositories/page_interaction_repo"; 2 | 3 | export class ChromePageInteractionRepo implements PageInteractionRepo { 4 | async getActiveTabEmailAccount(): Promise { 5 | return new Promise((resolve, reject) => { 6 | chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { 7 | const tabId = tabs[0]?.id; 8 | if (tabId === undefined) { 9 | reject("No active tab."); 10 | return; 11 | } 12 | 13 | chrome.tabs.sendMessage( 14 | tabId, 15 | { action: "GET_EMAIL_ACCOUNT" }, 16 | (response) => { 17 | if (chrome.runtime.lastError) { 18 | console.error( 19 | "Could not get email account:", 20 | chrome.runtime.lastError, 21 | ); 22 | reject(chrome.runtime.lastError.message); 23 | } else { 24 | resolve(response.result); 25 | } 26 | }, 27 | ); 28 | }); 29 | }); 30 | } 31 | 32 | searchEmailSenders(senderEmailAddresses: string[]): void { 33 | chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { 34 | if (tabs[0]?.id) { 35 | chrome.tabs.sendMessage(tabs[0].id, { 36 | type: "SEARCH_EMAIL_SENDERS", 37 | emails: senderEmailAddresses, 38 | }); 39 | } else { 40 | console.error("No active tab found."); 41 | } 42 | }); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/presentation/apps/sidebar/components/sendersContainer.tsx: -------------------------------------------------------------------------------- 1 | import { SenderLine } from "./senderLine"; 2 | import { useApp } from "../../../providers/app_provider"; 3 | import SenderLineSkeleton from "./senderLineSkeleton"; 4 | import { EmptySenders } from "./emptySenders"; 5 | import { FetchProgressBar } from "./fetchProgress"; 6 | 7 | export const SendersContainer = () => { 8 | const { filteredSenders, loading, searchTerm, fetchProgress, cancelFetch } = 9 | useApp(); 10 | 11 | return ( 12 |
13 | {fetchProgress ? ( 14 | 15 | ) : loading ? ( 16 | <> 17 | {Array.from({ length: 7 }).map((_, i) => ( 18 | 19 | ))} 20 | 21 | ) : filteredSenders.length === 0 ? ( 22 | searchTerm ? ( 23 |
30 |

No senders match "{searchTerm}"

31 |
32 | ) : ( 33 | 34 | ) 35 | ) : ( 36 | filteredSenders.map((sender, _index) => ( 37 | 43 | )) 44 | )} 45 |
46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /src/presentation/apps/sidebar/components/actionButton.tsx: -------------------------------------------------------------------------------- 1 | import "./actionButton.css"; 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 3 | import { faBan, faTrash } from "@fortawesome/free-solid-svg-icons"; 4 | import { IconProp } from "@fortawesome/fontawesome-svg-core"; 5 | import { useModal } from "../providers/modalContext"; 6 | import { useApp } from "../../../providers/app_provider"; 7 | 8 | export const ActionButton = ({ id }: { id: string }) => { 9 | const text: string = id == "unsubscribe-button" ? "Unsubscribe" : "Delete"; 10 | const icon: IconProp = id == "unsubscribe-button" ? faBan : faTrash; 11 | const { selectedSenders } = useApp(); 12 | const { setModal } = useModal(); 13 | 14 | const handleClick = () => { 15 | const selectedSenderKeys: string[] = Object.keys(selectedSenders); 16 | if (selectedSenderKeys.length > 0) { 17 | // open confirmation modal 18 | setModal({ 19 | action: id === "unsubscribe-button" ? "unsubscribe" : "delete", 20 | type: "confirm", 21 | extras: { 22 | emailsNum: selectedSenderKeys.reduce( 23 | (sum, key) => sum + selectedSenders[key], 24 | 0, 25 | ), 26 | sendersNum: selectedSenderKeys.length, 27 | }, 28 | }); 29 | } else { 30 | // open no-senders modal 31 | setModal({ type: "no-sender" }); 32 | } 33 | }; 34 | 35 | return ( 36 | 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /src/presentation/apps/sidebar/components/senderLine.tsx: -------------------------------------------------------------------------------- 1 | import "./senderLine.css"; 2 | import { useApp } from "../../../providers/app_provider"; 3 | 4 | interface SenderLineProps { 5 | senderName: string; 6 | senderEmail: string; 7 | senderCount: number; 8 | } 9 | 10 | export const SenderLine = ({ 11 | senderName, 12 | senderEmail, 13 | senderCount, 14 | }: SenderLineProps) => { 15 | const { selectedSenders, setSelectedSenders, searchEmailSenders } = useApp(); 16 | 17 | const selectLine = () => { 18 | setSelectedSenders((prev) => { 19 | const newSelected = { ...prev }; 20 | if (!(senderEmail in newSelected)) { 21 | newSelected[senderEmail] = senderCount; 22 | } else { 23 | delete newSelected[senderEmail]; 24 | } 25 | return newSelected; 26 | }); 27 | }; 28 | 29 | return ( 30 |
37 |
38 |
39 | 44 |
45 |
46 | {senderName} 47 | searchEmailSenders([senderEmail])} 50 | > 51 | {senderEmail} 52 | 53 |
54 |
55 |
56 | {senderCount} 57 |
58 |
59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from "@playwright/test"; 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // import dotenv from 'dotenv'; 8 | // import path from 'path'; 9 | // dotenv.config({ path: path.resolve(__dirname, '.env') }); 10 | 11 | /** 12 | * See https://playwright.dev/docs/test-configuration. 13 | */ 14 | export default defineConfig({ 15 | testDir: "./test/ui", 16 | /* Run tests in files in parallel */ 17 | fullyParallel: true, 18 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 19 | forbidOnly: !!process.env.CI, 20 | /* Retry on CI only */ 21 | retries: process.env.CI ? 2 : 0, 22 | /* Opt out of parallel tests on CI. */ 23 | workers: process.env.CI ? 1 : undefined, 24 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 25 | reporter: "html", 26 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 27 | use: { 28 | /* Base URL to use in actions like `await page.goto('/')`. */ 29 | baseURL: "http://localhost:5173", 30 | 31 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 32 | trace: "on-first-retry", 33 | }, 34 | 35 | /* Configure projects for major browsers */ 36 | projects: [ 37 | { 38 | name: "Google Chrome", 39 | use: { 40 | ...devices["Desktop Chrome"], 41 | channel: "chrome", 42 | viewport: { width: 360, height: 890 }, 43 | }, 44 | }, 45 | ], 46 | 47 | /* Run your local dev server before starting the tests */ 48 | webServer: { 49 | command: "npm run dev", 50 | url: "http://localhost:5173", 51 | reuseExistingServer: !process.env.CI, 52 | }, 53 | }); 54 | -------------------------------------------------------------------------------- /public/background.js: -------------------------------------------------------------------------------- 1 | const GMAIL_ORIGIN = "https://mail.google.com"; 2 | 3 | // Allows users to open the side panel by clicking on the action toolbar icon 4 | chrome.sidePanel 5 | .setPanelBehavior({ openPanelOnActionClick: true }) 6 | .catch((error) => console.error(error)); 7 | 8 | chrome.tabs.onUpdated.addListener(async (tabId, info, tab) => { 9 | if (!tab.url) return; 10 | const url = new URL(tab.url); 11 | if (url.origin === GMAIL_ORIGIN) { 12 | // Enables the side panel and disables popup on Gmail pages 13 | await chrome.sidePanel.setOptions({ 14 | tabId, 15 | path: "sidebar/index.html", 16 | enabled: true, 17 | }); 18 | await chrome.action.setPopup({ tabId, popup: "" }); 19 | } else { 20 | // Disables the side panel and enables popup on all other sites 21 | await chrome.sidePanel.setOptions({ 22 | tabId, 23 | enabled: false, 24 | }); 25 | chrome.action.setPopup({ tabId, popup: "popup/index.html" }); 26 | } 27 | }); 28 | 29 | // Shows a tutorial when the extension is installed 30 | chrome.runtime.onInstalled.addListener(function (object) { 31 | if (object.reason === chrome.runtime.OnInstalledReason.INSTALL) { 32 | chrome.tabs.create({ url: "https://mail.google.com/" }, function (tab) { 33 | // Wait for the tab to finish loading before sending the message 34 | function handleUpdated(tabId, info) { 35 | if (tabId === tab.id && info.status === "complete") { 36 | chrome.tabs.sendMessage(tab.id, { action: "SHOW_TUTORIAL" }); 37 | chrome.tabs.onUpdated.removeListener(handleUpdated); 38 | } 39 | } 40 | chrome.tabs.onUpdated.addListener(handleUpdated); 41 | }); 42 | } 43 | }); 44 | 45 | // Shows an uninstall survey when extension is removed 46 | chrome.runtime.setUninstallURL("https://tally.so/r/w4yg5X"); 47 | -------------------------------------------------------------------------------- /src/data/repositories/chrome_local_storage_repo.ts: -------------------------------------------------------------------------------- 1 | import { Sender } from "../../domain/entities/sender"; 2 | import { StorageRepo } from "../../domain/repositories/storage_repo"; 3 | 4 | export class ChromeLocalStorageRepo implements StorageRepo { 5 | async storeSenders(senders: Sender[], accountEmail: string): Promise { 6 | // Sort by count in descending order 7 | const sortedSenders = senders.sort((a, b) => b.emailCount - a.emailCount); 8 | 9 | // Store in local storage 10 | await chrome.storage.local.set({ 11 | [accountEmail]: { senders: sortedSenders }, 12 | }); 13 | } 14 | 15 | readSenders(accountEmail: string): Promise { 16 | return new Promise((resolve, reject) => { 17 | chrome.storage.local.get(accountEmail).then((result) => { 18 | if (chrome.runtime.lastError) { 19 | reject(chrome.runtime.lastError); 20 | return; 21 | } 22 | 23 | const senders = result[accountEmail]?.senders || []; 24 | resolve(senders); 25 | }); 26 | }); 27 | } 28 | 29 | deleteSenders(senderEmails: string[], accountEmail: string): Promise { 30 | return new Promise((resolve) => { 31 | chrome.storage.local.get([accountEmail], (result) => { 32 | if (result[accountEmail].senders) { 33 | const updatedSenders = result[accountEmail].senders.filter( 34 | (sender: { email: string; emailCount: number; names: string[] }) => 35 | !senderEmails.includes(sender.email), 36 | ); 37 | chrome.storage.local.set( 38 | { [accountEmail]: { senders: updatedSenders } }, 39 | () => { 40 | console.log("Updated senders in local storage."); 41 | resolve(); 42 | }, 43 | ); 44 | } 45 | }); 46 | }); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/presentation/apps/sidebar/components/modalPopup.css: -------------------------------------------------------------------------------- 1 | .modal { 2 | position: absolute; 3 | z-index: 50; 4 | left: 0; 5 | top: 0; 6 | width: 100%; 7 | height: 100%; 8 | border-radius: 16px; 9 | background-color: rgb(0 0 0 / 40%); 10 | color: var(--text-primary); 11 | } 12 | 13 | .modal-content { 14 | background-color: var(--bg-primary); 15 | color: var(--text-primary); 16 | margin: 50% auto; 17 | padding: 20px; 18 | border: 1px solid #888; 19 | width: 70%; 20 | border-radius: 7px; 21 | text-align: center; 22 | } 23 | 24 | p { 25 | font-size: 16px; 26 | } 27 | 28 | .note { 29 | font-size: 14px; 30 | } 31 | 32 | .modal-content button { 33 | border: none; 34 | display: block; 35 | text-align: center; 36 | border-radius: 5px; 37 | transition: background-color 0.3s; 38 | margin-top: 10px; 39 | width: 100%; 40 | font-size: 16px; 41 | padding: 10px 0; 42 | } 43 | 44 | button.primary { 45 | color: var(--modal-button-secondary); 46 | background-color: var(--modal-button-primary); 47 | padding: 12px 0; 48 | } 49 | 50 | button.secondary { 51 | color: var(--text-primary); 52 | background-color: var(--bg-primary); 53 | border: 1px solid var(--border-color); 54 | } 55 | 56 | button.primary:hover { 57 | background-color: var(--modal-button-primary-hover); 58 | cursor: pointer; 59 | } 60 | 61 | button.secondary:hover { 62 | background-color: var(--modal-button-secondary-hover); 63 | cursor: pointer; 64 | } 65 | 66 | .loader { 67 | border: 8px solid #e4e4e4; 68 | border-top: 8px solid #555; 69 | border-radius: 50%; 70 | width: 40px; 71 | height: 40px; 72 | animation: spin 2s linear infinite; 73 | margin: 10px auto; 74 | } 75 | 76 | @keyframes spin { 77 | 0% { 78 | transform: rotate(0deg); 79 | } 80 | 81 | 100% { 82 | transform: rotate(360deg); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/presentation/apps/sidebar/App.tsx: -------------------------------------------------------------------------------- 1 | import "./App.css"; 2 | import { useTheme } from "../../providers/theme_provider.tsx"; 3 | import { ActionButton } from "./components/actionButton.tsx"; 4 | import { ReloadButton } from "./components/reloadButton.tsx"; 5 | import { ModalPopup } from "./components/modalPopup.tsx"; 6 | import { SendersContainer } from "./components/sendersContainer.tsx"; 7 | import { DeclutterHeader } from "./components/header.tsx"; 8 | import { ModalProvider } from "./providers/modalContext.tsx"; 9 | import ThemeToggle from "./components/themeToggle.tsx"; 10 | import { AppProvider } from "../../providers/app_provider.tsx"; 11 | import { ThemeProvider } from "../../providers/theme_provider.tsx"; 12 | import { SearchInput } from "./components/searchInput.tsx"; 13 | import { useApp } from "../../providers/app_provider.tsx"; 14 | 15 | function App() { 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | } 24 | 25 | function AppWithTheme() { 26 | const { theme } = useTheme(); 27 | const { searchTerm, setSearchTerm } = useApp(); 28 | 29 | return ( 30 | 31 |
32 | 33 | 34 |
35 |
36 | 37 | 38 |
39 | 40 |
41 | 42 | 43 |
44 |
45 | 46 | 47 | 48 | 49 | 50 | 51 |
52 |
53 | ); 54 | } 55 | 56 | export default App; 57 | -------------------------------------------------------------------------------- /src/domain/repositories/email_repo.ts: -------------------------------------------------------------------------------- 1 | import { Sender } from "../entities/sender"; 2 | import { FetchProgress } from "../types/progress"; 3 | 4 | /** 5 | * Repository interface for managing email operations. 6 | * Meant to provide a consistent API for interacting with email data, 7 | * interchangeable between using Gmail API and browser automation. 8 | */ 9 | export interface EmailRepo { 10 | /** 11 | * Fetches a list of all email senders along with their details. 12 | * 13 | * @returns A Promise that resolves to an array of Sender objects. 14 | */ 15 | fetchSenders(): Promise; 16 | 17 | /** 18 | * Deletes the specified senders by moving their emails to trash. 19 | * 20 | * @param senderEmailAddresses - An array of sender email addresses to be deleted. 21 | * @returns A Promise that resolves when the senders have been trashed. 22 | */ 23 | deleteSenders(senderEmailAddresses: string[]): Promise; 24 | 25 | /** 26 | * Attempts to unsubscribe the specified senders. 27 | * 28 | * @param senderEmailAddresses - An array of sender email addresses to be unsubscribed. 29 | * @returns A Promise that resolves to an array of failed email addresses. 30 | */ 31 | unsubscribeSenders(senderEmailAddresses: string[]): Promise; 32 | 33 | /** 34 | * Blocks the specified sender by their email address. 35 | * This creates a filter to automatically move emails from the sender to trash. 36 | * 37 | * @param senderEmailAddress - The email address of the sender to block. 38 | * @returns A promise that resolves when the sender has been blocked. 39 | */ 40 | blockSender(senderEmailAddress: string): Promise; 41 | 42 | /** 43 | * Sets a callback function to receive progress updates during fetch operations. 44 | * 45 | * @param callback - Function called with progress updates 46 | */ 47 | setProgressCallback?(callback: (progress: FetchProgress) => void): void; 48 | 49 | /** 50 | * Cancels any ongoing fetch operation. 51 | * 52 | * @returns A promise that resolves when the operation is cancelled 53 | */ 54 | cancelFetch?(): Promise; 55 | } 56 | -------------------------------------------------------------------------------- /src/data/services/page_interaction_service.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Service class for interacting with the Gmail page (must be used within content script). 3 | * Actions here are reused regardless of implementation of EmailRepo. 4 | */ 5 | export class PageInteractionService { 6 | /** 7 | * Opens a search for specific senders in Gmail interface. 8 | */ 9 | static searchEmailSenders(emails: string[]): void { 10 | // Concatenate emails 11 | const email = emails.join(" OR "); 12 | 13 | // Get the search input element 14 | const searchInput = document.querySelector( 15 | "input[name='q']", 16 | )! as HTMLInputElement; 17 | 18 | // Set the search input value to the email address 19 | searchInput.value = `from:(${email})`; 20 | 21 | // Submit the search form 22 | ( 23 | document.querySelector("button[aria-label='Search mail']") as HTMLElement 24 | ).click(); 25 | } 26 | 27 | /** 28 | * Extracts the email address of the account on the currently open tab. 29 | */ 30 | static getActiveTabEmailAccount(): string | undefined { 31 | const title = document.querySelector("title")?.textContent; 32 | return title?.split(" - ")[1].trim(); 33 | } 34 | 35 | static displayTutorial() { 36 | // Create an iframe element 37 | const iframe = document.createElement("iframe"); 38 | iframe.id = "inboxwhiz-tutorial"; 39 | iframe.src = chrome.runtime.getURL("tutorial/index.html"); 40 | 41 | // Style the iframe as a modal 42 | iframe.setAttribute("allowtransparency", "true"); 43 | iframe.style.backgroundColor = "transparent"; 44 | iframe.style.position = "fixed"; 45 | iframe.style.top = "50%"; 46 | iframe.style.left = "50%"; 47 | iframe.style.transform = "translate(-50%, -50%)"; 48 | iframe.style.width = "100%"; 49 | iframe.style.height = "100%"; 50 | iframe.style.border = "none"; 51 | iframe.style.zIndex = "10000"; 52 | 53 | // Append the iframe to the document body 54 | document.body.appendChild(iframe); 55 | } 56 | 57 | static closeTutorial() { 58 | const iframe = document.getElementById("inboxwhiz-tutorial"); 59 | iframe?.remove(); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gmail-declutter", 3 | "private": true, 4 | "version": "2.1.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "cross-env VITE_USE_MOCK=true vite", 8 | "build": "tsc -b && vite build", 9 | "preview": "vite preview", 10 | "test:ui": "playwright test", 11 | "test": "jest --passWithNoTests", 12 | "lint:js": "eslint . --fix", 13 | "lint:css": "stylelint **/*.css --fix", 14 | "lint:html": "htmlhint **/*.html", 15 | "lint:prettier": "prettier --write .", 16 | "lint": "npm run lint:js && npm run lint:css && npm run lint:html && npm run lint:prettier", 17 | "lint:dup": "jscpd ." 18 | }, 19 | "dependencies": { 20 | "@fortawesome/fontawesome-svg-core": "^6.7.2", 21 | "@fortawesome/free-brands-svg-icons": "^6.7.2", 22 | "@fortawesome/free-regular-svg-icons": "^6.7.2", 23 | "@fortawesome/free-solid-svg-icons": "^6.7.2", 24 | "@fortawesome/react-fontawesome": "^0.2.2", 25 | "jest-environment-jsdom": "^29.7.0", 26 | "react": "^19.0.0", 27 | "react-dom": "^19.0.0", 28 | "react-loading-skeleton": "^3.5.0" 29 | }, 30 | "devDependencies": { 31 | "@eslint/js": "^9.21.0", 32 | "@playwright/test": "^1.52.0", 33 | "@testing-library/jest-dom": "^6.6.3", 34 | "@testing-library/react": "^16.3.0", 35 | "@types/chrome": "^0.0.315", 36 | "@types/gapi.client.gmail-v1": "^0.0.4", 37 | "@types/jest": "^29.5.14", 38 | "@types/node": "^22.14.1", 39 | "@types/react": "^19.0.10", 40 | "@types/react-dom": "^19.0.4", 41 | "@vitejs/plugin-react": "^4.3.4", 42 | "cross-env": "^7.0.3", 43 | "eslint": "^9.21.0", 44 | "eslint-plugin-react-hooks": "^5.1.0", 45 | "eslint-plugin-react-refresh": "^0.4.19", 46 | "globals": "^15.15.0", 47 | "htmlhint": "^1.6.3", 48 | "identity-obj-proxy": "^3.0.0", 49 | "jest": "^29.7.0", 50 | "jscpd": "^4.0.5", 51 | "prettier": "3.5.3", 52 | "stylelint": "^16.18.0", 53 | "stylelint-config-standard": "^38.0.0", 54 | "ts-jest": "^29.3.2", 55 | "ts-node": "^10.9.2", 56 | "typescript": "~5.7.2", 57 | "typescript-eslint": "^8.24.1", 58 | "vite": "^6.2.0" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "path"; 2 | import { defineConfig } from "vite"; 3 | import react from "@vitejs/plugin-react"; 4 | import fs from "fs"; 5 | import path from "path"; 6 | 7 | const root = resolve(__dirname, "src"); 8 | const outDir = resolve(__dirname, "dist"); 9 | const publicDir = resolve(__dirname, "public"); 10 | 11 | // https://vite.dev/config/ 12 | export default defineConfig({ 13 | root, 14 | publicDir: publicDir, 15 | plugins: [ 16 | react(), 17 | { 18 | name: "strip-presentation-apps", 19 | closeBundle() { 20 | const baseDir = path.join(outDir, "presentation", "apps"); 21 | if (!fs.existsSync(baseDir)) return; 22 | 23 | // Move every subfolder of presentation/apps to dist root 24 | const apps = fs.readdirSync(baseDir); 25 | for (const app of apps) { 26 | const from = path.join(baseDir, app); 27 | const to = path.join(outDir, app); 28 | 29 | // Remove existing target if necessary 30 | if (fs.existsSync(to)) 31 | fs.rmSync(to, { recursive: true, force: true }); 32 | fs.renameSync(from, to); 33 | } 34 | 35 | // Remove now-empty parent folders 36 | fs.rmSync(path.join(outDir, "presentation"), { 37 | recursive: true, 38 | force: true, 39 | }); 40 | }, 41 | }, 42 | ], 43 | build: { 44 | outDir, 45 | emptyOutDir: true, 46 | rollupOptions: { 47 | input: { 48 | main: resolve(root, "index.html"), 49 | sidebar: resolve(root, "presentation/apps/sidebar/index.html"), 50 | popup: resolve(root, "presentation/apps/popup/index.html"), 51 | tutorial: resolve(root, "presentation/apps/tutorial/index.html"), 52 | content_script: resolve(root, "data/content_scripts/content.ts"), 53 | }, 54 | output: { 55 | entryFileNames: (chunkInfo) => { 56 | if (chunkInfo.name === "content_script") { 57 | return "[name].js"; 58 | } 59 | return "assets/[name].js"; 60 | }, 61 | chunkFileNames: `assets/[name].js`, 62 | assetFileNames: `assets/[name].[ext]`, 63 | }, 64 | }, 65 | }, 66 | }); 67 | -------------------------------------------------------------------------------- /src/presentation/apps/popup/Popup.tsx: -------------------------------------------------------------------------------- 1 | import "./Popup.css"; 2 | 3 | const supportLink = "https://www.inboxwhiz.net/support.html"; 4 | const donateLink = "https://buymeacoffee.com/inboxwhiz"; 5 | const feedbackLink = 6 | "https://chromewebstore.google.com/detail/inboxwhiz/bjcegpgebdbhkkhngbahpfjfolcmkpma/reviews"; 7 | const version = "2.1.0"; 8 | 9 | const PopupApp = () => { 10 | const openGmail = () => { 11 | window.open("https://mail.google.com", "_blank"); 12 | }; 13 | 14 | return ( 15 |
16 |

InboxWhiz

17 |

Manage your inbox effortlessly with InboxWhiz!

18 | 19 | 37 | 38 |
39 |

40 | Need help? Have suggestions?{" "} 41 | 42 | Contact Us 43 | 44 |

45 |
46 |

47 | Support Development:{" "} 48 | 49 | Donate Here 50 | 51 | 💖 52 |

53 |
54 |

55 | Loved the tool? {" "} 56 | 57 | Leave a Review 58 | 59 | ⭐ 60 |

61 |
62 |
63 | 64 |
65 |

Version {version}

66 |
67 |
68 | ); 69 | }; 70 | 71 | export default PopupApp; 72 | -------------------------------------------------------------------------------- /test/ui/sidebar/delete.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | import { selectAliceBob, setupSidebarTest } from "./helpers"; 3 | 4 | test.describe("UI tests for Epic 2 - Delete Functionality", () => { 5 | const logs: string[] = []; 6 | 7 | test.beforeEach(async ({ page }) => { 8 | await setupSidebarTest(page, logs); 9 | }); 10 | 11 | test("2.1 - shows delete confirmation popup with correct counts and buttons", async ({ 12 | page, 13 | }) => { 14 | // Select two senders 15 | await selectAliceBob(page, "delete"); 16 | 17 | const modal = page.locator("#delete-confirm-modal"); 18 | await expect(modal).toBeVisible(); 19 | await expect(modal).toContainText("2 sender(s)"); 20 | await expect(modal).toContainText("110 email(s)"); 21 | await expect( 22 | page.getByRole("button", { name: "Show all emails" }), 23 | ).toBeVisible(); 24 | await expect(page.getByRole("button", { name: "Delete" })).toBeVisible(); 25 | }); 26 | 27 | test("2.2 - “Show all senders” opens Gmail search, modal persists", async ({ 28 | page, 29 | }) => { 30 | // select two senders 31 | await selectAliceBob(page, "delete"); 32 | 33 | // click "Show all emails" button 34 | await page.getByRole("button", { name: "Show all emails" }).click(); 35 | 36 | // check that the search function was called 37 | expect(logs).toContain( 38 | "[MOCK] Searching email senders: [alice@email.com, bob@email.com]", 39 | ); 40 | 41 | // check that the modal is still visible 42 | const modal = page.locator("#delete-confirm-modal"); 43 | await expect(modal).toBeVisible(); 44 | }); 45 | 46 | test("2.3 - clicking “Confirm” triggers deletion", async ({ page }) => { 47 | // Select two senders 48 | await selectAliceBob(page, "delete"); 49 | 50 | // Confirm deletion 51 | await page.getByRole("button", { name: "Confirm" }).click(); 52 | 53 | // "Success" modal appears at the end 54 | const modal = page.locator("#delete-success-modal"); 55 | await expect(modal).toBeVisible(); 56 | 57 | // Delete function was called with correct senders 58 | expect(logs).toContain( 59 | "[MOCK] Deleting senders: [alice@email.com, bob@email.com]", 60 | ); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /test/ui/sidebar/helpers.ts: -------------------------------------------------------------------------------- 1 | import { Page } from "@playwright/test"; 2 | 3 | export const selectAliceBob = async ( 4 | page: Page, 5 | action: "delete" | "unsubscribe", 6 | ) => { 7 | // Helper function to select Alice and Bob senders, then click action 8 | await page 9 | .locator("div") 10 | .filter({ hasText: /^Alicealice@email\.com32$/ }) 11 | .getByRole("checkbox") 12 | .check(); 13 | await page 14 | .locator("div") 15 | .filter({ hasText: /^Bobbob@email\.com78$/ }) 16 | .getByRole("checkbox") 17 | .check(); 18 | await page.click(`#${action}-button`); 19 | }; 20 | 21 | export const selectEveFrank = async ( 22 | page: Page, 23 | action: "delete" | "unsubscribe", 24 | ) => { 25 | // Helper function to select Eve and Frank senders, then click action 26 | await page 27 | .locator("div") 28 | .filter({ hasText: /^Eveeve@email\.com49$/ }) 29 | .getByRole("checkbox") 30 | .check(); 31 | await page 32 | .locator("div") 33 | .filter({ hasText: /^Frankfrank@email\.com12$/ }) 34 | .getByRole("checkbox") 35 | .check(); 36 | await page.click(`#${action}-button`); 37 | }; 38 | 39 | export const setupSidebarTest = async (page: Page, logs: string[]) => { 40 | await page.goto("/presentation/apps/sidebar/"); 41 | 42 | logs.length = 0; // reset logs before each test 43 | page.on("console", (msg) => logs.push(msg.text())); 44 | 45 | // Load senders 46 | await page.locator("#load-senders").click(); 47 | }; 48 | 49 | export const waitForProgressToComplete = async ( 50 | page: Page, 51 | timeout = 10000, 52 | ) => { 53 | // Wait for progress bar to appear and then disappear 54 | await page.waitForSelector(".fetch-progress-container", { 55 | state: "visible", 56 | timeout: 5000, 57 | }); 58 | 59 | await page.waitForSelector(".fetch-progress-container", { 60 | state: "hidden", 61 | timeout, 62 | }); 63 | }; 64 | 65 | export const getProgressPercentage = async (page: Page): Promise => { 66 | const percentageText = await page 67 | .locator(".progress-percentage") 68 | .textContent(); 69 | return parseInt(percentageText?.replace("%", "") || "0"); 70 | }; 71 | 72 | export const isProgressBarVisible = async (page: Page): Promise => { 73 | return await page.locator(".fetch-progress-container").isVisible(); 74 | }; 75 | -------------------------------------------------------------------------------- /src/presentation/apps/sidebar/utils/unsubscribeFlow.tsx: -------------------------------------------------------------------------------- 1 | import { useApp } from "../../../providers/app_provider"; 2 | import { useModal } from "../providers/modalContext"; 3 | 4 | export function useUnsubscribeFlow( 5 | deleteEmails: boolean, 6 | blockSenders: boolean, 7 | ) { 8 | const { setModal } = useModal(); 9 | const { 10 | selectedSenders, 11 | clearSelectedSenders, 12 | unsubscribeSenders, 13 | deleteSenders, 14 | reloadSenders, 15 | blockSender, 16 | } = useApp(); 17 | 18 | let failedSenders: string[]; 19 | 20 | // Kick off the flow 21 | const startUnsubscribeFlow = async () => { 22 | // Set modal to pending state 23 | setModal({ 24 | action: "unsubscribe", 25 | type: "pending", 26 | subtype: "working", 27 | }); 28 | 29 | // Attempt to unsubscribe all senders automatically 30 | failedSenders = await unsubscribeSenders(Object.keys(selectedSenders)); 31 | 32 | // Start processing failed senders by optionally blocking 33 | processNextBlock(0); 34 | }; 35 | 36 | // End the flow 37 | const endUnsubscribeFlow = async () => { 38 | // Delete senders if needed 39 | if (deleteEmails) { 40 | setModal({ action: "delete", type: "pending" }); 41 | await deleteSenders(Object.keys(selectedSenders)); 42 | } 43 | 44 | // Block senders if needed 45 | if (blockSenders) { 46 | setModal({ action: "unsubscribe", type: "pending", subtype: "blocking" }); 47 | for (const email of Object.keys(selectedSenders)) { 48 | if (!failedSenders.includes(email)) { 49 | await blockSender(email); 50 | } 51 | } 52 | } 53 | 54 | // Deselect all senders 55 | clearSelectedSenders(); 56 | 57 | // Show success modal and refresh senders 58 | setModal({ action: "unsubscribe", type: "success" }); 59 | reloadSenders(); 60 | }; 61 | 62 | // Process one block-only sender at `i` 63 | const processNextBlock = async (i: number) => { 64 | if (i >= failedSenders.length) { 65 | endUnsubscribeFlow(); 66 | return; 67 | } 68 | 69 | const email = failedSenders[i]; 70 | 71 | setModal({ 72 | action: "unsubscribe", 73 | type: "error", 74 | extras: { 75 | email, 76 | onContinue: () => { 77 | processNextBlock(i + 1); 78 | }, 79 | }, 80 | }); 81 | }; 82 | 83 | return { startUnsubscribeFlow }; 84 | } 85 | -------------------------------------------------------------------------------- /src/presentation/apps/sidebar/components/fetchProgress.css: -------------------------------------------------------------------------------- 1 | .fetch-progress-container { 2 | padding: 20px; 3 | background-color: var(--bg-primary); 4 | border: 1px solid var(--border-color); 5 | border-radius: 8px; 6 | margin: 0 5px; 7 | } 8 | 9 | .fetch-progress-header { 10 | display: flex; 11 | justify-content: space-between; 12 | align-items: center; 13 | margin-bottom: 15px; 14 | } 15 | 16 | .fetch-progress-header h3 { 17 | margin: 0; 18 | font-size: 16px; 19 | color: var(--text-primary); 20 | } 21 | 22 | .cancel-button { 23 | background-color: #dc3545; 24 | color: white; 25 | border: none; 26 | border-radius: 4px; 27 | padding: 5px 15px; 28 | cursor: pointer; 29 | font-size: 14px; 30 | position: relative; 31 | } 32 | 33 | .cancel-button:hover { 34 | background-color: #c82333; 35 | } 36 | 37 | /* Custom tooltip that appears instantly */ 38 | .cancel-button::after { 39 | content: attr(data-tooltip); 40 | position: absolute; 41 | top: 100%; 42 | left: 50%; 43 | transform: translateX(-50%); 44 | margin-top: 8px; 45 | padding: 8px 4px; 46 | background-color: rgb(0 0 0 / 90%); 47 | color: white; 48 | font-size: 12px; 49 | width: 120px; 50 | text-align: center; 51 | border-radius: 4px; 52 | opacity: 0; 53 | pointer-events: none; 54 | transition: opacity 0.1s ease; 55 | z-index: 9999; 56 | } 57 | 58 | .cancel-button::before { 59 | content: ""; 60 | position: absolute; 61 | top: 100%; 62 | left: 50%; 63 | transform: translateX(-50%); 64 | margin-top: 2px; 65 | border: 5px solid transparent; 66 | border-bottom-color: rgb(0 0 0 / 90%); 67 | opacity: 0; 68 | pointer-events: none; 69 | transition: opacity 0.1s ease; 70 | z-index: 9999; 71 | } 72 | 73 | .cancel-button:hover::after, 74 | .cancel-button:hover::before { 75 | opacity: 1; 76 | } 77 | 78 | .progress-stats { 79 | display: flex; 80 | justify-content: space-between; 81 | font-size: 14px; 82 | color: var(--text-secondary); 83 | margin-bottom: 10px; 84 | } 85 | 86 | .progress-bar-container { 87 | width: 100%; 88 | height: 20px; 89 | background-color: var(--button-bg); 90 | border-radius: 10px; 91 | overflow: hidden; 92 | margin-bottom: 5px; 93 | } 94 | 95 | .progress-bar-fill { 96 | height: 100%; 97 | background-color: #28a745; 98 | transition: width 0.3s ease; 99 | } 100 | 101 | .progress-percentage { 102 | text-align: center; 103 | font-weight: bold; 104 | font-size: 14px; 105 | color: var(--text-primary); 106 | } 107 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # InboxWhiz - Chrome Extension 2 | 3 | A live Chrome extension that helps declutter Gmail inboxes through intelligent sender analysis and bulk email management. 4 | 5 | **🔗 [Available on Chrome Web Store](https://chromewebstore.google.com/detail/inboxwhiz-bulk-unsubscrib/bjcegpgebdbhkkhngbahpfjfolcmkpma)** 6 | 7 | **🌐 [Visit InboxWhiz Website](https://www.inboxwhiz.net/)** 8 | 9 | ## 🎯 Project Overview 10 | 11 | InboxWhiz addresses the common problem of email overload by providing users with actionable insights about their email patterns and efficient tools to manage unwanted messages. The extension integrates seamlessly with Gmail's interface and processes thousands of emails locally for optimal performance. 12 | 13 | ## ⚡ Key Features 14 | 15 | - **Smart Sender Analytics**: Analyzes email patterns to identify top senders by frequency and volume 16 | - **Bulk Email Management**: Select multiple senders and perform actions on all their emails simultaneously 17 | - **Automated Unsubscribe**: Intelligent detection and execution of unsubscribe processes 18 | - ~~**Gmail API Integration**: Secure read/write access to Gmail data with OAuth authentication~~ (deprecated in favor of browser automation to bypass CASA fees) 19 | - **Chrome Side Panel UI**: Modern, responsive interface built with React and TypeScript 20 | - **Interactive Tutorial System**: Guided onboarding with contextual help 21 | 22 | ## 🛠️ Technical Architecture 23 | 24 | ### Frontend Stack 25 | 26 | - **React 19** with **TypeScript** for type-safe component development 27 | - **Vite** for fast development builds and hot module replacement 28 | - **Chrome Extensions Manifest V3** for modern extension capabilities 29 | 30 | ### APIs & Integration 31 | 32 | - ~~**Gmail API** for email data access and manipulation~~ 33 | - ~~**Google OAuth 2.0** for secure user authentication~~ 34 | - **Chrome Extensions API** for side panel, content scripts, and background processes 35 | - **Chrome Storage API** for efficient local data caching 36 | 37 | ### Testing & Quality Assurance 38 | 39 | - **Comprehensive Test Suite**: Unit tests with Jest covering core business logic 40 | - **End-to-End Testing**: Playwright tests for critical user workflows including sender management, deletion, and unsubscribe flows 41 | - **Automated CI/CD Pipeline**: GitHub Actions workflows for automated testing, linting, and release management 42 | - **Code Quality Tools**: ESLint, Prettier, and Stylelint for consistent code standards 43 | 44 | ## 🔒 Security & Privacy 45 | 46 | - **Zero external data storage** - all processing happens client-side for privacy 47 | -------------------------------------------------------------------------------- /src/presentation/apps/sidebar/App.css: -------------------------------------------------------------------------------- 1 | .dark *::-webkit-scrollbar, 2 | .light *::-webkit-scrollbar { 3 | width: 12px; 4 | border-radius: 8px; 5 | } 6 | 7 | .dark *::-webkit-scrollbar { 8 | background: #222; 9 | } 10 | 11 | .light *::-webkit-scrollbar { 12 | background: #f0f0f0; 13 | } 14 | 15 | .dark *::-webkit-scrollbar-thumb, 16 | .light *::-webkit-scrollbar-thumb { 17 | border-radius: 8px; 18 | } 19 | 20 | .dark *::-webkit-scrollbar-thumb { 21 | background: #444; 22 | border: 2px solid #222; 23 | } 24 | 25 | .light *::-webkit-scrollbar-thumb { 26 | background: #ccc; 27 | border: 2px solid #f0f0f0; 28 | } 29 | 30 | .dark *::-webkit-scrollbar-thumb:hover { 31 | background: #666; 32 | } 33 | 34 | .light *::-webkit-scrollbar-thumb:hover { 35 | background: #999; 36 | } 37 | 38 | :root, 39 | .light { 40 | --bg-primary: #fff; 41 | --text-primary: #333; 42 | --text-secondary: #5f6368; 43 | --selected-bg: #c2dbff; 44 | --button-bg: #e9e9e9; 45 | --border-color: #c4c4c4; 46 | --line-color: #f2f6fc; 47 | --plain-button: #000; 48 | --plain-button-hover: #333; 49 | --modal-button-primary: #323232; 50 | --modal-button-primary-hover: #555; 51 | --modal-button-secondary: #fff; 52 | --modal-button-secondary-hover: #f4f4f4; 53 | } 54 | 55 | .dark { 56 | --bg-primary: #1e1e1e; 57 | --text-primary: #f0f0f0; 58 | --text-secondary: #b0b3b8; 59 | --selected-bg: #34527a; 60 | --button-bg: #3a3a3a; 61 | --border-color: #444; 62 | --line-color: #555; 63 | --plain-button: #555; 64 | --plain-button-hover: #333; 65 | --modal-button-primary: #fff; 66 | --modal-button-primary-hover: #bbb; 67 | --modal-button-secondary: #323232; 68 | --modal-button-secondary-hover: #444; 69 | } 70 | 71 | :root { 72 | background-color: var(--bg-primary); 73 | color: var(--text-primary); 74 | } 75 | 76 | #declutter-body { 77 | font-family: "Google Sans", Roboto, RobotoDraft, Helvetica, Arial, sans-serif; 78 | height: 97vh; 79 | display: flex; 80 | flex-direction: column; 81 | margin: 5px; 82 | } 83 | 84 | .declutter-header { 85 | background-color: var(--header-bg); 86 | } 87 | 88 | #declutter-body.login { 89 | height: 100vh; 90 | color: white; 91 | background-color: #233b86; 92 | align-items: center; 93 | justify-content: center; 94 | font-size: 1rem; 95 | margin: 0; 96 | } 97 | 98 | #declutter-body.login #email-account { 99 | margin-top: 7px; 100 | font-size: 0.75rem; 101 | } 102 | 103 | .button-bar { 104 | padding: 10px 5px 0 15px; 105 | min-width: 300px; 106 | display: flex; 107 | justify-content: space-between; 108 | align-items: center; 109 | } 110 | 111 | #senders { 112 | padding: 10px; 113 | overflow: hidden auto; 114 | flex-grow: 1; 115 | } 116 | -------------------------------------------------------------------------------- /test/ui/sidebar/senderManagement.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | import { setupSidebarTest } from "./helpers"; 3 | 4 | test.describe("UI tests for Epic 1 - Sender Management", () => { 5 | const logs: string[] = []; 6 | 7 | test.beforeEach(async ({ page }) => { 8 | await setupSidebarTest(page, logs); 9 | }); 10 | 11 | test("1.1 - displays senders sorted by email count", async ({ page }) => { 12 | // Wait for the senders list to be visible 13 | await page.waitForSelector("#senders"); 14 | await page.waitForSelector(".sender-line-real"); 15 | 16 | // Get all sender items 17 | const senderItems = await page.$$(".sender-line-real"); 18 | 19 | // Verify that it shows all senders (mock data has 20 senders) 20 | expect(senderItems.length).toBe(20); 21 | 22 | // Verify that the sender counts are sorted in descending order 23 | let max = Number.MAX_SAFE_INTEGER; 24 | for (const sender of senderItems) { 25 | const senderCountElement = await sender.$(".email-count"); 26 | const countText: string = 27 | (await senderCountElement!.textContent()) || "0"; 28 | const count: number = parseInt(countText); 29 | expect(count).toBeGreaterThanOrEqual(0); // Ensure count is a valid number 30 | expect(count).toBeLessThanOrEqual(max); // Ensure count is not greater than previous max 31 | max = count; // Update max for next iteration 32 | } 33 | }); 34 | 35 | test("1.1a - displays skeleton loader while loading senders", async ({ 36 | page, 37 | }) => { 38 | // Wait for the senders list to be visible 39 | await page.waitForSelector("#senders"); 40 | 41 | // Verify that skeleton loaders are displayed 42 | const skeletons = await page.$$(".sender-line-skeleton"); 43 | expect(skeletons.length).toBeGreaterThan(0); // Ensure there are skeletons 44 | }); 45 | 46 | test("1.2 - clicking a sender opens searches it on Gmail", async ({ 47 | page, 48 | }) => { 49 | // Click the first sender link 50 | await page.locator(".sender-email").first().click(); 51 | 52 | // Verify that the Gmail search function is called 53 | expect(logs).toContain("[MOCK] Searching email senders: [grace@email.com]"); 54 | }); 55 | 56 | test("1.3a - shows 'No senders' modal when no senders are selected and unsubscribe button is clicked", async ({ 57 | page, 58 | }) => { 59 | await page.locator("#unsubscribe-button").click(); 60 | const modal = page.locator("#no-sender-modal"); 61 | await expect(modal).toBeVisible(); 62 | }); 63 | 64 | test("1.3b - shows 'No senders' modal when no senders are selected and delete button is clicked", async ({ 65 | page, 66 | }) => { 67 | await page.locator("#delete-button").click(); 68 | const modal = page.locator("#no-sender-modal"); 69 | await expect(modal).toBeVisible(); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to InboxWhiz 2 | 3 | Thank you for considering contributing to **InboxWhiz**! ❤️ 4 | 5 | This document will help you set up a local development environment, understand the project structure, and submit high-quality contributions. 6 | 7 | ## License Agreement 8 | 9 | By submitting a pull request, you agree to license your contributions under the **GNU General Public License v3.0 (GPL-3.0)**, the same license as the project. See the [LICENSE](LICENSE) file for details. 10 | 11 | ## Project Structure 12 | 13 | ``` 14 | public/ # Contains files that do not need to be pre-bundled 15 | src/ 16 | data/ 17 | content_scripts/ # Connects repositories with services 18 | repositories/ # Implements browser/email/storage logic outside DOM 19 | services/ # Implements logic inside DOM 20 | domain/ 21 | entities/ # Shared models & types 22 | repositories/ # Interfaces for repositories 23 | presentation/ 24 | apps/ # UI & entry points 25 | providers/ # React context providers 26 | test/ # Automated tests 27 | docs/ # Documentation 28 | ``` 29 | 30 | ![Structure diagram](docs/architecture_structure.jpg) 31 | 32 | --- 33 | 34 | ## Setting Up Locally 35 | 36 | ### 1. Clone and install 37 | 38 | ```bash 39 | git clone https://github.com/InboxWhiz/gmail-declutter-extension.git 40 | cd gmail-declutter-extension 41 | npm install 42 | ``` 43 | 44 | ### 2. Preview UI 45 | 46 | ```bash 47 | npm run dev 48 | ``` 49 | 50 | This will run a **local development server with mock implementations of repositories**. 51 | 52 | - **If you only want to work on UI**, you can use this local server. 53 | - **If you want to load it into the browser as a proper extension**, follow the next step: 54 | 55 | ### 3. Load into Chrome 56 | 57 | ```bash 58 | npm run build 59 | ``` 60 | 61 | This will create a development bundle inside the `dist/` folder. 62 | 63 | 1. Open `chrome://extensions/` in Chrome. 64 | 2. Enable **Developer Mode** (toggle at top right). 65 | 3. Click **Load Unpacked** and select the `dist/` folder. 66 | 67 | The extension should now appear in your browser. 68 | 69 | ## Coding Guidelines 70 | 71 | ### Style 72 | 73 | Automated formatting and linting tests will run on all PRs. Run before committing to cut down on GH workflow failures: 74 | 75 | ```bash 76 | npm run lint 77 | ``` 78 | 79 | ## Pull Request Process 80 | 81 | 1. **Fork the repo** and create your branch from `main`: 82 | 83 | ```bash 84 | git checkout -b feature/my-awesome-change 85 | ``` 86 | 87 | 2. **Test locally** to ensure nothing breaks. 88 | 89 | Where appropriate, please write relevant tests to cover your changes. This helps us maintain code quality and prevents future regressions. 90 | 91 | ```bash 92 | npm run test 93 | npm run test:ui 94 | ``` 95 | 96 | 3. **Write a clear PR description**: 97 | 98 | - What feature/fix does this add? 99 | - reference any existing issues by typing `Fixes #` in your pull request 100 | - Screenshots (if UI changes). 101 | 102 | 4. **Submit your PR** to `main`. 103 | 104 | - Resolve any merge conflicts. 105 | - PRs will be reviewed for correctness, clarity, and consistency. 106 | - Address feedback if requested. 107 | 108 | ## Need Help? 109 | 110 | - Open a GitHub Issue with the `question` label. 111 | - For quick clarifications, leave a comment on the relevant code or PR. 112 | -------------------------------------------------------------------------------- /src/presentation/providers/theme_provider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect, useMemo, useState } from "react"; 2 | import { 3 | ThemeContext, 4 | ThemeMode, 5 | ThemeSetting, 6 | defaultThemeContext, 7 | } from "./theme_context"; 8 | 9 | const STORAGE_KEY = "themeSetting"; 10 | 11 | function isExtContext() { 12 | return typeof chrome !== "undefined" && !!chrome.storage?.local; 13 | } 14 | 15 | function getSystemPrefersDark(): boolean { 16 | if (typeof window === "undefined" || !window.matchMedia) return false; 17 | return window.matchMedia("(prefers-color-scheme: dark)").matches; 18 | } 19 | 20 | async function readSetting(): Promise { 21 | try { 22 | if (isExtContext()) { 23 | const setting = await new Promise((resolve) => { 24 | chrome.storage.local.get([STORAGE_KEY], (res) => { 25 | resolve((res?.[STORAGE_KEY] as ThemeSetting | undefined) ?? null); 26 | }); 27 | }); 28 | return setting; 29 | } else { 30 | const raw = localStorage.getItem(STORAGE_KEY); 31 | return (raw as ThemeSetting | null) ?? null; 32 | } 33 | } catch { 34 | return null; 35 | } 36 | } 37 | 38 | async function writeSetting(setting: ThemeSetting) { 39 | try { 40 | if (isExtContext()) { 41 | await new Promise((resolve) => { 42 | chrome.storage.local.set({ [STORAGE_KEY]: setting }, () => resolve()); 43 | }); 44 | } else { 45 | localStorage.setItem(STORAGE_KEY, setting); 46 | } 47 | } catch { 48 | // ignore 49 | } 50 | } 51 | 52 | export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ 53 | children, 54 | }) => { 55 | const [setting, setSettingState] = useState("system"); 56 | const [systemPrefersDark, setSystemPrefersDark] = useState( 57 | getSystemPrefersDark(), 58 | ); 59 | const [isInitialized, setIsInitialized] = useState(false); 60 | 61 | useEffect(() => { 62 | (async () => { 63 | const saved = await readSetting(); 64 | if (saved === "light" || saved === "dark" || saved === "system") { 65 | setSettingState(saved); 66 | } else { 67 | setSettingState("system"); 68 | } 69 | setIsInitialized(true); 70 | })(); 71 | }, []); 72 | 73 | useEffect(() => { 74 | if (!isInitialized) return; 75 | void writeSetting(setting); 76 | }, [setting, isInitialized]); 77 | 78 | useEffect(() => { 79 | if (typeof window === "undefined" || !window.matchMedia) return; 80 | const mql = window.matchMedia("(prefers-color-scheme: dark)"); 81 | const handler = (e: MediaQueryListEvent) => setSystemPrefersDark(e.matches); 82 | mql.addEventListener("change", handler); 83 | return () => mql.removeEventListener("change", handler); 84 | }, []); 85 | 86 | const theme: ThemeMode = useMemo(() => { 87 | if (setting === "system") { 88 | return systemPrefersDark ? "dark" : "light"; 89 | } 90 | return setting; 91 | }, [setting, systemPrefersDark]); 92 | 93 | useEffect(() => { 94 | const root = document.documentElement; 95 | root.classList.remove("light", "dark"); 96 | root.classList.add(theme); 97 | }, [theme]); 98 | 99 | const setSetting = (s: ThemeSetting) => { 100 | setSettingState(s); 101 | }; 102 | 103 | const resetToSystem = () => { 104 | setSettingState("system"); 105 | }; 106 | 107 | const toggleTheme = () => { 108 | if (setting === "system") { 109 | setSettingState(theme === "light" ? "dark" : "light"); 110 | return; 111 | } 112 | setSettingState(setting === "light" ? "dark" : "light"); 113 | }; 114 | 115 | if (!isInitialized) { 116 | return
; 117 | } 118 | 119 | return ( 120 | 123 | {children} 124 | 125 | ); 126 | }; 127 | 128 | export const useTheme = () => useContext(ThemeContext) || defaultThemeContext; 129 | -------------------------------------------------------------------------------- /src/presentation/apps/tutorial/components/steps.tsx: -------------------------------------------------------------------------------- 1 | import { SuccessIcon } from "./successIcon"; 2 | 3 | /** 4 | * Returns the appropriate asset URL depending on the environment. 5 | * 6 | * In a Chrome extension environment, it uses `chrome.runtime.getURL` to resolve the asset path. 7 | * Otherwise, it falls back to the raw path or a provided development path. 8 | * 9 | * @param prodPath - The path to the asset in the production (extension) environment. 10 | * @param devPath - (Optional) The path to the asset in the development environment. 11 | * @returns The resolved asset URL for the current environment. 12 | */ 13 | export const getAssetUrl = (prodPath: string, devPath?: string) => { 14 | if (typeof chrome !== "undefined" && chrome.runtime?.getURL) { 15 | return chrome.runtime.getURL(prodPath); 16 | } 17 | return devPath ?? prodPath; 18 | }; 19 | 20 | const openSidePanel = () => { 21 | chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { 22 | chrome.sidePanel.open({ 23 | tabId: tabs[0]?.id, 24 | } as chrome.sidePanel.OpenOptions); 25 | }); 26 | }; 27 | 28 | const closeTutorial = () => { 29 | chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { 30 | const tab = tabs[0]; 31 | if (tab && tab.id !== undefined) { 32 | chrome.tabs.sendMessage(tab.id, { 33 | action: "CLOSE_TUTORIAL", 34 | }); 35 | } 36 | }); 37 | }; 38 | 39 | export const WelcomeStep = ({ onNext }: { onNext: () => void }) => { 40 | return ( 41 |
42 | Welcome 48 |

Welcome to InboxWhiz!

49 |

Declutter your Gmail in seconds.

50 | 53 |
54 | ); 55 | }; 56 | 57 | export const Step1 = ({ onNext }: { onNext: () => void }) => { 58 | return ( 59 |
60 |

61 | Go to Gmail and click the InboxWhiz icon 62 |

63 | Extension icon demo 69 | 72 |
73 | ); 74 | }; 75 | 76 | export const Step2 = ({ onNext }: { onNext: () => void }) => { 77 | return ( 78 |
79 |

See your top senders

80 | Top Senders 86 | 89 |
90 | ); 91 | }; 92 | 93 | export const Step3 = ({ onNext }: { onNext: () => void }) => { 94 | return ( 95 |
96 |

97 | Click Delete or Unsubscribe to clean up your inbox 98 |

99 | Unsubscribe 105 | 108 |
109 | ); 110 | }; 111 | 112 | export const Success = () => { 113 | return ( 114 |
115 |
123 |
124 |

128 | 129 | You're all set! 130 |

131 |
132 |
133 |

You are ready to clean up your inbox.

134 | 135 | 144 | 145 |
146 |
147 | ); 148 | }; 149 | -------------------------------------------------------------------------------- /src/data/content_scripts/content.ts: -------------------------------------------------------------------------------- 1 | // src/data/content_scripts/content.ts 2 | import { BrowserEmailService } from "../services/browser_email_service"; 3 | import { PageInteractionService } from "../services/page_interaction_service"; 4 | 5 | let currentAbortController: AbortController | null = null; 6 | 7 | // Establish connection with the side panel 8 | chrome.runtime.onConnect.addListener(function (port) { 9 | console.assert(port.name === "gmail-port"); 10 | port.postMessage({ message: "Content script connected" }); 11 | 12 | port.onMessage.addListener(function (msg) { 13 | console.log("sidepanel said: ", msg); 14 | 15 | if (msg.action === "FETCH_SENDERS") { 16 | fetchSenders(port); 17 | } else if (msg.action === "DELETE_SENDERS") { 18 | deleteSenders(port, msg.emails); 19 | } else if (msg.action === "UNSUBSCRIBE_SENDERS") { 20 | unsubscribeSenders(port, msg.emails); 21 | } else if (msg.action === "BLOCK_SENDER") { 22 | blockSender(port, msg.email); 23 | } else if (msg.action === "CANCEL_FETCH") { 24 | if (currentAbortController) { 25 | currentAbortController.abort(); 26 | console.log("Fetch cancelled by user"); 27 | } 28 | } 29 | }); 30 | }); 31 | 32 | async function fetchSenders(port: chrome.runtime.Port) { 33 | try { 34 | // Create new abort controller for this fetch 35 | currentAbortController = new AbortController(); 36 | 37 | const senders = await BrowserEmailService.fetchSendersFromBrowser({ 38 | onProgress: (progress) => { 39 | // Send progress updates to the side panel 40 | port.postMessage({ 41 | action: "FETCH_PROGRESS", 42 | progress, 43 | }); 44 | }, 45 | batchSize: 10, 46 | signal: currentAbortController.signal, 47 | }); 48 | 49 | const serialized = senders.map((sender) => ({ 50 | email: sender.email, 51 | names: Array.from(sender.names), // convert Set -> array 52 | emailCount: sender.emailCount, 53 | })); 54 | 55 | port.postMessage({ 56 | action: "FETCH_SENDERS_RESPONSE", 57 | success: true, 58 | data: serialized, 59 | }); 60 | } catch (error) { 61 | port.postMessage({ 62 | action: "FETCH_SENDERS_RESPONSE", 63 | success: false, 64 | error: (error as Error).message, 65 | }); 66 | } finally { 67 | currentAbortController = null; 68 | } 69 | } 70 | 71 | async function deleteSenders(port: chrome.runtime.Port, emails: string[]) { 72 | try { 73 | await BrowserEmailService.deleteSendersFromBrowser(emails); 74 | port.postMessage({ 75 | action: "DELETE_SENDERS_RESPONSE", 76 | success: true, 77 | }); 78 | } catch (error) { 79 | port.postMessage({ 80 | action: "DELETE_SENDERS_RESPONSE", 81 | success: false, 82 | error: (error as Error).message, 83 | }); 84 | } 85 | } 86 | 87 | async function unsubscribeSenders(port: chrome.runtime.Port, emails: string[]) { 88 | try { 89 | const failures = 90 | await BrowserEmailService.unsubscribeSendersFromBrowser(emails); 91 | port.postMessage({ 92 | action: "UNSUBSCRIBE_SENDERS_RESPONSE", 93 | success: true, 94 | failures: failures, 95 | }); 96 | } catch (error) { 97 | port.postMessage({ 98 | action: "UNSUBSCRIBE_SENDERS_RESPONSE", 99 | success: false, 100 | error: (error as Error).message, 101 | }); 102 | } 103 | } 104 | 105 | async function blockSender(port: chrome.runtime.Port, email: string) { 106 | try { 107 | await BrowserEmailService.blockSenderFromBrowser(email); 108 | port.postMessage({ 109 | action: "BLOCK_SENDER_RESPONSE", 110 | success: true, 111 | }); 112 | } catch (error) { 113 | port.postMessage({ 114 | action: "BLOCK_SENDER_RESPONSE", 115 | success: false, 116 | error: (error as Error).message, 117 | }); 118 | } 119 | } 120 | 121 | // Get email account 122 | chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { 123 | if (message.action === "GET_EMAIL_ACCOUNT") { 124 | const value = PageInteractionService.getActiveTabEmailAccount(); 125 | sendResponse({ result: value }); 126 | } 127 | }); 128 | 129 | // Search email senders 130 | chrome.runtime.onMessage.addListener((message) => { 131 | if (message.type === "SEARCH_EMAIL_SENDERS") { 132 | PageInteractionService.searchEmailSenders(message.emails); 133 | } 134 | }); 135 | 136 | // Show tutorial 137 | chrome.runtime.onMessage.addListener((message) => { 138 | if (message.action === "SHOW_TUTORIAL") { 139 | PageInteractionService.displayTutorial(); 140 | } 141 | }); 142 | 143 | // Close tutorial 144 | chrome.runtime.onMessage.addListener((message) => { 145 | if (message.action === "CLOSE_TUTORIAL") { 146 | PageInteractionService.closeTutorial(); 147 | } 148 | }); 149 | -------------------------------------------------------------------------------- /src/data/repositories/browser_email_repo.ts: -------------------------------------------------------------------------------- 1 | import { Sender } from "../../domain/entities/sender"; 2 | import { EmailRepo } from "../../domain/repositories/email_repo"; 3 | import { FetchProgress } from "../../domain/types/progress"; 4 | import { PortManager } from "../ports/port_manager"; 5 | 6 | export class BrowserEmailRepo implements EmailRepo { 7 | private onProgressCallback?: (progress: FetchProgress) => void; 8 | 9 | setProgressCallback(callback: (progress: FetchProgress) => void): void { 10 | this.onProgressCallback = callback; 11 | } 12 | 13 | async cancelFetch(): Promise { 14 | const port = PortManager.gmailPort; 15 | if (!port) return Promise.reject("Port not connected"); 16 | port.postMessage({ action: "CANCEL_FETCH" }); 17 | return Promise.resolve(); 18 | } 19 | 20 | async fetchSenders(): Promise { 21 | const port = PortManager.gmailPort; 22 | if (!port) return Promise.reject("Port not connected"); 23 | 24 | port.postMessage({ action: "FETCH_SENDERS" }); 25 | 26 | return await new Promise((resolve, reject) => { 27 | const listener = (msg: any) => { 28 | if (msg.action === "FETCH_PROGRESS" && this.onProgressCallback) { 29 | this.onProgressCallback(msg.progress); 30 | } else if (msg.action === "FETCH_SENDERS_RESPONSE") { 31 | port.onMessage.removeListener(listener); 32 | 33 | if (msg.success) { 34 | const senders = msg.data as Sender[]; 35 | senders.sort((a, b) => b.emailCount - a.emailCount); 36 | resolve(senders); 37 | } else { 38 | console.error( 39 | `Error fetching senders from content script: ${msg.error}`, 40 | ); 41 | reject(new Error(msg.error)); 42 | } 43 | } 44 | }; 45 | port.onMessage.addListener(listener); 46 | }); 47 | } 48 | 49 | async deleteSenders(senderEmailAddresses: string[]): Promise { 50 | const port = PortManager.gmailPort; 51 | if (!port) return Promise.reject("Port not connected"); 52 | 53 | // Send message 54 | port.postMessage({ 55 | action: "DELETE_SENDERS", 56 | emails: senderEmailAddresses, 57 | }); 58 | 59 | // Wait for response 60 | return await new Promise((resolve, reject) => { 61 | const listener = (msg: any) => { 62 | if (msg.action === "DELETE_SENDERS_RESPONSE") { 63 | port.onMessage.removeListener(listener); 64 | 65 | if (msg.success) { 66 | resolve(); 67 | } else { 68 | console.error( 69 | `Error deleting senders from content script: ${msg.error}`, 70 | ); 71 | reject(new Error(msg.error)); 72 | } 73 | } 74 | }; 75 | port.onMessage.addListener(listener); 76 | }); 77 | } 78 | 79 | async unsubscribeSenders(senderEmailAddresses: string[]): Promise { 80 | const port = PortManager.gmailPort; 81 | if (!port) return Promise.reject("Port not connected"); 82 | 83 | // Send message 84 | port.postMessage({ 85 | action: "UNSUBSCRIBE_SENDERS", 86 | emails: senderEmailAddresses, 87 | }); 88 | 89 | // Wait for response 90 | return await new Promise((resolve, reject) => { 91 | const listener = (msg: any) => { 92 | if (msg.action === "UNSUBSCRIBE_SENDERS_RESPONSE") { 93 | port.onMessage.removeListener(listener); 94 | 95 | if (msg.success) { 96 | resolve(msg.failures as string[]); 97 | } else { 98 | console.error( 99 | `Error unsubscribing senders from content script: ${msg.error}`, 100 | ); 101 | reject(new Error(msg.error)); 102 | } 103 | } 104 | }; 105 | port.onMessage.addListener(listener); 106 | }); 107 | } 108 | 109 | async blockSender(senderEmailAddress: string): Promise { 110 | const port = PortManager.gmailPort; 111 | if (!port) return Promise.reject("Port not connected"); 112 | 113 | // Send message 114 | port.postMessage({ 115 | action: "BLOCK_SENDER", 116 | email: senderEmailAddress, 117 | }); 118 | 119 | // Wait for response 120 | return await new Promise((resolve, reject) => { 121 | const listener = (msg: any) => { 122 | if (msg.action === "BLOCK_SENDER_RESPONSE") { 123 | port.onMessage.removeListener(listener); 124 | 125 | if (msg.success) { 126 | resolve(); 127 | } else { 128 | console.error( 129 | `Error blocking sender from content script: ${msg.error}`, 130 | ); 131 | reject(new Error(msg.error)); 132 | } 133 | } 134 | }; 135 | port.onMessage.addListener(listener); 136 | }); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/data/repositories/mocks/mock_email_repo.ts: -------------------------------------------------------------------------------- 1 | // src/data/repositories/mocks/mock_email_repo.ts 2 | import { Sender } from "../../../domain/entities/sender"; 3 | import { EmailRepo } from "../../../domain/repositories/email_repo"; 4 | import { FetchProgress } from "../../../domain/types/progress"; 5 | 6 | export class MockEmailRepo implements EmailRepo { 7 | private mockSenders: Sender[] = [ 8 | { email: "alice@email.com", names: new Set(["Alice"]), emailCount: 32 }, 9 | { email: "bob@email.com", names: new Set(["Bob"]), emailCount: 78 }, 10 | { email: "carol@email.com", names: new Set(["Carol"]), emailCount: 15 }, 11 | { email: "dave@email.com", names: new Set(["Dave"]), emailCount: 56 }, 12 | { email: "eve@email.com", names: new Set(["Eve"]), emailCount: 49 }, 13 | { email: "frank@email.com", names: new Set(["Frank"]), emailCount: 12 }, 14 | { email: "grace@email.com", names: new Set(["Grace"]), emailCount: 91 }, 15 | { email: "heidi@email.com", names: new Set(["Heidi"]), emailCount: 27 }, 16 | { email: "ivan@email.com", names: new Set(["Ivan"]), emailCount: 68 }, 17 | { email: "judy@email.com", names: new Set(["Judy"]), emailCount: 39 }, 18 | { email: "mallory@email.com", names: new Set(["Mallory"]), emailCount: 50 }, 19 | { email: "niaj@email.com", names: new Set(["Niaj"]), emailCount: 83 }, 20 | { email: "olivia@email.com", names: new Set(["Olivia"]), emailCount: 21 }, 21 | { email: "peggy@email.com", names: new Set(["Peggy"]), emailCount: 74 }, 22 | { email: "quentin@email.com", names: new Set(["Quentin"]), emailCount: 59 }, 23 | { email: "rupert@email.com", names: new Set(["Rupert"]), emailCount: 34 }, 24 | { email: "sybil@email.com", names: new Set(["Sybil"]), emailCount: 88 }, 25 | { email: "trent@email.com", names: new Set(["Trent"]), emailCount: 44 }, 26 | { email: "uma@email.com", names: new Set(["Uma"]), emailCount: 66 }, 27 | { email: "victor@email.com", names: new Set(["Victor"]), emailCount: 29 }, 28 | ]; 29 | 30 | private failingSenders: string[]; 31 | private progressCallback?: (progress: FetchProgress) => void; 32 | private abortController?: AbortController; 33 | private isProgressiveLoadEnabled: boolean = true; 34 | 35 | constructor(initialSenders: Sender[] = this.mockSenders) { 36 | this.mockSenders = initialSenders; 37 | this.failingSenders = ["eve@email.com", "frank@email.com"]; 38 | } 39 | 40 | setSenders(senders: Sender[]) { 41 | this.mockSenders = senders; 42 | } 43 | 44 | setFailingSenders(senders: string[]) { 45 | this.failingSenders = senders; 46 | } 47 | 48 | setProgressCallback(callback: (progress: FetchProgress) => void): void { 49 | this.progressCallback = callback; 50 | } 51 | 52 | setProgressiveLoadEnabled(enabled: boolean) { 53 | this.isProgressiveLoadEnabled = enabled; 54 | } 55 | 56 | async cancelFetch(): Promise { 57 | console.log("[MOCK] Cancel fetch requested"); 58 | this.abortController?.abort(); 59 | } 60 | 61 | // - Mock implementations - 62 | 63 | async fetchSenders(): Promise { 64 | console.log("[MOCK] Fetching senders..."); 65 | 66 | if (this.isProgressiveLoadEnabled && this.progressCallback) { 67 | // Simulate progressive loading 68 | const totalPages = 5; 69 | const emailsPerPage = 4; 70 | const totalEmails = this.mockSenders.length; 71 | 72 | this.abortController = new AbortController(); 73 | 74 | for (let page = 1; page <= totalPages; page++) { 75 | // Check if cancelled 76 | if (this.abortController.signal.aborted) { 77 | console.log("[MOCK] Fetch cancelled"); 78 | throw new Error("Fetch cancelled"); 79 | } 80 | 81 | // Simulate page processing delay 82 | await new Promise((resolve) => setTimeout(resolve, 200)); 83 | 84 | // Report progress 85 | const progress: FetchProgress = { 86 | currentPage: page, 87 | totalPages: totalPages, 88 | processedEmails: Math.min(page * emailsPerPage, totalEmails), 89 | totalEmails: totalEmails, 90 | percentage: Math.round((page / totalPages) * 100), 91 | }; 92 | this.progressCallback(progress); 93 | } 94 | } else { 95 | // Original behavior without progress 96 | await new Promise((resolve) => setTimeout(resolve, 500)); 97 | } 98 | 99 | this.mockSenders.sort((a, b) => b.emailCount - a.emailCount); 100 | return this.mockSenders; 101 | } 102 | 103 | async deleteSenders(senderEmailAddresses: string[]): Promise { 104 | console.log("[MOCK] Deleting senders:", senderEmailAddresses); 105 | this.mockSenders = this.mockSenders.filter( 106 | (sender) => !senderEmailAddresses.includes(sender.email), 107 | ); 108 | return Promise.resolve(); 109 | } 110 | 111 | async unsubscribeSenders(senderEmailAddresses: string[]): Promise { 112 | console.log("[MOCK] Unsubscribing senders:", senderEmailAddresses); 113 | const fails = this.failingSenders.filter((email) => 114 | senderEmailAddresses.includes(email), 115 | ); 116 | return Promise.resolve(fails); 117 | } 118 | 119 | async blockSender(senderEmailAddress: string): Promise { 120 | console.log("[MOCK] Blocking sender:", senderEmailAddress); 121 | this.mockSenders = this.mockSenders.filter( 122 | (sender) => sender.email !== senderEmailAddress, 123 | ); 124 | return Promise.resolve(); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /test/ui/sidebar/progressiveLoading.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | import { setupSidebarTest } from "./helpers"; 3 | 4 | test.describe("Progressive Loading functionality", () => { 5 | const logs: string[] = []; 6 | 7 | test.beforeEach(async ({ page }) => { 8 | await setupSidebarTest(page, logs); 9 | }); 10 | 11 | test("should display progress bar when loading senders", async ({ page }) => { 12 | // Progress container should appear 13 | const progressContainer = page.locator(".fetch-progress-container"); 14 | await expect(progressContainer).toBeVisible(); 15 | 16 | // Progress bar elements should be visible 17 | await expect(progressContainer.locator("h3")).toContainText( 18 | "Scanning inbox...", 19 | ); 20 | await expect(progressContainer.locator(".cancel-button")).toBeVisible(); 21 | await expect(progressContainer.locator(".progress-stats")).toBeVisible(); 22 | await expect( 23 | progressContainer.locator(".progress-bar-container"), 24 | ).toBeVisible(); 25 | }); 26 | 27 | test("should show progress updates during scan", async ({ page }) => { 28 | // Check initial progress state 29 | const progressStats = page.locator(".progress-stats"); 30 | await expect(progressStats).toContainText("Page"); 31 | await expect(progressStats).toContainText("emails processed"); 32 | 33 | // Check progress bar fill 34 | const progressBarFill = page.locator(".progress-bar-fill"); 35 | await expect(progressBarFill).toBeVisible(); 36 | 37 | // Verify percentage is shown 38 | const progressPercentage = page.locator(".progress-percentage"); 39 | await expect(progressPercentage).toBeVisible(); 40 | await expect(progressPercentage).toHaveText(/\d+%/); 41 | }); 42 | 43 | test("should handle cancel button during scan", async ({ page }) => { 44 | // Wait for progress to start 45 | const progressContainer = page.locator(".fetch-progress-container"); 46 | await expect(progressContainer).toBeVisible(); 47 | 48 | // Click cancel button 49 | await page.locator(".cancel-button").click(); 50 | 51 | // Progress should disappear 52 | await expect(progressContainer).not.toBeVisible(); 53 | 54 | // Should return to empty state or previous state 55 | const emptySendersContainer = page.locator(".e-container"); 56 | await expect(emptySendersContainer).toBeVisible(); 57 | }); 58 | 59 | test("should transition from progress to loaded senders", async ({ 60 | page, 61 | }) => { 62 | // Wait for progress to appear 63 | const progressContainer = page.locator(".fetch-progress-container"); 64 | await expect(progressContainer).toBeVisible(); 65 | 66 | // Wait for loading to complete and senders to appear 67 | await expect(progressContainer).not.toBeVisible({ timeout: 5000 }); 68 | 69 | // Senders should now be visible 70 | await expect(page.locator(".sender-line-real")).toHaveCount(20); 71 | }); 72 | 73 | test("should maintain search functionality after progressive load", async ({ 74 | page, 75 | }) => { 76 | // Wait for loading to complete 77 | await page.waitForSelector(".sender-line-real", { timeout: 5000 }); 78 | 79 | // Search should work normally 80 | const searchInput = page.locator('input[aria-label="Search senders"]'); 81 | await searchInput.fill("alice"); 82 | await expect(page.locator(".sender-line-real")).toHaveCount(1); 83 | }); 84 | 85 | test("should allow reloading while showing progress", async ({ page }) => { 86 | await expect(page.locator(".fetch-progress-container")).toBeVisible(); 87 | 88 | // Click reload button while loading 89 | await page.locator(".reload-button").click(); 90 | 91 | // Should still show progress (new scan started) 92 | await expect(page.locator(".fetch-progress-container")).toBeVisible(); 93 | }); 94 | 95 | test("progress bar should update smoothly", async ({ page }) => { 96 | // Get initial width of progress bar 97 | const progressBarFill = page.locator(".progress-bar-fill"); 98 | const initialWidth = await progressBarFill.evaluate((el) => { 99 | return parseFloat(window.getComputedStyle(el).width); 100 | }); 101 | 102 | // Wait a bit 103 | await page.waitForTimeout(500); 104 | 105 | // Get updated width 106 | const updatedWidth = await progressBarFill.evaluate((el) => { 107 | return parseFloat(window.getComputedStyle(el).width); 108 | }); 109 | 110 | // Progress should have increased (unless already complete) 111 | if (await progressBarFill.isVisible()) { 112 | expect(updatedWidth).toBeGreaterThanOrEqual(initialWidth); 113 | } 114 | }); 115 | 116 | test("should show correct email count in progress", async ({ page }) => { 117 | // Check that email count is formatted with commas for large numbers 118 | const progressStats = page.locator(".progress-stats"); 119 | await expect(progressStats).toContainText( 120 | /\d{1,3}(,\d{3})* emails processed/, 121 | ); 122 | }); 123 | 124 | test("should handle errors during progressive loading gracefully", async ({ 125 | page, 126 | }) => { 127 | // Even if an error occurs, the UI should remain functional 128 | // Cancel button should always be clickable 129 | const cancelButton = page.locator(".cancel-button"); 130 | await expect(cancelButton).toBeEnabled(); 131 | }); 132 | 133 | test("should not show progress when loading from cache", async ({ page }) => { 134 | // Wait for loading to complete 135 | await page.waitForSelector(".sender-line-real", { timeout: 5000 }); 136 | 137 | // Reload page to simulate loading from cache 138 | await page.reload(); 139 | await setupSidebarTest(page, logs); 140 | 141 | // Should show senders without progress bar 142 | await expect(page.locator(".sender-line-real")).toHaveCount(20); 143 | await expect(page.locator(".fetch-progress-container")).not.toBeVisible(); 144 | }); 145 | }); 146 | -------------------------------------------------------------------------------- /project-managment/epics.md: -------------------------------------------------------------------------------- 1 | ### **Epic 1: Sender Management** 2 | 3 | As a user, I want to easily manage and view all email senders in my inbox so that I can identify senders I want to take action on (unsubscribe, delete, block). 4 | 5 | - **User Story 1.1:** 6 | **As a user**, I want to see a list of all senders in my inbox, sorted by the number of emails from each sender, so that I can easily identify the most frequent senders. 7 | 8 | - **Acceptance Criteria:** 9 | - The extension fetches the senders and counts the number of emails. 10 | - Display the sender's name and the email count in the list. 11 | 12 | - **User Story 1.2:** 13 | **As a user**, I want to click on a sender to view all the emails from that sender in the Gmail interface, so that I can take action on those emails. 14 | 15 | - **Acceptance Criteria:** 16 | - Clicking on a sender opens a Gmail view of all emails from that sender. 17 | 18 | - **User Story 1.3:** 19 | **As a user**, I want to see a “No senders” modal if I click Unsubscribe or Delete with no senders selected, so that I’m reminded to pick at least one sender before proceeding. 20 | 21 | - **Acceptance Criteria:** 22 | - Clicking “Unsubscribe” with zero senders selected displays a modal reading “No senders selected.” 23 | - Clicking “Delete” with zero senders selected displays the same modal. 24 | 25 | --- 26 | 27 | ### **Epic 2: Delete Functionality** 28 | 29 | As a user, I want to be able to delete emails from multiple senders at once and confirm the action before proceeding, so that I avoid accidental deletions. 30 | 31 | - **User Story 2.1:** 32 | **As a user**, I want a popup to show when I click delete with selected senders, displaying the number of senders and emails to be deleted, so that I can confirm the action before it happens. 33 | 34 | - **Acceptance Criteria:** 35 | - Popup displays the number of senders and emails. 36 | - Popup includes two buttons: "Show all emails" and "Confirm." 37 | 38 | - **User Story 2.2:** 39 | **As a user**, I want to click "Show all emails" in the delete confirmation popup to view a Gmail interface showing all the emails from the selected senders combined, so that I can review them before deleting. 40 | 41 | - **Acceptance Criteria:** 42 | - Clicking "Show all emails" opens the Gmail interface with all emails from the selected senders. 43 | - Have the modal persist after emails are shown. 44 | 45 | - **User Story 2.3:** 46 | **As a user**, I want to click "Confirm" in the delete confirmation popup to delete all emails from the selected senders, so that I can quickly declutter my inbox. 47 | 48 | - **Acceptance Criteria:** 49 | - Clicking "Confirm" deletes all the emails from the selected senders. 50 | - After deleting the emails, a confirmation message is displayed notifying the user that the emails were successfully deleted. 51 | 52 | --- 53 | 54 | ### **Epic 3: Unsubscribe Functionality** 55 | 56 | As a user, I want to unsubscribe from emails in a single click, so that I can reduce unwanted messages without manually searching for unsubscribe links. 57 | 58 | - **User Story 3.1: Popup** 59 | **As a user**, I want a confirmation popup to show when I click unsubscribe with selected senders, displaying the number of senders and emails, so that I can confirm the action before it happens. 60 | 61 | - **Acceptance Criteria:** 62 | - Popup shows the number of senders & emails. 63 | - Popup includes two buttons: "Show all senders" and "Confirm." 64 | 65 | - **User Story 3.2: Show All Emails** 66 | **As a user**, I want to click "Show all senders" in the unsubscribe confirmation popup to view a Gmail interface showing all emails from the selected senders combined, so that I can review them before unsubscribing. 67 | 68 | - **Acceptance Criteria:** 69 | - Clicking "Show all senders" opens the Gmail interface with all emails from the selected senders. 70 | - Have the modal persist after emails are shown. 71 | 72 | - **User Story 3.3: Automatic Unsubscribe Attempt** 73 | **As a user**, when I confirm “Unsubscribe,” I want the extension to first try to unsubscribe me automatically from all selected senders, so that I don’t have to click through links for services that support a direct “POST” or “mailto” method. 74 | 75 | - **Acceptance Criteria:** 76 | - After clicking Confirm, a “working” modal appears saying “Unsubscribing…” 77 | - The extension calls `unsubscribeSendersAuto` with all selected addresses 78 | - For addresses with post or mailto links, manual popups do not show up 79 | 80 | - **User Story 3.4: Manual Unsubscribe Link Wizard** 81 | 82 | **As a user**, for each sender that couldn’t be auto‑unsubscribed but did have a link, I want to be shown each unsubscribe link one at a time so that I can complete any extra steps before moving on. 83 | 84 | - **Acceptance Criteria:** 85 | - After the “working” phase, for the first link‑only sender, a modal appears with the option to go to the website. 86 | - Clicking Go to Website opens the unsubscribe URL in a new tab. 87 | - Clicking Continue closes the modal and advances to the next link‑only sender’s modal (if any). 88 | - Once all senders have been handled, the success modal is shown. 89 | 90 | - **User Story 3.5: Block Prompt** 91 | 92 | **As a user**, for any sender that has no unsubscribe option, I want the extension to prompt me whether to block them, so that I can still stop receiving emails. 93 | 94 | - **Acceptance Criteria:** 95 | - If there are any senders with no unsubscribe links whatsoever, a modal appears per sender with options to "Block" or "No Block" 96 | - Clicking Block invokes blockSender then proceeds to the next. 97 | - Don’t Block simply proceeds. 98 | 99 | - **User Story 3.6: Combination Unsubscribe Wizard** 100 | 101 | **As a user**, when I unsubscribe from a batch of senders that includes some that can be auto‑unsubscribed, some that require manual link clicks, and some with no unsubscribe option, I want the extension to process each category correctly, so that I can handle a mixed set of senders in one smooth flow. 102 | 103 | - **Acceptance Criteria:** 104 | - Senders that support automatic unsubscribe are processed immediately with no modals. 105 | - For each link‑only sender, a modal appears and is able to open links. 106 | - After all link‑only senders, for each no‑unsubscribe sender, a modal appears and is able to block senders. 107 | - After all processing, a success modal appears 108 | 109 | - **User Story 3.7: Optional Delete‑Emails Step** 110 | **As a user**, I want a toggle button to delete all the emails from the senders I unsubscribe from, so that I can clean my inbox as I unsubscribe. 111 | - **Acceptance Criteria:** 112 | - The toggle is on by default. 113 | - The toggle can be turned off by the user. 114 | - When the toggle is on, all emails from unsubscribed senders are deleted. 115 | - When toggle is off, emails are left in the inbox as is. 116 | -------------------------------------------------------------------------------- /test/ui/sidebar/search.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | import { setupSidebarTest } from "./helpers"; 3 | 4 | test.describe("Search functionality", () => { 5 | const logs: string[] = []; 6 | 7 | test.beforeEach(async ({ page }) => { 8 | await setupSidebarTest(page, logs); 9 | }); 10 | 11 | test("should display search input", async ({ page }) => { 12 | const searchInput = page.locator('input[aria-label="Search senders"]'); 13 | await expect(searchInput).toBeVisible(); 14 | await expect(searchInput).toHaveAttribute( 15 | "placeholder", 16 | "Search senders...", 17 | ); 18 | }); 19 | 20 | test("should filter senders by email address", async ({ page }) => { 21 | // Initially all senders should be visible 22 | await expect(page.locator(".sender-line-real")).toHaveCount(20); 23 | 24 | // Type in search input 25 | const searchInput = page.locator('input[aria-label="Search senders"]'); 26 | await searchInput.fill("alice@email.com"); 27 | 28 | // Should only show Alice 29 | await expect(page.locator(".sender-line-real")).toHaveCount(1); 30 | await expect(page.locator(".sender-line-real")).toContainText( 31 | "alice@email.com", 32 | ); 33 | }); 34 | 35 | test("should filter senders by name", async ({ page }) => { 36 | // Search by sender name 37 | const searchInput = page.locator('input[aria-label="Search senders"]'); 38 | await searchInput.fill("Bob"); 39 | 40 | // Should only show Bob 41 | await expect(page.locator(".sender-line-real")).toHaveCount(1); 42 | await expect(page.locator(".sender-line-real")).toContainText( 43 | "bob@email.com", 44 | ); 45 | }); 46 | 47 | test("should perform case-insensitive search", async ({ page }) => { 48 | const searchInput = page.locator('input[aria-label="Search senders"]'); 49 | 50 | // Search with lowercase 51 | await searchInput.fill("grace"); 52 | await expect(page.locator(".sender-line-real")).toHaveCount(1); 53 | 54 | // Search with uppercase 55 | await searchInput.clear(); 56 | await searchInput.fill("GRACE"); 57 | await expect(page.locator(".sender-line-real")).toHaveCount(1); 58 | 59 | // Search with mixed case 60 | await searchInput.clear(); 61 | await searchInput.fill("GrAcE"); 62 | await expect(page.locator(".sender-line-real")).toHaveCount(1); 63 | }); 64 | 65 | test("should filter multiple senders with partial match", async ({ 66 | page, 67 | }) => { 68 | const searchInput = page.locator('input[aria-label="Search senders"]'); 69 | await searchInput.fill("e@email.com"); 70 | 71 | await page.waitForSelector(".sender-line-real", { 72 | state: "visible", 73 | timeout: 5000, 74 | }); 75 | 76 | // Should show all senders whose email contains "e@email.com" 77 | const senderCount = await page.locator(".sender-line-real").count(); 78 | expect(senderCount).toBeGreaterThan(1); 79 | 80 | // Verify all visible senders contain the search term 81 | const visibleEmails = await page.locator(".sender-email").allTextContents(); 82 | for (const email of visibleEmails) { 83 | expect(email.toLowerCase()).toContain("e@email.com"); 84 | } 85 | }); 86 | 87 | test("should show no results message when no matches found", async ({ 88 | page, 89 | }) => { 90 | const searchInput = page.locator('input[aria-label="Search senders"]'); 91 | await searchInput.fill("nonexistent@email.com"); 92 | 93 | // Should show no senders 94 | await expect(page.locator(".sender-line-real")).toHaveCount(0); 95 | 96 | // Should show "no results" message 97 | await expect(page.locator("#senders")).toContainText( 98 | 'No senders match "nonexistent@email.com"', 99 | ); 100 | }); 101 | 102 | test("should show clear button when search term exists", async ({ page }) => { 103 | const searchInput = page.locator('input[aria-label="Search senders"]'); 104 | const clearButton = page.locator('button[aria-label="Clear search"]'); 105 | 106 | // Initially no clear button 107 | await expect(clearButton).not.toBeVisible(); 108 | 109 | // Type something 110 | await searchInput.fill("test"); 111 | 112 | // Clear button should appear 113 | await expect(clearButton).toBeVisible(); 114 | }); 115 | 116 | test("should clear search and restore all senders when clear button clicked", async ({ 117 | page, 118 | }) => { 119 | const searchInput = page.locator('input[aria-label="Search senders"]'); 120 | const clearButton = page.locator('button[aria-label="Clear search"]'); 121 | 122 | // Filter senders 123 | await searchInput.fill("alice"); 124 | await expect(page.locator(".sender-line-real")).toHaveCount(1); 125 | 126 | // Click clear button 127 | await clearButton.click(); 128 | 129 | // Search input should be empty 130 | await expect(searchInput).toHaveValue(""); 131 | 132 | // All senders should be visible again 133 | await expect(page.locator(".sender-line-real")).toHaveCount(20); 134 | }); 135 | 136 | test("should update search results in real-time as user types", async ({ 137 | page, 138 | }) => { 139 | const searchInput = page.locator('input[aria-label="Search senders"]'); 140 | 141 | // Type character by character 142 | await searchInput.type("ali", { delay: 100 }); 143 | 144 | // Should filter progressively 145 | const initialCount = await page.locator(".sender-line-real").count(); 146 | expect(initialCount).toBeLessThan(20); 147 | 148 | // Continue typing 149 | await searchInput.type("ce", { delay: 100 }); 150 | 151 | // Should narrow down results 152 | await expect(page.locator(".sender-line-real")).toHaveCount(1); 153 | }); 154 | 155 | test("should maintain search state when selecting/deselecting senders", async ({ 156 | page, 157 | }) => { 158 | const searchInput = page.locator('input[aria-label="Search senders"]'); 159 | 160 | // Search for a sender 161 | await searchInput.fill("alice"); 162 | await expect(page.locator(".sender-line-real")).toHaveCount(1); 163 | 164 | // Select the sender 165 | await page.locator(".sender-line-real").getByRole("checkbox").check(); 166 | 167 | // Search should still be active 168 | await expect(searchInput).toHaveValue("alice"); 169 | await expect(page.locator(".sender-line-real")).toHaveCount(1); 170 | 171 | // Deselect the sender 172 | await page.locator(".sender-line-real").getByRole("checkbox").uncheck(); 173 | 174 | // Search should still be active 175 | await expect(searchInput).toHaveValue("alice"); 176 | await expect(page.locator(".sender-line-real")).toHaveCount(1); 177 | }); 178 | 179 | test("should work with action buttons on filtered results", async ({ 180 | page, 181 | }) => { 182 | const searchInput = page.locator('input[aria-label="Search senders"]'); 183 | 184 | // Search for specific senders 185 | await searchInput.fill("e@email.com"); 186 | 187 | // Select first two visible senders 188 | const checkboxes = page.locator(".sender-line-real").getByRole("checkbox"); 189 | await checkboxes.nth(0).check(); 190 | await checkboxes.nth(1).check(); 191 | 192 | // Click delete button 193 | await page.locator("#delete-button").click(); 194 | 195 | // Should show delete confirmation modal 196 | const modal = page.locator("#delete-confirm-modal"); 197 | await expect(modal).toBeVisible(); 198 | await expect(modal).toContainText("2 sender(s)"); 199 | }); 200 | }); 201 | -------------------------------------------------------------------------------- /src/presentation/providers/app_provider.tsx: -------------------------------------------------------------------------------- 1 | // src/presentation/providers/app_provider.tsx 2 | import React, { 3 | createContext, 4 | useContext, 5 | useState, 6 | useCallback, 7 | useEffect, 8 | useMemo, 9 | } from "react"; 10 | import { Sender } from "../../domain/entities/sender"; 11 | import { ChromeLocalStorageRepo } from "../../data/repositories/chrome_local_storage_repo"; 12 | import { BrowserEmailRepo } from "../../data/repositories/browser_email_repo"; 13 | import { ChromePageInteractionRepo } from "../../data/repositories/chrome_page_interaction_repo"; 14 | // NOTE: Assuming FetchProgress is correctly exported from "../../domain/repositories/email_repo" 15 | import { EmailRepo } from "../../domain/repositories/email_repo"; 16 | import { FetchProgress } from "../../domain/types/progress"; 17 | import { StorageRepo } from "../../domain/repositories/storage_repo"; 18 | import { PageInteractionRepo } from "../../domain/repositories/page_interaction_repo"; 19 | 20 | // Mock repositories 21 | import { MockEmailRepo } from "../../data/repositories/mocks/mock_email_repo"; 22 | import { MockStorageRepo } from "../../data/repositories/mocks/mock_storage_repo"; 23 | import { MockPageInteractionRepo } from "../../data/repositories/mocks/mock_page_interaction_repo"; 24 | 25 | type AppContextType = { 26 | senders: Sender[]; 27 | selectedSenders: Record; 28 | loading: boolean; 29 | reloadSenders: (fetchNew?: boolean) => void; 30 | setSelectedSenders: React.Dispatch< 31 | React.SetStateAction> 32 | >; 33 | clearSelectedSenders: () => void; 34 | searchEmailSenders: (emails: string[]) => void; 35 | getEmailAccount: () => Promise; 36 | deleteSenders: (senderEmails: string[]) => Promise; 37 | unsubscribeSenders: (senderEmails: string[]) => Promise; 38 | blockSender: (senderEmail: string) => Promise; 39 | searchTerm: string; 40 | setSearchTerm: (term: string) => void; 41 | filteredSenders: Sender[]; 42 | fetchProgress: FetchProgress | null; 43 | cancelFetch: () => void; 44 | }; 45 | 46 | const AppContext = createContext(undefined); 47 | 48 | export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ 49 | children, 50 | }) => { 51 | const [senders, setSenders] = useState([]); 52 | const [selectedSenders, setSelectedSenders] = useState< 53 | Record 54 | >({}); 55 | const [loading, setLoading] = useState(false); 56 | const [searchTerm, setSearchTerm] = useState(""); 57 | const [fetchProgress, setFetchProgress] = useState( 58 | null, 59 | ); 60 | 61 | // - REPOS - 62 | const useMock = import.meta.env.VITE_USE_MOCK === "true"; 63 | 64 | const emailRepo: EmailRepo = useMemo(() => { 65 | const repo = useMock ? new MockEmailRepo() : new BrowserEmailRepo(); 66 | 67 | // Set up progress callback for both mock and production 68 | if (repo.setProgressCallback) { 69 | repo.setProgressCallback((progress) => { 70 | setFetchProgress(progress); 71 | }); 72 | } 73 | return repo; 74 | }, [useMock]); 75 | 76 | const storageRepo: StorageRepo = useMemo( 77 | () => (useMock ? new MockStorageRepo() : new ChromeLocalStorageRepo()), 78 | [useMock], 79 | ); 80 | const pageInteractionRepo: PageInteractionRepo = useMemo( 81 | () => 82 | useMock ? new MockPageInteractionRepo() : new ChromePageInteractionRepo(), 83 | [useMock], 84 | ); 85 | 86 | // - METHODS - 87 | 88 | const reloadSenders = useCallback( 89 | async (fetchNew = false) => { 90 | setLoading(true); 91 | setFetchProgress(null); 92 | try { 93 | const accountEmail = 94 | await pageInteractionRepo.getActiveTabEmailAccount(); 95 | 96 | if (fetchNew) { 97 | const fetchedSenders = await emailRepo.fetchSenders(); 98 | storageRepo.storeSenders(fetchedSenders, accountEmail); 99 | setSenders(fetchedSenders); 100 | } else { 101 | const storedData = await storageRepo.readSenders(accountEmail); 102 | setSenders(storedData); 103 | } 104 | } finally { 105 | setLoading(false); 106 | setFetchProgress(null); 107 | } 108 | }, 109 | [emailRepo, pageInteractionRepo, storageRepo], 110 | ); 111 | 112 | const cancelFetch = useCallback(() => { 113 | if (emailRepo.cancelFetch) { 114 | emailRepo.cancelFetch(); 115 | } 116 | setFetchProgress(null); 117 | }, [emailRepo]); 118 | 119 | const clearSelectedSenders = useCallback(() => { 120 | setSelectedSenders({}); 121 | }, []); 122 | 123 | const searchEmailSenders = useCallback( 124 | (emails: string[]) => { 125 | pageInteractionRepo.searchEmailSenders(emails); 126 | }, 127 | [pageInteractionRepo], 128 | ); 129 | 130 | const getEmailAccount = useCallback(async (): Promise => { 131 | const accountEmail = await pageInteractionRepo.getActiveTabEmailAccount(); 132 | return accountEmail; 133 | }, [pageInteractionRepo]); 134 | 135 | const deleteSenders = useCallback( 136 | async (senderEmails: string[]) => { 137 | const accountEmail = await pageInteractionRepo.getActiveTabEmailAccount(); 138 | await emailRepo.deleteSenders(senderEmails); 139 | await storageRepo.deleteSenders(senderEmails, accountEmail); 140 | setSenders((prevSenders) => 141 | prevSenders.filter((sender) => !senderEmails.includes(sender.email)), 142 | ); 143 | }, 144 | [emailRepo, pageInteractionRepo, storageRepo], 145 | ); 146 | 147 | const unsubscribeSenders = useCallback( 148 | async (senderEmails: string[]) => { 149 | return await emailRepo.unsubscribeSenders(senderEmails); 150 | }, 151 | [emailRepo], 152 | ); 153 | 154 | const blockSender = useCallback( 155 | async (senderEmail: string) => { 156 | await emailRepo.blockSender(senderEmail); 157 | }, 158 | [emailRepo], 159 | ); 160 | 161 | // Add filtered senders computation 162 | const filteredSenders = useMemo(() => { 163 | if (!searchTerm.trim()) { 164 | return senders; 165 | } 166 | 167 | const lowerSearchTerm = searchTerm.toLowerCase(); 168 | return senders.filter((sender) => { 169 | const matchesEmail = sender.email.toLowerCase().includes(lowerSearchTerm); 170 | const matchesName = Array.from(sender.names).some((name) => 171 | name.toLowerCase().includes(lowerSearchTerm), 172 | ); 173 | return matchesEmail || matchesName; 174 | }); 175 | }, [senders, searchTerm]); 176 | 177 | // Automatically load senders from storage when the component mounts 178 | useEffect(() => { 179 | reloadSenders(); 180 | }, [reloadSenders]); 181 | 182 | return ( 183 | 203 | {children} 204 | 205 | ); 206 | }; 207 | 208 | export function useApp() { 209 | const context = useContext(AppContext); 210 | if (!context) { 211 | throw new Error("useApp must be used within an AppProvider"); 212 | } 213 | return context; 214 | } 215 | -------------------------------------------------------------------------------- /test/ui/sidebar/unsubscribe.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | import { selectAliceBob, selectEveFrank, setupSidebarTest } from "./helpers"; 3 | 4 | test.describe("UI tests for Epic 3 - Unsubscribe Flow", () => { 5 | const logs: string[] = []; 6 | 7 | test.beforeEach(async ({ page }) => { 8 | await setupSidebarTest(page, logs); 9 | }); 10 | 11 | test("3.1 - shows modal with correct email & sender count & buttons", async ({ 12 | page, 13 | }) => { 14 | // select two senders 15 | await selectAliceBob(page, "unsubscribe"); 16 | 17 | const modal = page.locator("#unsubscribe-confirm-modal"); 18 | await expect(modal).toBeVisible(); 19 | await expect(modal).toContainText("2 selected sender(s)"); 20 | await expect(modal).toContainText("110 email(s)"); 21 | await expect( 22 | page.getByRole("button", { name: "Show all emails" }), 23 | ).toBeVisible(); 24 | await expect( 25 | page.getByRole("button", { name: "Unsubscribe" }), 26 | ).toBeVisible(); 27 | }); 28 | 29 | test("3.2 - “Show all senders” opens Gmail search, modal persists", async ({ 30 | page, 31 | }) => { 32 | // select two senders 33 | await selectAliceBob(page, "unsubscribe"); 34 | 35 | // click "Show all emails" button 36 | await page.getByRole("button", { name: "Show all emails" }).click(); 37 | 38 | // check that the search function was called 39 | expect(logs).toContain( 40 | "[MOCK] Searching email senders: [alice@email.com, bob@email.com]", 41 | ); 42 | 43 | // check that the modal is still visible 44 | const modal = page.locator("#unsubscribe-confirm-modal"); 45 | await expect(modal).toBeVisible(); 46 | }); 47 | 48 | test("3.3 - 'Confirm' goes through automatic unsubscription process", async ({ 49 | page, 50 | }) => { 51 | // select two senders 52 | await selectAliceBob(page, "unsubscribe"); 53 | 54 | // click "Confirm" button 55 | await page.getByRole("button", { name: "Confirm" }).click(); 56 | 57 | // check that the unsubscribe action was called once we see success modal 58 | const successModal = page.locator("#unsubscribe-success-modal"); 59 | await expect(successModal).toBeVisible(); 60 | expect(logs).toContain( 61 | "[MOCK] Unsubscribing senders: [alice@email.com, bob@email.com]", 62 | ); 63 | }); 64 | 65 | test("3.5 - failed unsubscribe flows into block-sender prompt", async ({ 66 | page, 67 | }) => { 68 | // select two senders 69 | selectEveFrank(page, "unsubscribe"); 70 | await page.getByRole("button", { name: "Confirm" }).click(); 71 | 72 | // check that the modal is visible 73 | const modal = page.locator("#unsubscribe-error-modal"); 74 | await expect(modal).toBeVisible(); 75 | 76 | // click "Don't Block" button 77 | await page.locator(".secondary").click(); 78 | 79 | // "Block" the next sender 80 | await expect(modal).toBeVisible(); 81 | await page.locator(".primary").click(); 82 | 83 | // check that the success modal appears 84 | const successModal = page.locator("#unsubscribe-success-modal"); 85 | await expect(successModal).toBeVisible(); 86 | 87 | // check that the right blocking action was called 88 | expect(logs).not.toContain("[MOCK] Blocking sender: eve@email.com"); 89 | expect(logs).toContain("[MOCK] Blocking sender: frank@email.com"); 90 | 91 | // check that the delete action was called 92 | expect(logs).toContain( 93 | "[MOCK] Deleting senders: [eve@email.com, frank@email.com]", 94 | ); 95 | }); 96 | 97 | test("3.5a - multiple senders can be blocked in a row", async ({ page }) => { 98 | // select a sender 99 | selectEveFrank(page, "unsubscribe"); 100 | await page.getByRole("button", { name: "Confirm" }).click(); 101 | 102 | // click "Block" button twice 103 | await page.locator(".primary").click(); 104 | await page.locator(".primary").click(); 105 | 106 | // check that the success modal appears 107 | const successModal = page.locator("#unsubscribe-success-modal"); 108 | await expect(successModal).toBeVisible(); 109 | 110 | // check that both blocking actions were called 111 | expect(logs).toContain("[MOCK] Blocking sender: eve@email.com"); 112 | expect(logs).toContain("[MOCK] Blocking sender: frank@email.com"); 113 | 114 | // check that the delete action was called 115 | expect(logs).toContain( 116 | "[MOCK] Deleting senders: [eve@email.com, frank@email.com]", 117 | ); 118 | }); 119 | 120 | test("3.6 - Combination Unsubscribe Wizard", async ({ page }) => { 121 | // select four senders 122 | await page 123 | .locator("div") 124 | .filter({ hasText: /^Alicealice@email\.com32$/ }) 125 | .getByRole("checkbox") 126 | .check(); 127 | await page 128 | .locator("div") 129 | .filter({ hasText: /^Frankfrank@email\.com12$/ }) 130 | .getByRole("checkbox") 131 | .check(); 132 | await page 133 | .locator("div") 134 | .filter({ hasText: /^Bobbob@email\.com78$/ }) 135 | .getByRole("checkbox") 136 | .check(); 137 | await page 138 | .locator("div") 139 | .filter({ hasText: /^Eveeve@email\.com49$/ }) 140 | .getByRole("checkbox") 141 | .check(); 142 | await page.click("#unsubscribe-button"); 143 | await page.getByRole("button", { name: "Confirm" }).click(); 144 | 145 | // Frank & Eve are processed with block-sender prompt 146 | const modal3 = page.locator("#unsubscribe-error-modal"); 147 | await expect(modal3).toBeVisible(); 148 | await page.locator(".primary").click(); // Block Frank 149 | await page.locator(".primary").click(); // Block Eve 150 | 151 | // "Success" modal appears at the end 152 | const modal4 = page.locator("#unsubscribe-success-modal"); 153 | await expect(modal4).toBeVisible(); 154 | 155 | // Emails were deleted (by default) 156 | expect(logs).toContain( 157 | "[MOCK] Deleting senders: [alice@email.com, frank@email.com, bob@email.com, eve@email.com]", 158 | ); 159 | 160 | // Blocking action was called only on manually blocked senders (by default) 161 | expect(logs).toContain("[MOCK] Blocking sender: frank@email.com"); 162 | expect(logs).toContain("[MOCK] Blocking sender: eve@email.com"); 163 | expect(logs).not.toContain("[MOCK] Blocking sender: alice@email.com"); 164 | expect(logs).not.toContain("[MOCK] Blocking sender: bob@email.com"); 165 | }); 166 | 167 | test("3.7 - delete-emails toggle defaults on and can be toggled off", async ({ 168 | page, 169 | }) => { 170 | // select a sender 171 | await page 172 | .locator("div") 173 | .filter({ hasText: /^Alicealice@email\.com32$/ }) 174 | .getByRole("checkbox") 175 | .check(); 176 | await page.click("#unsubscribe-button"); 177 | 178 | // check that the delete toggle is checked by default 179 | const toggle = page 180 | .locator("div") 181 | .filter({ hasText: /^Delete 32 email\(s\) from selected senders$/ }) 182 | .locator(".switch"); 183 | await expect(toggle).toBeChecked(); 184 | 185 | // toggle it off 186 | await toggle.click(); 187 | await expect(toggle).not.toBeChecked(); 188 | 189 | // go through the unsubscribe flow 190 | page.getByRole("button", { name: "Confirm" }).click(); 191 | await expect(page.locator("#unsubscribe-success-modal")).toBeVisible(); 192 | 193 | // check that delete action was not called 194 | expect(logs).not.toContain( 195 | "[MOCK] Trashed senders successfully: [alice@email.com]", 196 | ); 197 | }); 198 | 199 | test("3.8 - senders can be blocked even when link is found", async ({ 200 | page, 201 | }) => { 202 | // select two senders 203 | await selectAliceBob(page, "unsubscribe"); 204 | 205 | // check that the toggle is not checked by default 206 | const toggle = page 207 | .locator("div") 208 | .filter({ hasText: /^Also block senders$/ }) 209 | .locator("span"); 210 | await expect(toggle).not.toBeChecked(); 211 | 212 | // toggle it on 213 | await toggle.click(); 214 | await expect(toggle).toBeChecked(); 215 | 216 | // click through the unsubscribe flow 217 | page.getByRole("button", { name: "Confirm" }).click(); 218 | await expect(page.locator("#unsubscribe-success-modal")).toBeVisible(); 219 | 220 | // check that block action was called 221 | expect(logs).toContain("[MOCK] Blocking sender: alice@email.com"); 222 | expect(logs).toContain("[MOCK] Blocking sender: bob@email.com"); 223 | }); 224 | }); 225 | -------------------------------------------------------------------------------- /src/presentation/apps/sidebar/components/modalPopup.tsx: -------------------------------------------------------------------------------- 1 | import "./modalPopup.css"; 2 | import { useState } from "react"; 3 | import { useModal } from "../providers/modalContext"; 4 | import { ToggleOption } from "./toggleOption"; 5 | import { useUnsubscribeFlow } from "../utils/unsubscribeFlow"; 6 | import { useApp } from "../../../providers/app_provider"; 7 | 8 | interface ConfirmProps { 9 | emailsNum: number; 10 | sendersNum: number; 11 | } 12 | 13 | const UnsubscribeConfirm = ({ emailsNum, sendersNum }: ConfirmProps) => { 14 | const [deleteEmails, setDeleteEmails] = useState(true); 15 | const [blockSenders, setBlockSenders] = useState(false); 16 | 17 | const { selectedSenders, searchEmailSenders } = useApp(); 18 | const { startUnsubscribeFlow } = useUnsubscribeFlow( 19 | deleteEmails, 20 | blockSenders, 21 | ); 22 | 23 | const showEmails = () => { 24 | searchEmailSenders(Object.keys(selectedSenders)); 25 | }; 26 | 27 | return ( 28 | <> 29 |

30 | Are you sure you want to unsubscribe from {sendersNum}{" "} 31 | selected sender(s)? 32 |

33 | 34 |
35 | 38 | Delete {emailsNum} email(s) from selected senders 39 | 40 | } 41 | defaultChecked={true} 42 | onChange={(checked) => setDeleteEmails(checked)} 43 | /> 44 | 45 | setBlockSenders(checked)} 49 | /> 50 |
51 | 52 | 55 | 58 | 59 | ); 60 | }; 61 | 62 | const UnsubscribePending = ({ subtype }: { subtype: string }) => { 63 | let message: string; 64 | switch (subtype) { 65 | case "working": 66 | message = "Unsubscribing from senders..."; 67 | break; 68 | case "finding-link": 69 | message = "Finding unsubscribe links..."; 70 | break; 71 | case "blocking": 72 | message = "Blocking sender..."; 73 | break; 74 | default: 75 | message = "Processing..."; 76 | break; 77 | } 78 | 79 | return ( 80 | <> 81 |

{message}

82 |
83 |
84 | 85 | ); 86 | }; 87 | 88 | const UnsubscribeContinue = ({ 89 | email, 90 | link, 91 | onContinue, 92 | }: { 93 | email: string; 94 | link: string; 95 | onContinue: () => void; 96 | }) => { 97 | const openLink = () => { 98 | window.open(link, "_blank"); 99 | }; 100 | return ( 101 | <> 102 |

103 | To stop getting messages from {email}, go to their website to 104 | unsubscribe. 105 |

106 |

107 | Once you've finished on the website, click "Continue" to proceed. 108 |

109 | 110 | 113 | 116 | 117 | ); 118 | }; 119 | 120 | const UnsubscribeError = ({ 121 | email, 122 | onContinue, 123 | }: { 124 | email: string; 125 | onContinue: () => void; 126 | }) => { 127 | const { blockSender } = useApp(); 128 | const { setModal } = useModal(); 129 | 130 | const handleBlockSender = async () => { 131 | setModal({ action: "unsubscribe", type: "pending", subtype: "blocking" }); 132 | await blockSender(email); 133 | onContinue(); // Continue to next sender after blocking 134 | }; 135 | 136 | return ( 137 | <> 138 |

139 | Unable to unsubscribe from {email}. 140 |

141 |

Block sender instead?

142 | 145 | 148 | 149 | ); 150 | }; 151 | 152 | const UnsubscribeSuccess = () => { 153 | return ( 154 | <> 155 |

✅ Success!

156 |

You have been unsubscribed from selected senders.

157 |

158 | Note: You may need to reload your browser to see changes. 159 |

160 | 161 | ); 162 | }; 163 | 164 | const DeleteConfirm = ({ emailsNum, sendersNum }: ConfirmProps) => { 165 | const { 166 | reloadSenders, 167 | selectedSenders, 168 | setSelectedSenders, 169 | deleteSenders, 170 | searchEmailSenders, 171 | } = useApp(); 172 | const { setModal } = useModal(); 173 | 174 | const showEmails = () => { 175 | searchEmailSenders(Object.keys(selectedSenders)); 176 | }; 177 | 178 | const deleteEmails = async () => { 179 | try { 180 | // Set modal to pending state 181 | setModal({ action: "delete", type: "pending" }); 182 | 183 | // Delete senders and remove them from selectedSenders 184 | await deleteSenders(Object.keys(selectedSenders)); 185 | for (const senderEmail in selectedSenders) { 186 | setSelectedSenders((prev) => { 187 | const newSelected = { ...prev }; 188 | delete newSelected[senderEmail]; 189 | return newSelected; 190 | }); 191 | } 192 | 193 | // Set modal to success state 194 | setModal({ action: "delete", type: "success" }); 195 | 196 | // Wait 1 sec then reload senders 197 | setTimeout(() => { 198 | reloadSenders(); 199 | }, 1000); 200 | } catch { 201 | setModal({ action: "delete", type: "error" }); 202 | } 203 | }; 204 | 205 | return ( 206 | <> 207 |

208 | Are you sure you want to delete {emailsNum} email(s) from{" "} 209 | {sendersNum} sender(s)? 210 |

211 |

Note: This will not block or unsubscribe.

212 | 213 | 216 | 219 | 220 | ); 221 | }; 222 | 223 | const DeletePending = () => { 224 | return ( 225 | <> 226 |

Deleting emails...

227 |
228 |
229 | 230 | ); 231 | }; 232 | 233 | const DeleteSuccess = () => { 234 | return ( 235 | <> 236 |

✅ Success!

237 |

Selected senders have been deleted.

238 |

239 | Note: You may need to reload your browser to see changes. 240 |

241 | 242 | ); 243 | }; 244 | 245 | const DeleteError = () => { 246 | return ( 247 | <> 248 |

❌ Error!

249 |

There was an error deleting the selected senders.

250 | 251 | ); 252 | }; 253 | 254 | const NoSender = () => { 255 | const { setModal } = useModal(); 256 | return ( 257 | <> 258 |

Oops!

259 |

You haven't selected a sender yet.

260 | 261 |
262 | 263 | 266 | 267 | ); 268 | }; 269 | 270 | export const ModalPopup = () => { 271 | const { modal, setModal } = useModal(); 272 | if (!modal) return null; 273 | 274 | const { action, type, subtype, extras } = modal; 275 | const id: string = action ? `${action}-${type}-modal` : `${type}-modal`; 276 | 277 | const handleBackgroundClick = (event: React.MouseEvent) => { 278 | if (event.target === event.currentTarget) { 279 | setModal(null); 280 | } 281 | }; 282 | 283 | const getChild = (): React.ReactNode => { 284 | switch (true) { 285 | case action === "unsubscribe" && type === "confirm": 286 | return ( 287 | 291 | ); 292 | case action === "unsubscribe" && type === "pending": 293 | return ; 294 | case action === "unsubscribe" && type === "error": 295 | return ( 296 | 300 | ); 301 | case action === "unsubscribe" && type === "continue": 302 | return ( 303 | 308 | ); 309 | case action === "unsubscribe" && type === "success": 310 | return ; 311 | case action === "delete" && type === "confirm": 312 | return ( 313 | 317 | ); 318 | case action === "delete" && type === "pending": 319 | return ; 320 | case action === "delete" && type === "success": 321 | return ; 322 | case action === "delete" && type === "error": 323 | return ; 324 | case type === "no-sender": 325 | return ; 326 | default: 327 | return <>; 328 | } 329 | }; 330 | 331 | return ( 332 |
338 |
{getChild()}
339 |
340 | ); 341 | }; 342 | -------------------------------------------------------------------------------- /src/data/services/browser_email_service.ts: -------------------------------------------------------------------------------- 1 | // src/data/services/browser_email_service.ts 2 | 3 | import { Sender } from "../../domain/entities/sender"; 4 | import { FetchProgress } from "../../domain/types/progress"; 5 | import { PageInteractionService } from "./page_interaction_service"; 6 | 7 | export interface FetchOptions { 8 | onProgress?: (progress: FetchProgress) => void; 9 | batchSize?: number; 10 | maxPages?: number; 11 | signal?: AbortSignal; 12 | } 13 | 14 | /** 15 | * Service class that implements browser-specific email operations to aid BrowserEmailRepo. 16 | * Provides methods to interact with email data within the browser environment. 17 | * All methods here must be run in the content script to work properly. 18 | */ 19 | export class BrowserEmailService { 20 | static async fetchSendersFromBrowser( 21 | options: FetchOptions = {}, 22 | ): Promise { 23 | const { 24 | onProgress, 25 | batchSize = 10, // Process pages sequentially in batches of 10, with a delay between batches 26 | maxPages, 27 | signal, 28 | } = options; 29 | 30 | // Go to "All Mail" page 31 | const currentPage = window.location.href.split("#")[0]; 32 | window.location.href = `${currentPage}#all`; 33 | 34 | // Wait for loading to complete 35 | await this._waitForLoaderToHide(); 36 | const { messages: totalMessages, pages: totalPages } = 37 | this._getTotalMessagesPages(); 38 | 39 | const pagesToProcess = maxPages 40 | ? Math.min(totalPages, maxPages) 41 | : totalPages; 42 | console.log( 43 | `Total messages: ${totalMessages}, pages to process: ${pagesToProcess}`, 44 | ); 45 | 46 | // Use a Map for efficient sender aggregation 47 | const senderMap = new Map(); 48 | let processedEmails = 0; 49 | 50 | // Process pages in batches 51 | for ( 52 | let batchStart = 1; 53 | batchStart <= pagesToProcess; 54 | batchStart += batchSize 55 | ) { 56 | // Check for cancellation 57 | if (signal?.aborted) { 58 | console.log("Fetch cancelled by user"); 59 | break; 60 | } 61 | 62 | const batchEnd = Math.min(batchStart + batchSize - 1, pagesToProcess); 63 | 64 | // Process batch of pages sequentially 65 | for (let i = batchStart; i <= batchEnd; i++) { 66 | if (signal?.aborted) break; 67 | 68 | // Go to page 69 | window.location.href = `${currentPage}#all/p${i}`; 70 | await this._waitForLoaderToHide(); 71 | 72 | // Process emails 73 | console.log(`Processing page ${i} of ${pagesToProcess}`); 74 | const pageEmails = this._extractSendersFromPage(); 75 | 76 | // Aggregate senders incrementally 77 | this._aggregateSenders(pageEmails, senderMap); 78 | processedEmails += pageEmails.length; 79 | 80 | // Report progress 81 | if (onProgress) { 82 | const progress: FetchProgress = { 83 | currentPage: i, 84 | totalPages: pagesToProcess, 85 | processedEmails, 86 | totalEmails: totalMessages, 87 | percentage: Math.round((i / pagesToProcess) * 100), 88 | }; 89 | onProgress(progress); 90 | } 91 | } 92 | 93 | // Small delay between batches to prevent browser freezing 94 | await new Promise((resolve) => setTimeout(resolve, 100)); 95 | } 96 | 97 | // Convert map to sorted array 98 | const senders = Array.from(senderMap.values()); 99 | senders.sort((a, b) => b.emailCount - a.emailCount); 100 | 101 | return senders; 102 | } 103 | 104 | private static _aggregateSenders( 105 | emails: { email: string; name: string }[], 106 | senderMap: Map, 107 | ): void { 108 | emails.forEach(({ email, name }) => { 109 | if (senderMap.has(email)) { 110 | const sender = senderMap.get(email)!; 111 | sender.names.add(name); 112 | sender.emailCount += 1; 113 | } else { 114 | senderMap.set(email, { 115 | email, 116 | names: new Set([name]), 117 | emailCount: 1, 118 | }); 119 | } 120 | }); 121 | } 122 | 123 | // - BLOCK SENDER HELPERS - 124 | 125 | static async blockSenderFromBrowser( 126 | senderEmailAddress: string, 127 | ): Promise { 128 | console.log("Blocking sender in browser: ", senderEmailAddress); 129 | const originalPageUrl = window.location.href; 130 | 131 | // Open filters page 132 | const currentPage = window.location.href.split("#")[0]; 133 | window.location.href = `${currentPage}#settings/filters`; 134 | 135 | // Click "Create a new filter" 136 | const createFilterButtonFunc = () => { 137 | return Array.from(document.querySelectorAll('span[role="link"]')).find( 138 | (el) => el.textContent?.includes("Create a new filter"), 139 | ); 140 | }; 141 | await this._waitForElement(createFilterButtonFunc); 142 | const createFilterButton = createFilterButtonFunc() as HTMLElement; 143 | createFilterButton?.click(); 144 | 145 | // Create the filter 146 | const confirmButtonFunc = () => { 147 | return Array.from(document.querySelectorAll('div[role="link"]')).filter( 148 | (el) => el.textContent?.includes("Create filter"), 149 | )[0] as HTMLElement; 150 | }; 151 | await this._waitForElement(confirmButtonFunc); 152 | await new Promise((resolve) => setTimeout(resolve, 500)); // Wait for the input to be ready 153 | (document.activeElement! as HTMLInputElement).value = senderEmailAddress; 154 | const confirmButton = confirmButtonFunc(); 155 | confirmButton?.click(); 156 | 157 | // Choose deleting 158 | const labelFunc = () => { 159 | return Array.from(document.querySelectorAll("label")).find( 160 | (el) => el.textContent?.trim() === "Delete it", 161 | ); 162 | }; 163 | await this._waitForElement(labelFunc); 164 | const label = labelFunc(); 165 | const checkbox = document.getElementById( 166 | label!.getAttribute("for")!, 167 | )! as HTMLInputElement; 168 | checkbox.checked = true; 169 | 170 | // Confirm 171 | const confirmCreateButton = Array.from( 172 | document.querySelectorAll('div[role="button"]'), 173 | ).filter((el) => 174 | el.textContent?.includes("Create filter"), 175 | )[0] as HTMLElement; 176 | confirmCreateButton?.click(); 177 | 178 | // Go back to old page 179 | window.location.href = originalPageUrl; 180 | } 181 | 182 | // - UNSUBSCRIBE SENDERS HELPERS - 183 | 184 | static async unsubscribeSendersFromBrowser( 185 | senderEmailAddresses: string[], 186 | ): Promise { 187 | console.log("Unsubscribing senders in browser: ", senderEmailAddresses); 188 | const failures = []; 189 | for (const senderEmailAddress of senderEmailAddresses) { 190 | const success = await this._unsubscribeSingleSender(senderEmailAddress); 191 | if (!success) { 192 | failures.push(senderEmailAddress); 193 | } 194 | } 195 | return failures; 196 | } 197 | 198 | /** 199 | * Attempts to unsubscribe a single sender by automating browser interactions. 200 | * @param senderEmailAddress - The email address of the sender to unsubscribe from. 201 | * @returns A promise that resolves to `true` if the unsubscribe process was successful, or `false` if the "Unsubscribe" button was not found. 202 | */ 203 | static async _unsubscribeSingleSender( 204 | senderEmailAddress: string, 205 | ): Promise { 206 | console.log("Unsubscribing sender: ", senderEmailAddress); 207 | 208 | // Search for the sender's emails 209 | PageInteractionService.searchEmailSenders([senderEmailAddress]); 210 | await this._waitForEmailBodyToLoad(); 211 | 212 | // Hover over the first email row 213 | const emailRows = this._getEmailRows(); 214 | emailRows[0].dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); // TODO: If this fails, maybe we could go down the list of rows? 215 | 216 | // Click the "Unsubscribe" button 217 | const unsubscribeButton = Array.from( 218 | document.querySelectorAll(".aJ6"), 219 | ).filter( 220 | (button) => 221 | (button as HTMLElement).offsetParent !== null && 222 | button.textContent?.includes("Unsubscribe"), 223 | )[0]; 224 | if (!unsubscribeButton) { 225 | return false; 226 | } 227 | unsubscribeButton?.dispatchEvent( 228 | new MouseEvent("mousedown", { bubbles: true }), 229 | ); 230 | unsubscribeButton?.dispatchEvent( 231 | new MouseEvent("mouseup", { bubbles: true }), 232 | ); 233 | 234 | // Confirm unsubscribe 235 | await new Promise((resolve) => setTimeout(resolve, 500)); // Wait for unsubscribe confirmation to load 236 | const confirmButton = Array.from( 237 | document.querySelectorAll(".mUIrbf-anl"), 238 | ).filter((el) => el.textContent?.includes("Unsubscribe"))[0] as HTMLElement; 239 | confirmButton.click(); 240 | 241 | console.log("Unsubscribe process completed for: ", senderEmailAddress); 242 | return true; 243 | } 244 | 245 | // - DELETE SENDERS HELPERS - 246 | 247 | static async deleteSendersFromBrowser( 248 | senderEmailAddresses: string[], 249 | ): Promise { 250 | console.log("Deleting senders in browser: ", senderEmailAddresses); 251 | 252 | PageInteractionService.searchEmailSenders(senderEmailAddresses); 253 | await this._waitForEmailBodyToLoad(); 254 | while (!document.querySelector("td.TC")) { 255 | // No 'No messages matched your search' 256 | await this._deleteEmailsOnPage(); 257 | } 258 | } 259 | 260 | static async _deleteEmailsOnPage(): Promise { 261 | const checkboxes = Array.from( 262 | document.querySelectorAll('span[role="checkbox"]'), 263 | ); 264 | const checkbox = checkboxes.filter( 265 | (checkbox) => (checkbox as HTMLElement).offsetParent !== null, 266 | )[0] as HTMLElement; 267 | checkbox.click(); 268 | 269 | const deleteButtons = Array.from( 270 | document.querySelectorAll('div[aria-label="Delete"]'), 271 | ); 272 | const deleteButton = deleteButtons.filter( 273 | (button) => (button as HTMLElement).offsetParent !== null, 274 | )[0] as HTMLElement; 275 | deleteButton.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); 276 | deleteButton.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); 277 | 278 | await this._waitForDeleteConfirmation(); 279 | } 280 | 281 | static async _waitForDeleteConfirmation(): Promise { 282 | return new Promise((resolve) => { 283 | const observer = new MutationObserver(() => { 284 | const confirmation = Array.from(document.querySelectorAll("span")).find( 285 | (span) => span.textContent?.includes("conversations moved to Bin"), 286 | ); 287 | if (confirmation) { 288 | observer.disconnect(); 289 | resolve(); 290 | } 291 | }); 292 | 293 | observer.observe(document.body, { childList: true, subtree: true }); 294 | 295 | // Fallback timeout in case confirmation never appears 296 | setTimeout(() => { 297 | observer.disconnect(); 298 | resolve(); 299 | }, 1000); 300 | }); 301 | } 302 | 303 | // - GENERAL HELPERS - 304 | 305 | /** 306 | * Waits for the Gmail loading indicator to disappear, indicating that the page has finished loading. 307 | * @param timeout Optional timeout in milliseconds to stop waiting after a certain period. Default is 10000ms. 308 | */ 309 | static async _waitForLoaderToHide(timeout = 10000): Promise { 310 | const selector = ".vX.UC"; 311 | return new Promise((resolve, reject) => { 312 | const el = document.querySelector(selector) as HTMLElement; 313 | if (!el) { 314 | reject(new Error(`Element ${selector} not found`)); 315 | return; 316 | } 317 | 318 | const observer = new MutationObserver(() => { 319 | if (getComputedStyle(el).display === "none") { 320 | observer.disconnect(); 321 | resolve(); 322 | } 323 | }); 324 | 325 | observer.observe(el, { attributes: true, attributeFilter: ["style"] }); 326 | 327 | // Optional timeout 328 | setTimeout(() => { 329 | observer.disconnect(); 330 | }, timeout); 331 | }); 332 | } 333 | 334 | /** 335 | * Gets the total number of messages and pages in the inbox. 336 | */ 337 | static _getTotalMessagesPages(): { messages: number; pages: number } { 338 | const infoDiv = Array.from(document.querySelectorAll(".Dj")).find( 339 | (el) => (el as HTMLElement).offsetParent !== null, 340 | ); 341 | const [_, pageSize, totalMessages] = infoDiv?.querySelectorAll(".ts") || []; 342 | const messages = parseInt( 343 | (totalMessages?.textContent || "0").replace(/[.,\s]/g, ""), 344 | ); 345 | const pages = Math.ceil( 346 | messages / parseInt((pageSize?.textContent || "0").replace(/,/g, "")) + 1, 347 | ); 348 | 349 | return { messages, pages }; 350 | } 351 | 352 | /** 353 | * Extracts sender information from all email rows within a table body element. 354 | */ 355 | static _extractSendersFromPage(): { email: string; name: string }[] { 356 | const emailRows = this._getEmailRows(); 357 | const senders: { email: string; name: string }[] = []; 358 | 359 | // Extract sender information from each email row 360 | emailRows.forEach((row) => { 361 | const sender = this._extractSenderFromEmailRow(row); 362 | if (sender) { 363 | senders.push(sender); 364 | } 365 | }); 366 | 367 | return senders; 368 | } 369 | 370 | /** 371 | * Extracts sender information (email and display name) from an HTML email row (tr) element. 372 | */ 373 | static _extractSenderFromEmailRow( 374 | row: HTMLElement, 375 | ): { email: string; name: string } | null { 376 | const emailElement = row.querySelector("span[email]"); 377 | if (!emailElement) return null; 378 | 379 | const email: string = emailElement.getAttribute("email")!; 380 | const name: string = emailElement.textContent || email; 381 | 382 | return { email, name }; 383 | } 384 | 385 | /** 386 | * Retrieves all email row elements from the current page. 387 | */ 388 | static _getEmailRows(): HTMLElement[] { 389 | // Find the main email table body element 390 | const tables = Array.from(document.querySelectorAll("tbody")).filter( 391 | (el) => el.offsetParent !== null, 392 | ); 393 | const tableBody = tables[tables.length - 1]; 394 | 395 | // Find all email rows within the table body 396 | const emailRows = Array.from(tableBody.querySelectorAll("tr")).filter( 397 | (row) => !(row as HTMLElement).innerText.includes("No messages"), 398 | ); 399 | return emailRows; 400 | } 401 | 402 | /** 403 | * Waits for a specific DOM element to appear in the document. 404 | * @param findFn A function that returns the desired element or null if not found. 405 | * @param timeout Optional timeout in milliseconds to stop waiting after a certain period. Default is 10000ms. 406 | * @returns A promise that resolves with the found element or rejects if the timeout is reached. 407 | */ 408 | static _waitForElement( 409 | findFn: () => Element | undefined, 410 | timeout = 10000, 411 | ): Promise { 412 | function isVisible(el: Element) { 413 | return !!( 414 | el && 415 | (el as HTMLElement).offsetParent !== null && 416 | el.getClientRects().length > 0 417 | ); 418 | } 419 | 420 | return new Promise((resolve, reject) => { 421 | const checkAndResolve = () => { 422 | const el = findFn(); 423 | if (el && isVisible(el)) { 424 | return el; 425 | } 426 | return null; 427 | }; 428 | 429 | // Check immediately in case it's already present and visible 430 | const existing = checkAndResolve(); 431 | if (existing) { 432 | resolve(existing); 433 | return; 434 | } 435 | 436 | const observer = new MutationObserver(() => { 437 | const el = checkAndResolve(); 438 | if (el) { 439 | observer.disconnect(); 440 | resolve(el); 441 | } 442 | }); 443 | 444 | observer.observe(document.body, { childList: true, subtree: true }); 445 | 446 | setTimeout(() => { 447 | observer.disconnect(); 448 | reject( 449 | new Error(`Element not found or not visible within ${timeout}ms`), 450 | ); 451 | }, timeout); 452 | }); 453 | } 454 | 455 | /** 456 | * Waits until the email body is loaded in the DOM. 457 | * 458 | * This method repeatedly checks for the presence of exactly three visible `` elements, 459 | * which indicates that the email table has finished loading. The check is performed every 100 milliseconds. 460 | * The returned promise resolves once the condition is met. 461 | * 462 | * @returns {Promise} A promise that resolves when the email body is detected as loaded. 463 | */ 464 | static async _waitForEmailBodyToLoad(): Promise { 465 | return await new Promise((resolve) => { 466 | const checkTables = () => { 467 | const tables = Array.from(document.querySelectorAll("tbody")).filter( 468 | (el) => (el as HTMLElement).offsetParent !== null, 469 | ); 470 | if (tables.length === 3) { 471 | resolve(); 472 | } else { 473 | setTimeout(checkTables, 100); 474 | } 475 | }; 476 | checkTables(); 477 | }); 478 | } 479 | } 480 | --------------------------------------------------------------------------------