diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index a60e30f..90201be 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -12,8 +12,8 @@ Clean-Autofill is a Chrome extension that automatically generates email addresse # Build extension (compile TypeScript + copy assets to dist/) bun run build -# Run tests (119 tests with DOM support) -bun test src/ +# Run tests (251 tests with DOM support) +bun run test # Run tests in watch mode bun run test:watch @@ -49,14 +49,15 @@ bun run bump:major # 0.1.0 → 1.0.0 The extension follows Chrome Extension Manifest V3 architecture with three main components: -### 1. Service Worker (`src/background.ts`) -- Handles extension icon clicks via `chrome.action.onClicked` +### 1. Service Worker (`src/extension/background.ts`) +- Handles messages from popup via `chrome.runtime.onMessage` - Generates email addresses using domain extraction logic in `generateEmailForTab()` +- Sends fill requests to content script and returns results to popup +- Saves generated emails to history via `src/ui/history.ts` - Manages Chrome storage API for user settings -- Shows notifications for success/error states - Opens options page on first install -### 2. Content Script (`src/content.ts`) +### 2. Autofill Script (`src/extension/autofill.ts`) - Injected into all web pages (``) - Receives messages from service worker to fill email fields - Smart field detection with priority order: @@ -65,59 +66,99 @@ The extension follows Chrome Extension Manifest V3 architecture with three main 3. General text input fields - Handles React/framework compatibility with native input events -### 3. Options Page (`src/options.html` + `src/options.ts`) -- Settings interface for configuring user's email domain -- Uses Chrome sync storage for cross-device settings - -### 4. Shared Utilities (`src/utils.ts`) +### 3. Popup (`src/ui/popup.html` + `src/ui/popup.ts`) +- Opens on extension icon click +- Triggers email generation and autofill via message to background +- Displays the generated email with a Copy button +- Shows config prompt if email domain not set + +### 4. Options Page (`src/ui/options.html` + `src/ui/options.ts`) +- Sidebar navigation with three pages: Home, Settings, History +- **Home**: Extension explanation and usage examples +- **Settings**: Email domain configuration, mode selection, Chrome profile import +- **History**: Searchable log of all generated emails with copy/delete actions +- Settings use Chrome sync storage; history uses Chrome local storage + +### 5. History Module (`src/ui/history.ts`) +- CRUD operations for email history entries stored in `chrome.storage.local` +- `addEntry()` - Save new entry (prepend, enforce 10K limit) +- `getHistory()` - Query with optional search filter and pagination +- `deleteEntry()` / `clearHistory()` - Deletion + +### 6. Shared Utilities (`src/email/utils.ts`) - `extractMainDomain()` - Removes subdomains and handles special TLDs (.co.uk, .com.au, etc.) - `isValidEmail()` - Basic email format validation - `createTimeout()` - Promise-based timeout for async operations - `debounce()` - Rate-limiting for input events +### 7. Provider Detection (`src/email/`) +- **`providers.ts`** - `getProviderStatus()` / `getProviderStatusWithMx()` for determining plus-addressing support +- **`provider-domains.ts`** - Static data: 500+ email domains categorized as plus-supported or unsupported +- **`mx-lookup.ts`** - DNS MX record lookup via Google DNS API with memory + storage caching + ## File Structure ``` ├── manifest.json # Extension configuration (MV3) - paths relative to dist/ ├── package.json # NPM/Bun configuration -├── bunfig.toml # Bun test configuration (DOM support) ├── .github/ │ └── workflows/ │ └── ci.yml # GitHub Actions CI pipeline ├── toolkit/ │ ├── biome/ │ │ └── biome.json # Biome linter/formatter config +│ ├── bun/ +│ │ └── bunfig.toml # Bun test configuration (DOM support) │ ├── typescript/ │ │ └── tsconfig.json # TypeScript configuration │ ├── husky/ │ │ └── pre-commit # Pre-commit hook (typecheck, lint, test) +│ ├── test/ +│ │ └── test-setup.ts # DOM test setup (happy-dom) │ └── scripts/ # Build scripts │ ├── build.js # Compiles TS + copies assets to dist/ │ ├── pack.js # Creates distribution zip │ ├── validate.js # Manifest validation │ └── bump-version.js # Version management ├── src/ # TypeScript source (edit these) -│ ├── background.ts # Service worker -│ ├── background.test.ts # Service worker tests -│ ├── content.ts # Content script for email filling -│ ├── content.test.ts # Content script tests -│ ├── options.ts # Options page logic -│ ├── options.test.ts # Options page tests -│ ├── options.html # Options page UI -│ ├── utils.ts # Shared utilities -│ ├── utils.test.ts # Utility tests -│ ├── test-setup.ts # DOM test setup (happy-dom) +│ ├── extension/ # Chrome extension entry points +│ │ ├── background.ts # Service worker +│ │ ├── background.test.ts +│ │ ├── autofill.ts # Content script for email filling +│ │ └── autofill.test.ts +│ ├── email/ # Email/domain logic + utilities +│ │ ├── catch-all-instructions.ts # Provider-specific catch-all setup guides +│ │ ├── catch-all-instructions.test.ts +│ │ ├── providers.ts # Provider status functions +│ │ ├── providers.test.ts +│ │ ├── mx-lookup.ts # MX record DNS lookup + caching +│ │ ├── mx-lookup.test.ts +│ │ ├── provider-domains.ts # Static domain sets +│ │ ├── provider-domains.test.ts +│ │ ├── utils.ts # Shared utilities (domain extraction, validation) +│ │ └── utils.test.ts │ ├── types/ │ │ └── index.ts # TypeScript type definitions +│ ├── ui/ # UI pages + data +│ │ ├── popup.html # Popup UI +│ │ ├── popup.ts # Popup logic +│ │ ├── popup.test.ts +│ │ ├── options.html # Options page UI (sidebar: Home, Settings, History) +│ │ ├── options.css # Options page styles +│ │ ├── options.ts # Options page logic +│ │ ├── options.test.ts +│ │ ├── options-preview.ts # Live email preview for options page +│ │ ├── options-preview.test.ts +│ │ ├── history.ts # Email history storage module +│ │ ├── history.test.ts +│ │ └── message-tokens.css # Shared CSS tokens for messages │ └── icons/ # Extension icons (16, 32, 48, 128px) └── dist/ # Build output (load this in Chrome) - ├── background.js # Compiled service worker - ├── content.js # Compiled content script - ├── options.js # Compiled options page - ├── utils.js # Compiled utilities - ├── options.html # Copied from src/ - ├── manifest.json # Copied from root + ├── extension/ # Compiled extension entry points + ├── email/ # Compiled email/domain modules + ├── ui/ # Compiled UI pages + history ├── icons/ # Copied from src/ + ├── manifest.json # Copied from root └── Clean-Autofill.zip # Distribution package ``` @@ -126,7 +167,7 @@ The extension follows Chrome Extension Manifest V3 architecture with three main 1. Edit TypeScript files in `src/` 2. Run `bun run build` to compile to `dist/` 3. Load `dist/` folder in Chrome (chrome://extensions, Developer mode) -4. Run `bun test src/` to verify changes +4. Run `bun run test` to verify changes 5. Run `bun run check` before committing ## Testing @@ -134,7 +175,7 @@ The extension follows Chrome Extension Manifest V3 architecture with three main Tests are colocated with source files (`*.test.ts`). DOM testing is supported via happy-dom. ```bash -bun test src/ # Run all 119 tests +bun run test # Run all 251 tests bun run test:watch # Watch mode bun run test:coverage # Coverage report (98%+ line coverage) ``` @@ -158,8 +199,8 @@ GitHub Actions runs on push/PR to main: ## Development Notes -- Extension requires minimal permissions: activeTab, storage, notifications -- Uses Chrome's sync storage for cross-device settings persistence +- Extension permissions: activeTab, storage, notifications, identity, identity.email +- Uses Chrome's sync storage for settings, local storage for email history - Domain extraction handles edge cases like localhost, IP addresses, and special TLDs - Content script uses multiple fallback strategies for reliable field detection - TypeScript source in `src/`, compiled output in `dist/` diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000..986d489 --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,31 @@ +# Workflows + +| # | Workflow | File | Trigger | Purpose | +|---|---------|------|---------|---------| +| W1 | Test | `W1-Test.yml` | Push/PR to `main` | Typecheck, lint, test, build, validate | +| W2 | Build | `W2-Build.yml` | Push/PR to `main`, manual | Build, package, upload artifact | +| W3 | Release | `W3-Release-Chrome-Web-Store.yml` | Manual only | Run W1 + W2, then upload & publish to Chrome Web Store | + +## Dependencies + +```text +W3 → W1 (CI gate) → W2 (build + package) → release job +``` + +W1 and W2 are also independently triggered on push/PR. + +## Comparison + +| Step | W1 Test | W2 Build | W3 Release | +|---|:---:|:---:|:---:| +| Typecheck | yes | - | via W1 | +| Lint & format | yes | - | via W1 | +| Tests | yes | - | via W1 | +| `bun run validate` | yes | - | via W1 | +| Build + package | yes | yes | via W2 | +| Upload artifact | - | yes (30d) | yes (90d) | +| Build report summary | - | yes | via W2 | +| Version match check | - | - | yes | +| Upload to Chrome Web Store | - | - | yes | +| Publish to Chrome Web Store | - | - | if input | +| GitHub Release | - | - | if input | diff --git a/.github/workflows/ci.yml b/.github/workflows/W1-Test.yml similarity index 88% rename from .github/workflows/ci.yml rename to .github/workflows/W1-Test.yml index 45a7f59..ca328d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/W1-Test.yml @@ -1,10 +1,8 @@ -name: CI +name: W1 Test on: - push: - branches: [main] - pull_request: - branches: [main] + workflow_dispatch: + workflow_call: jobs: test: diff --git a/.github/workflows/W2-Build.yml b/.github/workflows/W2-Build.yml new file mode 100644 index 0000000..cc88857 --- /dev/null +++ b/.github/workflows/W2-Build.yml @@ -0,0 +1,50 @@ +name: W2 Build + +on: + workflow_dispatch: + workflow_call: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Package extension + run: bun run pack + + - name: Upload extension artifact + uses: actions/upload-artifact@v4 + with: + name: Clean-Autofill + path: dist/Clean-Autofill.zip + retention-days: 30 + + - name: Generate build report + run: | + echo "## Build Report" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Extension Details" >> $GITHUB_STEP_SUMMARY + echo "- **Package**: dist/Clean-Autofill.zip" >> $GITHUB_STEP_SUMMARY + echo "- **Size**: $(du -h dist/Clean-Autofill.zip | cut -f1)" >> $GITHUB_STEP_SUMMARY + echo "- **Files**: $(unzip -l dist/Clean-Autofill.zip | tail -1 | awk '{print $2}')" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Manifest Info" >> $GITHUB_STEP_SUMMARY + python3 -c " + import json + with open('manifest.json', 'r') as f: + m = json.load(f) + print(f\"- **Version**: {m.get('version')}\") + print(f\"- **Name**: {m.get('name')}\") + print(f\"- **Permissions**: {', '.join(m.get('permissions', []))}\") + " >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/Release-Chrome-Web-Store.yml b/.github/workflows/W3-Release-Chrome-Web-Store.yml similarity index 89% rename from .github/workflows/Release-Chrome-Web-Store.yml rename to .github/workflows/W3-Release-Chrome-Web-Store.yml index 1a11df8..5443814 100644 --- a/.github/workflows/Release-Chrome-Web-Store.yml +++ b/.github/workflows/W3-Release-Chrome-Web-Store.yml @@ -1,4 +1,4 @@ -name: Release to Chrome Web Store +name: W3 Release on: workflow_dispatch: @@ -15,36 +15,14 @@ concurrency: jobs: ci: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: Install dependencies - run: bun install - - - name: Type check - run: bun run typecheck + uses: ./.github/workflows/W1-Test.yml - - name: Lint and format check - run: bun run check - - - name: Run tests - run: bun run test - - - name: Build extension - run: bun run build - - - name: Validate extension - run: bun run validate + build: + needs: ci + uses: ./.github/workflows/W2-Build.yml release: - needs: ci + needs: build runs-on: ubuntu-latest permissions: contents: write @@ -76,16 +54,11 @@ jobs: fi echo "extension_id=$EXTENSION_ID" >> $GITHUB_OUTPUT - - name: Setup Bun - uses: oven-sh/setup-bun@v2 + - name: Download build artifact + uses: actions/download-artifact@v4 with: - bun-version: latest - - - name: Install dependencies - run: bun install - - - name: Package extension - run: bun run pack + name: Clean-Autofill + path: dist - name: Upload to Chrome Web Store id: upload diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml deleted file mode 100644 index 1d23846..0000000 --- a/.github/workflows/build-and-test.yml +++ /dev/null @@ -1,112 +0,0 @@ -name: Build and Test - -on: - push: - branches: [ main, develop ] - pull_request: - branches: [ main ] - workflow_dispatch: - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '18' - - - name: Validate manifest.json - run: | - echo "Validating manifest.json..." - python3 -c " - import json - import sys - - try: - with open('manifest.json', 'r') as f: - manifest = json.load(f) - print(f'✅ Manifest valid. Version: {manifest.get(\"version\")}') - print(f' Name: {manifest.get(\"name\")}') - print(f' Description: {manifest.get(\"description\")}') - except Exception as e: - print(f'❌ Manifest validation failed: {e}') - sys.exit(1) - " - - - name: Check required source files - run: | - echo "Checking required source files..." - required_files=( - "manifest.json" - "src/background.ts" - "src/content.ts" - "src/options.html" - "src/options.ts" - "src/utils.ts" - "src/icons/icon16.png" - "src/icons/icon32.png" - "src/icons/icon48.png" - "src/icons/icon128.png" - ) - - for file in "${required_files[@]}"; do - if [ -f "$file" ]; then - echo "✅ $file exists" - else - echo "❌ $file is missing" - exit 1 - fi - done - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: Install dependencies - run: bun install - - - name: Build extension - run: bun run build - - - name: Create extension package - run: | - echo "Creating extension package..." - # Create zip from the built dist directory - cd dist - zip -r Clean-Autofill.zip . -x "*.DS_Store" "*__MACOSX*" "*.map" - cd .. - - echo "✅ Extension package created: dist/Clean-Autofill.zip" - echo "📦 Package size: $(du -h dist/Clean-Autofill.zip | cut -f1)" - - - name: Upload extension artifact - uses: actions/upload-artifact@v4 - with: - name: Clean-Autofill - path: dist/Clean-Autofill.zip - retention-days: 30 - - - name: Generate build report - run: | - echo "## Build Report" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Extension Details" >> $GITHUB_STEP_SUMMARY - echo "- **Package**: dist/Clean-Autofill.zip" >> $GITHUB_STEP_SUMMARY - echo "- **Size**: $(du -h dist/Clean-Autofill.zip | cut -f1)" >> $GITHUB_STEP_SUMMARY - echo "- **Files**: $(unzip -l dist/Clean-Autofill.zip | tail -1 | awk '{print $2}')" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Manifest Info" >> $GITHUB_STEP_SUMMARY - python3 -c " - import json - with open('manifest.json', 'r') as f: - m = json.load(f) - print(f\"- **Version**: {m.get('version')}\") - print(f\"- **Name**: {m.get('name')}\") - print(f\"- **Permissions**: {', '.join(m.get('permissions', []))}\") - " >> $GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore index 537238e..3e27633 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ refresh-token.txt # Test coverage coverage/ .nyc_output/ +test-results/ # Design files (keep source, ignore temp) icons/designs/ @@ -61,3 +62,4 @@ __pycache__/ # Workspace files .context/ +.gstack/ diff --git a/bun.lock b/bun.lock index 2b5aeb3..db4373c 100644 --- a/bun.lock +++ b/bun.lock @@ -8,90 +8,90 @@ "psl": "^1.15.0", }, "devDependencies": { - "@biomejs/biome": "^2.3.13", - "@happy-dom/global-registrator": "^20.4.0", - "@types/chrome": "^0.1.36", - "esbuild": "^0.27.2", - "happy-dom": "^20.4.0", + "@biomejs/biome": "^2.4.11", + "@happy-dom/global-registrator": "^20.9.0", + "@types/chrome": "^0.1.40", + "esbuild": "^0.27.7", + "happy-dom": "^20.9.0", "husky": "^9.1.7", "typescript": "^5.9.3", }, }, }, "packages": { - "@biomejs/biome": ["@biomejs/biome@2.3.13", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.13", "@biomejs/cli-darwin-x64": "2.3.13", "@biomejs/cli-linux-arm64": "2.3.13", "@biomejs/cli-linux-arm64-musl": "2.3.13", "@biomejs/cli-linux-x64": "2.3.13", "@biomejs/cli-linux-x64-musl": "2.3.13", "@biomejs/cli-win32-arm64": "2.3.13", "@biomejs/cli-win32-x64": "2.3.13" }, "bin": { "biome": "bin/biome" } }, "sha512-Fw7UsV0UAtWIBIm0M7g5CRerpu1eKyKAXIazzxhbXYUyMkwNrkX/KLkGI7b+uVDQ5cLUMfOC9vR60q9IDYDstA=="], + "@biomejs/biome": ["@biomejs/biome@2.4.11", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.11", "@biomejs/cli-darwin-x64": "2.4.11", "@biomejs/cli-linux-arm64": "2.4.11", "@biomejs/cli-linux-arm64-musl": "2.4.11", "@biomejs/cli-linux-x64": "2.4.11", "@biomejs/cli-linux-x64-musl": "2.4.11", "@biomejs/cli-win32-arm64": "2.4.11", "@biomejs/cli-win32-x64": "2.4.11" }, "bin": { "biome": "bin/biome" } }, "sha512-nWxHX8tf3Opb/qRgZpBbsTOqOodkbrkJ7S+JxJAruxOReaDPPmPuLBAGQ8vigyUgo0QBB+oQltNEAvalLcjggA=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0OCwP0/BoKzyJHnFdaTk/i7hIP9JHH9oJJq6hrSCPmJPo8JWcJhprK4gQlhFzrwdTBAW4Bjt/RmCf3ZZe59gwQ=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wOt+ed+L2dgZanWyL6i29qlXMc088N11optzpo10peayObBaAshbTcxKUchzEMp9QSY8rh5h6VfAFE3WTS1rqg=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-AGr8OoemT/ejynbIu56qeil2+F2WLkIjn2d8jGK1JkchxnMUhYOfnqc9sVzcRxpG9Ycvw4weQ5sprRvtb7Yhcw=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-gZ6zR8XmZlExfi/Pz/PffmdpWOQ8Qhy7oBztgkR8/ylSRyLwfRPSadmiVCV8WQ8PoJ2MWUy2fgID9zmtgUUJmw=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-xvOiFkrDNu607MPMBUQ6huHmBG1PZLOrqhtK6pXJW3GjfVqJg0Z/qpTdhXfcqWdSZHcT+Nct2fOgewZvytESkw=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-avdJaEElXrKceK0va9FkJ4P5ci3N01TGkc6ni3P8l3BElqbOz42Wg2IyX3gbh0ZLEd4HVKEIrmuVu/AMuSeFFA=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-TUdDCSY+Eo/EHjhJz7P2GnWwfqet+lFxBZzGHldrvULr59AgahamLs/N85SC4+bdF86EhqDuuw9rYLvLFWWlXA=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-+Sbo1OAmlegtdwqFE8iOxFIWLh1B3OEgsuZfBpyyN/kWuqZ8dx9ZEes6zVnDMo+zRHF2wLynRVhoQmV7ohxl2Q=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.13", "", { "os": "linux", "cpu": "x64" }, "sha512-s+YsZlgiXNq8XkgHs6xdvKDFOj/bwTEevqEY6rC2I3cBHbxXYU1LOZstH3Ffw9hE5tE1sqT7U23C00MzkXztMw=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.11", "", { "os": "linux", "cpu": "x64" }, "sha512-TagWV0iomp5LnEnxWFg4nQO+e52Fow349vaX0Q/PIcX6Zhk4GGBgp3qqZ8PVkpC+cuehRctMf3+6+FgQ8jCEFQ=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.13", "", { "os": "linux", "cpu": "x64" }, "sha512-0bdwFVSbbM//Sds6OjtnmQGp4eUjOTt6kHvR/1P0ieR9GcTUAlPNvPC3DiavTqq302W34Ae2T6u5VVNGuQtGlQ=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.11", "", { "os": "linux", "cpu": "x64" }, "sha512-bexd2IklK7ZgPhrz6jXzpIL6dEAH9MlJU1xGTrypx+FICxrXUp4CqtwfiuoDKse+UlgAlWtzML3jrMqeEAHEhA=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-QweDxY89fq0VvrxME+wS/BXKmqMrOTZlN9SqQ79kQSIc3FrEwvW/PvUegQF6XIVaekncDykB5dzPqjbwSKs9DA=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-RJhaTnY8byzxDt4bDVb7AFPHkPcjOPK3xBip4ZRTrN3TEfyhjLRm3r3mqknqydgVTB74XG8l4jMLwEACEeihVg=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.13", "", { "os": "win32", "cpu": "x64" }, "sha512-trDw2ogdM2lyav9WFQsdsfdVy1dvZALymRpgmWsvSez0BJzBjulhOT/t+wyKeh3pZWvwP3VMs1SoOKwO3wecMQ=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.11", "", { "os": "win32", "cpu": "x64" }, "sha512-A8D3JM/00C2KQgUV3oj8Ba15EHEYwebAGCy5Sf9GAjr5Y3+kJIYOiESoqRDeuRZueuMdCsbLZIUqmPhpYXJE9A=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="], + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="], + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="], - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="], + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], - "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.4.0", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.4.0" } }, "sha512-MX0CK+FuP+cIx/2Lq7csXL0czMsgppIKW0Sg4SqIbsQBiacoLXVm6MU+J+ZcS+UhS17VF5wClsZhkpWubYspVg=="], + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.9.0", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.9.0" } }, "sha512-lBW6/m5BIFl3pMuWPNN0lIOYw9LMCmPfix53ExS3FBi4E+NELEljQ3xH6aAV9IYiQRfn9YIIgzzMrD0vIcD7tw=="], - "@types/chrome": ["@types/chrome@0.1.36", "", { "dependencies": { "@types/filesystem": "*", "@types/har-format": "*" } }, "sha512-BvHbuyGttYXnGt5Gpwa4769KIinKHY1iLjlAPrrMBS2GI9m/XNMPtdsq0NgQalyuUdxvlMN/0OyGw0shFVIoUQ=="], + "@types/chrome": ["@types/chrome@0.1.40", "", { "dependencies": { "@types/filesystem": "*", "@types/har-format": "*" } }, "sha512-UnfyRAe8ORu9HSuTH0EqyOEUin3JrWW9Nl/gDXezNfTUrfIoxw+WRZgKOxGz0t5BnjbfXBnS2eCYfW2PxH1wcA=="], "@types/filesystem": ["@types/filesystem@0.0.36", "", { "dependencies": { "@types/filewriter": "*" } }, "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA=="], @@ -105,11 +105,11 @@ "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], - "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], - "esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], + "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], - "happy-dom": ["happy-dom@20.4.0", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^4.5.0", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-RDeQm3dT9n0A5f/TszjUmNCLEuPnMGv3Tv4BmNINebz/h17PA6LMBcxJ5FrcqltNBMh9jA/8ufgDdBYUdBt+eg=="], + "happy-dom": ["happy-dom@20.9.0", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ=="], "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], diff --git a/bunfig.toml b/bunfig.toml deleted file mode 100644 index a6e36b2..0000000 --- a/bunfig.toml +++ /dev/null @@ -1,3 +0,0 @@ -[test] -# Enable DOM support for content script tests -preload = ["./src/test-setup.ts"] diff --git a/docs/Email-Provider.md b/docs/Email-Provider.md new file mode 100644 index 0000000..18eb853 --- /dev/null +++ b/docs/Email-Provider.md @@ -0,0 +1,114 @@ +# Email Provider Compatibility + +Clean Autofill supports two email generation modes. Which modes are available depends on your email provider and domain setup. + +## Modes + +### Plus Addressing + +Format: `yourname+website.com@provider.com` + +Uses sub-addressing (based on the concept described in [RFC 5233](https://www.rfc-editor.org/rfc/rfc5233), which defines Sieve filtering for subaddressed emails) to append the visited site's domain as a tag. The `+` separator convention is provider-specific — requires an email provider that supports it. No setup needed; it works automatically. + +### Catch-All Prefix + +Format: `website.com@yourdomain.com` + +Uses the visited site's domain as the entire local part. Requires you to own a domain with catch-all email routing configured so that any address `@yourdomain.com` is delivered to your mailbox. + +## Provider Support + +### Supports Plus Addressing + +| Provider | Domains | Support page | Notes | +|----------|---------|-------------|-------| +| Gmail | `gmail.com`, `googlemail.com` | [Use Gmail aliases](https://support.google.com/mail/answer/22370) | `googlemail.com` is treated as equivalent to `gmail.com`. No setup needed. | +| Google Workspace | Custom domains | [Create a variation of your address](https://support.google.com/a/users/answer/9282734) | Works on any domain hosted on Google Workspace. | +| Microsoft 365 / Exchange Online | Custom domains | [Plus Addressing in Exchange Online](https://learn.microsoft.com/en-us/exchange/recipients-in-exchange-online/plus-addressing-in-exchange-online) | Receive-only; no alias must be created first. | +| Outlook.com / Hotmail / Live | `outlook.com`, `hotmail.com`, `live.com`, `msn.com` | [Add or remove an email alias](https://support.microsoft.com/en-us/office/add-or-remove-an-email-alias-in-outlook-com-459b1989-356d-40fa-a689-8f285b13f1f2) | Microsoft's clearest plus-addressing docs are for Exchange Online. Consumer Outlook.com commonly works with `+tag` but Microsoft's official consumer alias page does not explicitly document it. | +| Proton Mail | `protonmail.com`, `proton.me`, `pm.me`, `protonmail.ch` | [Addresses and aliases](https://proton.me/support/addresses-and-aliases) | Works on Proton addresses and custom domains. | +| Fastmail | `fastmail.com` and 100+ Fastmail-owned domains | [Plus addressing and subdomain addressing](https://www.fastmail.help/hc/en-us/articles/360060591053) | Works automatically for all aliases. Full domain list includes `fastmail.fm`, `pobox.com`, `sent.com`, and many more. | +| mailbox.org | `mailbox.org` | [Using mail extensions](https://kb.mailbox.org/en/private/e-mail/mail-extensions/) | mailbox.org calls this "mail extensions". | +| Yandex Mail | `yandex.com`, `yandex.ru`, `ya.ru` | [Registration (Special email addresses)](https://yandex.com/support/mail/reg.html) | Russian provider. Documented under "Special email addresses". | + +### Does NOT Support Plus Addressing + +| Provider | Domains | What it offers instead | Support page | +|----------|---------|----------------------|-------------| +| Yahoo Mail | `yahoo.com`, `ymail.com`, `rocketmail.com` | Temporary/disposable email addresses (must be created first) | [Disposable email addresses](https://help.yahoo.com/kb/SLN28815.html) | +| GMX | `gmx.com`, `gmx.de`, `gmx.net` | Created alias addresses | [Alias Addresses](https://support.gmx.com/email/settings/aliasaddresses.html) | +| iCloud Mail | `icloud.com`, `me.com`, `mac.com` | Email aliases and Hide My Email (random relay addresses via iCloud+) | [Email aliases](https://support.apple.com/guide/icloud/add-and-manage-email-aliases-mm6b1a490a/icloud) / [Hide My Email](https://support.apple.com/guide/icloud/what-you-can-do-with-icloud-and-hide-my-email-mme38e1602db/icloud) | +| mail.com | `mail.com`, `email.com` and 100+ novelty domains | Up to 10 alias addresses per account | [Alias Addresses](https://support.mail.com/email/settings/aliasaddresses.html) | +| web.de | `web.de` | Alias addresses (premium only) | [Absenderadressen](https://hilfe.web.de/email/einstellungen/absenderadressen.html) | +| T-Online | `t-online.de` | Additional email addresses | [Zusaetzliche E-Mail-Adresse](https://www.telekom.de/hilfe/apps-dienste/e-mail/konto/weitere-adresse) | +| Tuta (Tutanota) | `tuta.com`, `tutanota.com` | Aliases or custom-domain addresses | [How to use Tuta](https://tuta.com/support/howto) | +| Hey | `hey.com` | Aliases only via HEY for Domains | [Can I create email aliases?](https://help.hey.com/article/928-can-i-create-email-aliases) | +| Mail.ru | `mail.ru`, `inbox.ru`, `list.ru`, `bk.ru` | Anonymous addresses (up to 10) | [Anonymous addresses](https://help.mail.ru/mail/account/aliases/) | +| NetEase | `163.com`, `126.com`, `yeah.net` | | [NetEase Mail Help](https://help.mail.163.com/) | +| QQ Mail | `qq.com`, `foxmail.com` | Alias accounts (up to 10) | [QQ Mail Aliases](https://service.mail.qq.com/detail/0/262) | +| Libero | `libero.it` | Disposable addresses (paid, up to 15/yr) | [Indirizzi usa e getta](https://aiuto.libero.it/articolo/mail-plus/indirizzi-usa-e-getta-alternativi-mail-plus/) | +| La Poste | `laposte.net` | Up to 6 aliases | [Gerer mes alias](https://aide.laposte.net/categories/mon-compte-et-mes-preferences/gerer-mes-alias-et-leur-signature) | +| Rediffmail | `rediffmail.com`, `rediff.com` | | [Rediffmail Help](https://www.rediffmail.com/HELP/main_help.html) | + +### Unverified + +| Provider | Domains | Status | Notes | +|----------|---------|--------|-------| +| Zoho Mail | `zoho.com`, custom domains | Unverified | Community threads indicate `name+tag@yourdomain.com` may work on custom domains, but no clear official support page was found confirming classic plus addressing. Treated as a custom domain in the extension. | + +### Custom Domains + +If you use a custom domain (e.g., `@company.com`), both modes may be available depending on your email hosting: + +- **Plus Addressing** works if your email host supports sub-addressing (Gmail/Google Workspace, Microsoft 365, Fastmail, etc.) +- **Catch-All Prefix** works if you have catch-all routing configured on your domain + +## Decision Table + +This table shows which modes are available based on what you enter in the extension settings: + +| Input | Plus Addressing | Catch-All Prefix | Reason | +|---|:-:|:-:|---| +| `name@gmail.com` | ✅ | ❌ | Gmail supports `+` | +| `name@outlook.com` | ✅ | ❌ | Outlook supports `+` | +| `name@proton.me` | ✅ | ❌ | Proton supports `+` | +| `name@fastmail.com` | ✅ | ❌ | Fastmail supports `+` | +| `name@mailbox.org` | ✅ | ❌ | mailbox.org supports `+` | +| `name@hey.com` | ✅ | ❌ | Hey supports `+` | +| `name@yahoo.com` | ⚠️ | ❌ | Yahoo doesn't support `+` | +| `name@gmx.com` | ⚠️ | ❌ | GMX doesn't support `+` | +| `name@icloud.com` | ⚠️ | ❌ | iCloud doesn't support `+` | +| `name@mail.com` | ⚠️ | ❌ | mail.com doesn't support `+` | +| `name@web.de` | ⚠️ | ❌ | web.de doesn't support `+` | +| `name@t-online.de` | ⚠️ | ❌ | T-Online doesn't support `+` | +| `name@tuta.com` | ⚠️ | ❌ | Tuta doesn't support `+` | +| `name@company.com` | ✅ | ✅ | Custom domain, both modes possible | +| `mydomain.com` (no `@`) | ❌ | ✅ | No local part, only catch-all | +| *(empty)* | ❌ | ❌ | Nothing configured | + +**Legend:** ✅ Available | ⚠️ Warning (may not work) | ❌ Not available + +## Known Limitations of Plus Addressing + +### Some websites reject the `+` character + +Although `+` is a valid character in email addresses per RFC 5321, some websites incorrectly reject it during signup or login. Common behaviors: + +- **Validation error**: The form shows "Invalid email address" when `+` is present +- **Silent stripping**: The site accepts the email but removes everything from `+` to `@`, so `name+site@gmail.com` becomes `name@gmail.com` +- **Blocking on login**: The account was created with `+` but the login form rejects it + +This is a limitation of the website, not of the email provider or this extension. There is no workaround other than contacting the website or using Catch-All Prefix mode instead. + +### Gmail dot trick + +Gmail ignores dots in the local part: `f.i.r.s.t.l.a.s.t@gmail.com` is the same as `firstlast@gmail.com`. This is a Gmail-specific behavior and is not related to plus addressing. + +## How the Extension Detects Providers + +The extension maintains two lists of known email provider domains in `src/email/provider-domains.ts`. When you enter your email address: + +1. If the domain matches a **known provider that supports `+`** (Gmail, Outlook, Proton, Fastmail, mailbox.org, Hey) the Plus Addressing column is available and Catch-All is disabled +2. If the domain matches a **known provider without `+` support** (Yahoo, GMX, iCloud, mail.com, web.de, T-Online, Tuta) the Plus Addressing column shows a warning and Catch-All is disabled +3. If the domain is **not recognized** it is treated as a custom domain and both modes are available +4. If **no `@` is present** (just a domain) only Catch-All is available diff --git a/docs/GITHUB_ACTIONS_SETUP.md b/docs/GITHUB_ACTIONS_SETUP.md deleted file mode 100644 index 161eaaf..0000000 --- a/docs/GITHUB_ACTIONS_SETUP.md +++ /dev/null @@ -1,229 +0,0 @@ -# GitHub Actions Setup for Chrome Extension Deployment - -This guide explains how to set up automated deployment of the Clean-Autofill Chrome Extension to the Chrome Web Store using GitHub Actions. - -## 📋 Prerequisites - -1. **Chrome Web Store Developer Account** - - Register at [Chrome Web Store Developer Dashboard](https://chrome.google.com/webstore/developer/dashboard) - - Pay the one-time $5 developer fee - - Create your extension listing - -2. **GitHub Repository** - - Your extension code must be in a GitHub repository - - You need admin access to configure secrets - -## 🔧 Setup Instructions - -### Step 1: Obtain Chrome Web Store API Credentials - -1. **Enable Chrome Web Store API** - - Go to [Google Cloud Console](https://console.cloud.google.com/) - - Create a new project or select existing - - Enable the Chrome Web Store API - -2. **Create OAuth 2.0 Credentials** - - Go to APIs & Services → Credentials - - Click "Create Credentials" → "OAuth client ID" - - Application type: "Desktop app" - - Download the credentials JSON - -3. **Get Refresh Token** - ```bash - # Install Google's OAuth tool - npm install -g @chrome-web-store/cli - - # Generate refresh token - chrome-web-store-cli auth \ - --client-id YOUR_CLIENT_ID \ - --client-secret YOUR_CLIENT_SECRET - ``` - - Follow the prompts and save the refresh token. - -4. **Get Extension ID** - - If new extension: Upload manually once to get the ID - - Find it in Chrome Web Store Developer Dashboard - -### Step 2: Configure GitHub Secrets - -In your GitHub repository, go to Settings → Secrets and variables → Actions - -Add these secrets: -- `CHROME_CLIENT_ID`: Your OAuth client ID -- `CHROME_CLIENT_SECRET`: Your OAuth client secret -- `CHROME_REFRESH_TOKEN`: The refresh token obtained above -- `CHROME_EXTENSION_ID`: Your extension's ID in Chrome Web Store - -### Step 3: Workflows Overview - -#### 1. **Build and Test Workflow** (`build-and-test.yml`) -- **Triggers**: Push to main/develop, pull requests -- **Actions**: - - Validates manifest.json - - Checks all required files exist - - Creates extension ZIP package - - Uploads artifact for testing - -#### 2. **Release Workflow** (`release-chrome-store.yml`) -- **Triggers**: - - Push tags starting with 'v' (e.g., v1.0.0) - - Manual trigger with version bump option -- **Actions**: - - Bumps version if triggered manually - - Creates extension package - - Uploads to Chrome Web Store - - Publishes extension (may require review) - - Creates GitHub release - -## 📦 Usage Guide - -### Automatic Releases (Recommended) - -1. **Bump version locally**: - ```bash - node toolkit/scripts/bump-version.js patch # or minor/major - ``` - -2. **Commit and tag**: - ```bash - git add manifest.json - git commit -m "Bump version to 1.0.1" - git tag v1.0.1 - git push && git push --tags - ``` - -3. **Automatic deployment**: - - GitHub Actions will automatically: - - Build the extension - - Upload to Chrome Web Store - - Create GitHub release - -### Manual Release - -1. Go to Actions tab in GitHub -2. Select "Release to Chrome Web Store" -3. Click "Run workflow" -4. Select release type (patch/minor/major) -5. Click "Run workflow" - -### Testing Builds - -For any push or PR: -1. Go to Actions tab -2. Click on the workflow run -3. Download the artifact to test locally - -## 🔍 Monitoring Deployments - -### Check Workflow Status -- Go to Actions tab in GitHub -- Green ✅ = Success -- Red ❌ = Failed (check logs) -- Yellow 🟡 = In progress - -### Chrome Web Store Status -- Check [Developer Dashboard](https://chrome.google.com/webstore/developer/dashboard) -- New versions may be "Pending Review" -- Review typically takes 1-3 days - -## 🚨 Troubleshooting - -### Common Issues - -1. **"Missing Chrome credentials"** - - Ensure all 4 secrets are set in GitHub - - Check secret names match exactly - -2. **"Upload failed"** - - Verify extension ID is correct - - Check API quotas in Google Cloud Console - - Ensure refresh token is valid - -3. **"Publish pending review"** - - Normal for significant changes - - Check email for Chrome Web Store notifications - -4. **"Invalid manifest"** - - Run build workflow first to validate - - Check version format (major.minor.patch) - -### Manual Testing - -Test the package locally: -```bash -# Create package -bun run pack - -# Load in Chrome -1. Open chrome://extensions/ -2. Enable Developer Mode -3. Load unpacked → select dist/ folder -``` - -## 📊 Version Management - -### Semantic Versioning -- **Patch** (1.0.X): Bug fixes -- **Minor** (1.X.0): New features (backward compatible) -- **Major** (X.0.0): Breaking changes - -### Version Bump Script -```bash -# Bump patch version (1.0.0 → 1.0.1) -bun run bump:patch - -# Bump minor version (1.0.0 → 1.1.0) -bun run bump:minor - -# Bump major version (1.0.0 → 2.0.0) -bun run bump:major -``` - -## 🔐 Security Best Practices - -1. **Never commit credentials** - - Use GitHub Secrets only - - Add credentials to .gitignore - -2. **Rotate tokens periodically** - - Regenerate refresh token every 6 months - - Update GitHub secrets - -3. **Limit permissions** - - Only grant necessary API scopes - - Use separate credentials for CI/CD - -4. **Monitor access** - - Check Google Cloud Console logs - - Review GitHub Actions history - -## 📝 Checklist for First Release - -- [ ] Chrome Web Store developer account created -- [ ] Extension uploaded manually once (to get ID) -- [ ] OAuth credentials created in Google Cloud -- [ ] Refresh token generated -- [ ] All 4 GitHub secrets configured -- [ ] Test build workflow runs successfully -- [ ] Version in manifest.json is correct -- [ ] Ready to tag and release - -## 🔗 Useful Links - -- [Chrome Web Store Developer Dashboard](https://chrome.google.com/webstore/developer/dashboard) -- [Chrome Web Store API Documentation](https://developer.chrome.com/docs/webstore/api/) -- [GitHub Actions Documentation](https://docs.github.com/en/actions) -- [Google Cloud Console](https://console.cloud.google.com/) - -## 📧 Support - -For issues with: -- **GitHub Actions**: Check workflow logs in Actions tab -- **Chrome Web Store**: Contact Chrome Web Store support -- **Extension bugs**: Create issue in GitHub repository - ---- - -Last updated: January 2026 -Clean-Autofill Chrome Extension diff --git a/docs/README.md b/docs/README.md index 7cde2a1..a6ecd45 100644 --- a/docs/README.md +++ b/docs/README.md @@ -38,6 +38,29 @@ The extension extracts the main domain (removing subdomains like `www.` or `app. - `account.apple.com` → `apple.com@yourdomain.com` - `mail.google.com` → `google.com@yourdomain.com` +## Email Provider Compatibility + +Clean Autofill supports two modes. Provider compatibility determines which mode you can use: + +| Provider | Plus Addressing | Catch-All Prefix | +|----------|:-:|:-:| +| Custom domain | ✅* | ✅ | +| Google Workspace | ✅* | ✅ | +| Gmail | ✅ | — | +| Outlook / Hotmail / Live | ✅ | — | +| Apple iCloud | ❌ | — | +| Yahoo / Ymail | ❌ | — | +| ProtonMail | ✅ | — | +| GMX / web.de | ❌ | — | +| mail.com | ❌ | — | +| T-Online | ❌ | — | +| Fastmail | ✅ | — | +| mailbox.org | ✅ | — | + +\*If your email host supports plus addressing. Outlook.com consumer accounts commonly work with `+tag` but Microsoft's official plus-addressing docs are for Exchange Online. Zoho Mail is unverified. + +See [Email Provider Details](Email-Provider.md) for the full decision table and provider notes. + ## Tech Stack - **TypeScript** - Strict mode, compiles to `dist/` @@ -54,7 +77,7 @@ The extension extracts the main domain (removing subdomains like `www.` or `app. bun run build # Run tests (119 tests with DOM support) -bun test src/ +bun run test # Run tests in watch mode bun run test:watch @@ -147,7 +170,6 @@ The extension will: Clean-Autofill/ ├── manifest.json # Extension configuration (MV3) ├── package.json # NPM/Bun configuration -├── bunfig.toml # Bun test configuration (DOM support) ├── .github/ │ └── workflows/ │ └── ci.yml # GitHub Actions CI pipeline @@ -215,7 +237,7 @@ The extension requires minimal permissions: 1. Edit TypeScript files in `src/` 2. Run `bun run build` to compile to `dist/` 3. Load `dist/` folder in Chrome (chrome://extensions, Developer mode) -4. Run `bun test src/` to verify changes +4. Run `bun run test` to verify changes 5. Run `bun run check` before committing Pre-commit hooks automatically run type checking, linting, and tests. @@ -225,7 +247,7 @@ Pre-commit hooks automatically run type checking, linting, and tests. Tests are colocated with source files (`*.test.ts`). DOM testing is supported via happy-dom. ```bash -bun test src/ # Run all 119 tests +bun run test # Run all 119 tests bun run test:watch # Watch mode bun run test:coverage # Coverage report (98%+ line coverage) ``` diff --git a/docs/Screenshots/Clean-Autofill-Screenshot-1.png b/docs/Screenshots/Clean-Autofill-Screenshot-1.png deleted file mode 100644 index 118a13c..0000000 Binary files a/docs/Screenshots/Clean-Autofill-Screenshot-1.png and /dev/null differ diff --git a/docs/Screenshots/Clean-Autofill-Screenshot-2.png b/docs/Screenshots/Clean-Autofill-Screenshot-2.png deleted file mode 100644 index a07497d..0000000 Binary files a/docs/Screenshots/Clean-Autofill-Screenshot-2.png and /dev/null differ diff --git a/docs/Screenshots/Options-1-Home.png b/docs/Screenshots/Options-1-Home.png new file mode 100644 index 0000000..2444973 Binary files /dev/null and b/docs/Screenshots/Options-1-Home.png differ diff --git a/docs/Screenshots/Options-2a-Settings-PlusAddressing.png b/docs/Screenshots/Options-2a-Settings-PlusAddressing.png new file mode 100644 index 0000000..2360a4e Binary files /dev/null and b/docs/Screenshots/Options-2a-Settings-PlusAddressing.png differ diff --git a/docs/Screenshots/Options-2b-Settings-CatchAll.png b/docs/Screenshots/Options-2b-Settings-CatchAll.png new file mode 100644 index 0000000..1c68ecc Binary files /dev/null and b/docs/Screenshots/Options-2b-Settings-CatchAll.png differ diff --git a/docs/Screenshots/Options-3-History.png b/docs/Screenshots/Options-3-History.png new file mode 100644 index 0000000..e160d8c Binary files /dev/null and b/docs/Screenshots/Options-3-History.png differ diff --git a/docs/Screenshots/Options-4-Help.png b/docs/Screenshots/Options-4-Help.png new file mode 100644 index 0000000..8f1e015 Binary files /dev/null and b/docs/Screenshots/Options-4-Help.png differ diff --git a/docs/Screenshots/Signup-Netflix.com-Filled.png b/docs/Screenshots/Signup-Netflix.com-Filled.png new file mode 100644 index 0000000..e743015 Binary files /dev/null and b/docs/Screenshots/Signup-Netflix.com-Filled.png differ diff --git a/docs/Screenshots/Signup-TED.com-Filled.png b/docs/Screenshots/Signup-TED.com-Filled.png new file mode 100644 index 0000000..15a87fa Binary files /dev/null and b/docs/Screenshots/Signup-TED.com-Filled.png differ diff --git a/docs/Screenshots/Signup-UI.com-Filled.png b/docs/Screenshots/Signup-UI.com-Filled.png new file mode 100644 index 0000000..267e3e7 Binary files /dev/null and b/docs/Screenshots/Signup-UI.com-Filled.png differ diff --git a/docs/chrome-web-store-release.md b/docs/chrome-web-store-release.md deleted file mode 100644 index c995933..0000000 --- a/docs/chrome-web-store-release.md +++ /dev/null @@ -1,244 +0,0 @@ -# Chrome Web Store Release via GitHub Actions - -## Current State - -Your project already has a fully configured release workflow at `.github/workflows/release-chrome-store.yml`. It supports: - -- **Tag-based releases**: Push a version tag to trigger automatic release (currently uses `v*`, will change to `[0-9]*`) -- **Manual dispatch**: Run from GitHub Actions UI with version bump selection (patch/minor/major) -- **Automatic version bumping**: Updates `manifest.json` and commits the change -- **Chrome Web Store upload & publish**: Uses Chrome Web Store API v1.1 -- **GitHub Release creation**: Attaches the `.zip` artifact with release notes - -The workflow gracefully skips Chrome Web Store upload if credentials aren't configured. - ---- - -## Setup Steps - -### Step 1: Register as a Chrome Web Store Developer - -1. Go to [Chrome Web Store Developer Dashboard](https://chrome.google.com/webstore/devconsole) -2. Pay the one-time $5 registration fee -3. Accept the developer agreement - -### Step 2: Create Your Extension Entry (First Time Only) - -1. In the Developer Dashboard, click **New Item** -2. Upload your `dist/Clean-Autofill.zip` manually -3. Complete the store listing: - - Description, screenshots, category - - Privacy policy (required) - - Justify permissions if prompted -4. Save as draft (don't publish yet) -5. **Copy the Extension ID** from the URL: `https://chrome.google.com/webstore/devconsole/.../items/EXTENSION_ID` - -### Step 3: Create Google Cloud OAuth Credentials (Detailed) - -#### 3.1 Create or Select a Google Cloud Project - -1. Go to [Google Cloud Console](https://console.cloud.google.com/) -2. Click the project dropdown (top-left, next to "Google Cloud") -3. Click **New Project** - - Project name: `Clean-Autofill-Publisher` - - Organization: Leave as default or select yours - - Click **Create** -4. Wait for project creation, then select it from the dropdown - -#### 3.2 Enable Chrome Web Store API - -1. In Google Cloud Console, go to **APIs & Services** → **Library** -2. Search for `Chrome Web Store API` -3. Click on it, then click **Enable** -4. Wait for the API to be enabled - -#### 3.3 Configure OAuth Consent Screen (Required First) - -1. Go to **APIs & Services** → **OAuth consent screen** -2. Select User Type: - - **External** (for personal Google accounts) - - **Internal** (only if using Google Workspace) -3. Click **Create** -4. Fill in the App Information: - - App name: `Clean-Autofill Publisher` - - User support email: Your email - - Developer contact: Your email -5. Click **Save and Continue** -6. **Scopes**: Click **Add or Remove Scopes** - - Search for `chromewebstore` - - Check `https://www.googleapis.com/auth/chromewebstore` - - Click **Update**, then **Save and Continue** -7. **Test Users**: Click **Add Users** - - Add your Google account email (the one that owns the Chrome Web Store developer account) - - Click **Save and Continue** -8. Review and click **Back to Dashboard** - -#### 3.4 Create OAuth Client ID - -1. Go to **APIs & Services** → **Credentials** -2. Click **Create Credentials** → **OAuth client ID** -3. Application type: **Desktop app** -4. Name: `Clean-Autofill GitHub Actions` -5. Click **Create** -6. **Important**: Copy and save both values: - - **Client ID**: `xxxx.apps.googleusercontent.com` - - **Client Secret**: `GOCSPX-xxxx` -7. Click **OK** - -### Step 4: Generate Refresh Token (Detailed) - -#### 4.1 Get Authorization Code - -1. Construct this URL (replace `YOUR_CLIENT_ID` with your actual client ID): - -``` -https://accounts.google.com/o/oauth2/auth?response_type=code&scope=https://www.googleapis.com/auth/chromewebstore&client_id=YOUR_CLIENT_ID&redirect_uri=http://localhost -``` - -2. Open the URL in your browser -3. Sign in with the Google account that owns the Chrome Web Store developer account -4. Click **Continue** on the consent screen -5. You'll be redirected to `http://localhost/?code=AUTHORIZATION_CODE&scope=...` -6. **Copy the `code` value** from the URL (everything between `code=` and `&scope`) - - Example: `4/0AQlEd8xxxxxxxxxxx` - - Note: URL decode if needed (`%2F` → `/`) - -#### 4.2 Exchange Code for Refresh Token - -Run this curl command (replace all placeholders): - -```bash -curl -X POST https://oauth2.googleapis.com/token \ - -d "client_id=YOUR_CLIENT_ID" \ - -d "client_secret=YOUR_CLIENT_SECRET" \ - -d "code=YOUR_AUTHORIZATION_CODE" \ - -d "grant_type=authorization_code" \ - -d "redirect_uri=http://localhost" -``` - -#### 4.3 Save the Refresh Token - -The response will look like: -```json -{ - "access_token": "ya29.xxxx", - "expires_in": 3599, - "refresh_token": "1//0xxxx-SAVE-THIS-VALUE-xxxx", - "scope": "https://www.googleapis.com/auth/chromewebstore", - "token_type": "Bearer" -} -``` - -**Copy the `refresh_token` value** - this is what you'll add to GitHub Secrets. - -#### Troubleshooting Common Issues - -| Error | Solution | -|-------|----------| -| `invalid_client` | Double-check client ID and secret are correct | -| `invalid_grant` | Authorization code expired (valid ~10 min). Get a new one | -| `access_denied` | Make sure you added yourself as a test user in OAuth consent screen | -| `redirect_uri_mismatch` | Use exactly `http://localhost` (not `https`, no trailing slash) | - -### Step 5: Configure GitHub Secrets - -Go to your repo → Settings → Secrets and variables → Actions → New repository secret - -Add these 4 secrets: - -| Secret Name | Value | -|-------------|-------| -| `CHROME_CLIENT_ID` | OAuth client ID from Step 3 | -| `CHROME_CLIENT_SECRET` | OAuth client secret from Step 3 | -| `CHROME_REFRESH_TOKEN` | Refresh token from Step 4 | -| `CHROME_EXTENSION_ID` | Extension ID from Step 2 | - ---- - -## Usage - -### Option A: Tag-based Release -```bash -git tag 1.0.0 -git push origin 1.0.0 -``` - -### Option B: Manual Dispatch -1. Go to Actions → "Release to Chrome Web Store" -2. Click "Run workflow" -3. Select release type (patch/minor/major) -4. Click "Run workflow" - ---- - -## Required Workflow Modification - -The current workflow uses `v*` tags. To use the preferred `1.2.3` format, update `.github/workflows/release-chrome-store.yml`: - -**Change line 6 from:** -```yaml -tags: - - 'v*' -``` - -**To:** -```yaml -tags: - - '[0-9]*' -``` - -**Also update line 213 from:** -```yaml -git tag "v${{ steps.bump_version.outputs.NEW_VERSION }}" -``` - -**To:** -```yaml -git tag "${{ steps.bump_version.outputs.NEW_VERSION }}" -``` - ---- - -## Alternative: Use Existing GitHub Actions - -Instead of the custom curl-based approach, you could use community actions: - -- [mnao305/chrome-extension-upload](https://github.com/marketplace/actions/chrome-extension-upload-action) - Popular, well-maintained -- [browser-actions/release-chrome-extension](https://github.com/browser-actions/release-chrome-extension) - Feature-rich - -Example with `mnao305/chrome-extension-upload`: -```yaml -- uses: mnao305/chrome-extension-upload@v5.0.0 - with: - file-path: dist/Clean-Autofill.zip - extension-id: ${{ secrets.CHROME_EXTENSION_ID }} - client-id: ${{ secrets.CHROME_CLIENT_ID }} - client-secret: ${{ secrets.CHROME_CLIENT_SECRET }} - refresh-token: ${{ secrets.CHROME_REFRESH_TOKEN }} -``` - ---- - -## Important Notes - -1. **Review time**: Chrome Web Store reviews typically take 1-3 business days -2. **Token expiry**: Refresh tokens may expire after 6 months of inactivity. Consider scheduling a keep-alive workflow -3. **First upload**: Must be done manually via Developer Dashboard before automation works -4. **Permissions**: Store may require justification for `` permission in content scripts - ---- - -## Files Involved - -- `.github/workflows/release-chrome-store.yml` - The release workflow (already configured) -- `manifest.json` - Version is auto-bumped during release -- `toolkit/scripts/pack.js` - Creates the `.zip` for upload - ---- - -## Verification - -After setup, test by running the workflow manually with a patch version bump. Check: -1. GitHub Actions log shows successful upload -2. Developer Dashboard shows new version pending review -3. GitHub Release is created with artifact diff --git a/docs/store-listing/checklist.md b/docs/store-listing/checklist.md new file mode 100644 index 0000000..70ff096 --- /dev/null +++ b/docs/store-listing/checklist.md @@ -0,0 +1,57 @@ +# Chrome Web Store — Publishing Checklist + +Use this checklist when updating the store listing or publishing a new version. + +--- + +## Before Publishing + +### Assets +- [ ] **Icon** — `images/icon-128.png` (128x128) uploaded +- [ ] **Screenshot 1** — `images/screenshot-1.png` (1280x800) uploaded +- [ ] **Screenshot 2** — `images/screenshot-2.png` (1280x800) uploaded +- [ ] **Screenshot 3** — `images/screenshot-3.png` (1280x800) captured and uploaded +- [ ] **Screenshot 4** — `images/screenshot-4.png` (1280x800) captured and uploaded +- [ ] **Screenshot 5** — `images/screenshot-5.png` (1280x800) captured and uploaded +- [ ] **Small Promo** — `images/small-promo-440x280.png` (440x280) designed and uploaded +- [ ] **Marquee** — `images/marquee-1400x560.png` (1400x560) designed and uploaded (optional) + +### Store Listing Tab +- [ ] **Title** — Paste from `texts.md` → Title (max 75 chars) +- [ ] **Summary** — Paste from `texts.md` → Summary (max 132 chars) +- [ ] **Description** — Paste from `texts.md` → Description (plain text block) +- [ ] **Category** — Select "Productivity" +- [ ] **Language** — English + +### Privacy Tab +- [ ] **Single purpose** — Paste from `privacy-justifications.md` → Single Purpose Description +- [ ] **Permission justifications** — Paste each from `privacy-justifications.md` +- [ ] **Privacy policy URL** — `https://github.com/ZAAI-com/Clean-Autofill/blob/main/docs/PRIVACY.md` +- [ ] **Data use disclosures** — Confirm "Does not collect user data" + +### Package +- [ ] Version bumped in `manifest.json` +- [ ] `bun run build` succeeds +- [ ] `bun run test` passes +- [ ] `bun run check` passes +- [ ] `bun run pack` creates `dist/Clean-Autofill.zip` +- [ ] Upload `.zip` via dashboard or GitHub Actions + +--- + +## Publishing + +1. Go to [Chrome Web Store Developer Dashboard](https://chrome.google.com/webstore/devconsole) +2. Select "Clean Autofill" from your items +3. Update any changed fields (texts, images, package) +4. Click "Submit for review" +5. Review typically takes 1-3 business days + +--- + +## After Publishing + +- [ ] Verify listing appears correctly on the [store page](https://chromewebstore.google.com/detail/clean-autofill/klbbkndjohchnidkbnjijdbggfadpppf) +- [ ] Check all screenshots display properly +- [ ] Test install from store on a clean Chrome profile +- [ ] Update `docs/store-listing/` if any changes were made directly in the dashboard diff --git a/docs/store-listing/images/README.md b/docs/store-listing/images/README.md new file mode 100644 index 0000000..658b786 --- /dev/null +++ b/docs/store-listing/images/README.md @@ -0,0 +1,133 @@ +# Chrome Web Store — Image Assets + +## Brand Colors + +| Color | Hex | Usage | +|-------|-----|-------| +| Primary Green | `#4CAF50` | Buttons, accents, icon background | +| Dark Green | `#388E3C` | Icon shield gradient | +| White | `#FFFFFF` | Backgrounds, text on green | +| Dark Text | `#333333` | Body text | +| Light Gray | `#F5F5F5` | Section backgrounds | + +--- + +## Store Icon + +| Property | Value | +|----------|-------| +| File | `icon-128.png` | +| Dimensions | 128x128 px | +| Format | PNG | +| Status | Done | + +Green shield with @ symbol. Already meets Chrome Web Store requirements (96px artwork + padding). + +--- + +## Generation + +Run: + +```bash +python3 docs/store-listing/images/generate.py +``` + +The generator renders real extension UI from `src/ui/options.html`, `src/ui/options.css`, and `src/ui/popup.html`, then composes the final store assets on a shared dark gradient background. The two action shots try live captures of recognizable signup pages (Netflix, Wikipedia) and fall back to deterministic local mocks if those pages change. + +--- + +## Screenshots (1280x800 px, PNG) + +Chrome Web Store allows up to 5 screenshots. Minimum 1 required. 1280x800 is the preferred high-resolution size. + +### Screenshot 1 — One-Click Fill + +| Property | Value | +|----------|-------| +| File | `screenshot-1.png` | +| Dimensions | 1280x800 px | +| Status | Done | + +Shows a browser-framed Netflix signup scene with a generated address already filled into the email field. + +### Screenshot 2 — Instant Popup + +| Property | Value | +|----------|-------| +| File | `screenshot-2.png` | +| Dimensions | 1280x800 px | +| Status | Done | + +Shows a Wikipedia create-account form with the real extension popup open and the generated email visible. + +### Screenshot 3 — Provider Match + +| Property | Value | +|----------|-------| +| File | `screenshot-3.png` | +| Dimensions | 1280x800 px | +| Status | Done | + +Shows the real Settings page with Gmail provider detection, plus-addressing selected, and two visible example rows. + +### Screenshot 4 — Signup History + +| Property | Value | +|----------|-------| +| File | `screenshot-4.png` | +| Dimensions | 1280x800 px | +| Status | Done | + +Shows the real History page with five example entries and one highlighted row. + +### Screenshot 5 — See Examples + +| Property | Value | +|----------|-------| +| File | `screenshot-5.png` | +| Dimensions | 1280x800 px | +| Status | Done | + +Shows the real Home page with the 3-step explanation and four example mappings. + +--- + +## Small Promotional Image (440x280 px, PNG) + +| Property | Value | +|----------|-------| +| File | `small-promo-440x280.png` | +| Dimensions | 440x280 px | +| Format | PNG | +| Status | Done | + +**Required** — extensions without this image rank lower in store search results. + +Uses the same visual system as the screenshots: soft background, floating icon tile, and a single email pill to communicate the feature quickly. + +--- + +## Marquee Promotional Image (1400x560 px, PNG) + +| Property | Value | +|----------|-------| +| File | `marquee-1400x560.png` | +| Dimensions | 1400x560 px | +| Format | PNG | +| Status | Done | + +**Optional** — required only if seeking featured placement in the store. + +Uses the same shared background and browser-card language as the screenshots, with value copy on the left and a Netflix autofill hero scene on the right. + +--- + +## General Guidelines + +- All images must be PNG format +- No alpha transparency on promotional images (use solid backgrounds) +- Avoid excessive text overlays on screenshots +- Use saturated colors and well-defined edges +- Ensure screenshots look clean — hide bookmarks bar, minimize open tabs +- Test that images look good at both full size and thumbnail diff --git a/docs/store-listing/images/generate.py b/docs/store-listing/images/generate.py new file mode 100644 index 0000000..5204472 --- /dev/null +++ b/docs/store-listing/images/generate.py @@ -0,0 +1,1093 @@ +#!/usr/bin/env python3 +""" +Generate Chrome Web Store listing images for Clean Autofill. + +The pipeline: +1. Load real screenshots from docs/store-listing/screenshots/. +2. Crop off macOS window shadow and Chrome chrome to get page content. +3. Compose those crops into polished store assets with a shared dark-background visual system. + +The marquee banner still uses a deterministic mock for stability. +""" + +from __future__ import annotations + +import base64 +import html +import io +import shutil +from pathlib import Path + +from PIL import Image +from playwright.sync_api import sync_playwright + +ROOT = Path(__file__).parent.parent.parent.parent +OUT = Path(__file__).parent +SCREENSHOTS = ROOT / "docs" / "Screenshots" +SRC_ICONS = ROOT / "src" / "icons" + +APP_ICON_URI = "" + +# --- Dark theme card styling --- +CARD_SHADOW = "0 32px 72px rgba(0, 0, 0, 0.45), 0 12px 28px rgba(0, 0, 0, 0.30)" +CARD_BORDER = "1px solid rgba(255, 255, 255, 0.10)" +CARD_RADIUS = 20 + +# Chrome chrome height (from window top to page content) in the real screenshots. +# Measured from alpha-channel analysis: window starts at y=76, content at y=250. +CHROME_HEIGHT = 174 # pixels within the window (tab bar + address bar) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def data_uri(path: Path, mime: str | None = None) -> str: + suffix = path.suffix.lower() + if mime is None: + if suffix == ".svg": + mime = "image/svg+xml" + elif suffix == ".png": + mime = "image/png" + else: + mime = "application/octet-stream" + payload = base64.b64encode(path.read_bytes()).decode() + return f"data:{mime};base64,{payload}" + + +def png_uri(png_bytes: bytes) -> str: + return f"data:image/png;base64,{base64.b64encode(png_bytes).decode()}" + + +def load_and_crop(path: Path, target_aspect: float, crop_mode: str = "content") -> bytes: + """Load a macOS screenshot, strip shadow, crop to target aspect ratio. + + crop_mode: + "content" — strip Chrome chrome, keep page content from top (default) + "with_chrome" — keep Chrome chrome (tab bar + address bar) in the crop + "center_content" — strip Chrome chrome, then center-crop vertically + """ + img = Image.open(path) + + # Find opaque window bounds (skip macOS shadow which has alpha < 255) + if img.mode != "RGBA": + img = img.convert("RGBA") + alpha = img.getchannel("A") + bbox = alpha.point(lambda p: 255 if p > 200 else 0).getbbox() + if not bbox: + raise ValueError(f"Could not find opaque region in {path}") + win_left, win_top, win_right, win_bottom = bbox + + if crop_mode == "with_chrome": + # Include Chrome chrome — start from window top + content_top = win_top + else: + # Strip Chrome chrome — start from page content + content_top = win_top + CHROME_HEIGHT + + content_left = win_left + content_right = win_right + content_bottom = win_bottom + + content_width = content_right - content_left + content_height = content_bottom - content_top + + # Crop height to match target aspect ratio + target_height = int(content_width / target_aspect) + if target_height < content_height: + if crop_mode == "center_content": + # Center the crop vertically to capture mid-page content + excess = content_height - target_height + content_top += excess // 2 + content_bottom = content_top + target_height + else: + # Top-aligned crop + content_bottom = content_top + target_height + + cropped = img.crop((content_left, content_top, content_right, content_bottom)) + + # Convert RGBA to RGB (white background) for the data URI + rgb = Image.new("RGB", cropped.size, (255, 255, 255)) + rgb.paste(cropped, mask=cropped.split()[3]) + + buf = io.BytesIO() + rgb.save(buf, format="PNG") + return buf.getvalue() + + +def load_full_window(path: Path) -> bytes: + """Load a macOS screenshot, strip only the shadow, keep the full window.""" + img = Image.open(path) + if img.mode != "RGBA": + img = img.convert("RGBA") + alpha = img.getchannel("A") + bbox = alpha.point(lambda p: 255 if p > 200 else 0).getbbox() + if not bbox: + raise ValueError(f"Could not find opaque region in {path}") + cropped = img.crop(bbox) + rgb = Image.new("RGB", cropped.size, (255, 255, 255)) + rgb.paste(cropped, mask=cropped.split()[3]) + buf = io.BytesIO() + rgb.save(buf, format="PNG") + return buf.getvalue() + + +def render_markup(page, markup: str, width: int, height: int) -> bytes: + page.set_viewport_size({"width": width, "height": height}) + page.set_content(markup, wait_until="load") + page.wait_for_timeout(80) + return page.screenshot(type="png") + + +# --------------------------------------------------------------------------- +# Marquee mock — Netflix signup (deterministic, no live capture needed) +# --------------------------------------------------------------------------- + +def netflix_site_html(width: int, height: int) -> str: + return f""" + + + + + + + + +
+

Unlimited movies, TV shows, and more

+

Starts at $7.99. Cancel anytime.

+

Ready to watch? Enter your email to create or restart your membership.

+ +
+ + + """ + + +# Popup mock for marquee only +POPUP_HTML_TEMPLATE = """ + + + +
+

Clean Autofill

+
+ +
Filled into email field
+""" + + +def render_popup(page, email_text: str, icon_uri: str) -> bytes: + markup = POPUP_HTML_TEMPLATE.replace("__ICON__", icon_uri).replace("__EMAIL__", email_text) + page.set_viewport_size({"width": 360, "height": 156}) + page.set_content(markup, wait_until="load") + page.wait_for_timeout(60) + return page.locator("body").screenshot(type="png") + + +# --------------------------------------------------------------------------- +# Scene composition — dark-background design system +# --------------------------------------------------------------------------- + +def scene_shell(content: str, width: int, height: int, bg_from: str, bg_to: str) -> str: + """Wrap scene content in a dark gradient background with subtle glow.""" + return f""" + + + + + + + {content} + +""" + + +def browser_scene( + headline: str, + subtitle: str, + url_text: str, + site_image_uri: str, + bg_from: str, + bg_to: str, +) -> str: + content = f""" +
+
+

{html.escape(headline)}

+

{html.escape(subtitle)}

+
+
+
+
+ + + +
+
+
+ {html.escape(url_text)} +
+
+
+ +
+
+
+ """ + return scene_shell(content, 1280, 800, bg_from, bg_to) + + +def extension_scene( + headline: str, + subtitle: str, + screenshot_uri: str, + bg_from: str, + bg_to: str, +) -> str: + content = f""" +
+
+

{html.escape(headline)}

+

{html.escape(subtitle)}

+
+
+ +
+
+ """ + return scene_shell(content, 1280, 800, bg_from, bg_to) + + +def split_scene( + headline: str, + subtitle: str, + bullets: list[str], + screenshot_uri: str, + bg_from: str, + bg_to: str, +) -> str: + """Text on left, full screenshot card on right.""" + bullet_html = "\n".join(f'
  • {html.escape(b)}
  • ' for b in bullets) + content = f""" +
    +
    +

    {html.escape(headline)}

    +

    {html.escape(subtitle)}

    +
      + {bullet_html} +
    +
    +
    + +
    +
    + """ + # Override the .stage default positioning and add bullet styling + shell = scene_shell(content, 1280, 800, bg_from, bg_to) + bullet_css = """ + li { + display: flex; + align-items: center; + gap: 10px; + font-size: 17px; + color: rgba(255, 255, 255, 0.78); + line-height: 1.4; + } + li::before { + content: ""; + width: 8px; + height: 8px; + border-radius: 50%; + background: #4CAF50; + flex-shrink: 0; + } + """ + return shell.replace("", f"{bullet_css}\n", 1) + + +def small_promo_html() -> str: + return f""" + + + + + + +
    +
    +
    Clean Autofill
    + +
    One click. Unique emails.
    +
    + +""" + + +def marquee_html(site_image_uri: str, popup_image_uri: str) -> str: + return f""" + + + + + + +
    +
    +
    +
    +
    Clean Autofill
    +
    +

    Unique email addresses in one click.

    +

    Generate the right address for each website, fill it instantly, and keep a clean record of every signup.

    +
      +
    • Per-site addresses that stay easy to filter
    • +
    • Provider detection for plus addressing
    • +
    • Searchable history for every signup
    • +
    +
    +
    +
    +
    + + + +
    +
    https://netflix.com
    +
    +
    + +
    + +
    +
    + +""" + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +# Browser body aspect: (1280 - 52 - 52) / (680 - 52) = 1176 / 628 +BROWSER_BODY_ASPECT = 1176 / 628 + +# Extension card aspect: (1280 - 52 - 52) / 660 = 1176 / 660 +EXTENSION_CARD_ASPECT = 1176 / 660 + + +def main() -> None: + global APP_ICON_URI + + APP_ICON_URI = data_uri(SRC_ICONS / "icon128.png") + + print("Generating Chrome Web Store images...\n") + + # --- Load and crop real screenshots --- + # Action shots (browser scenes) + netflix_png = load_and_crop(SCREENSHOTS / "Signup-Netflix.com-Filled.png", BROWSER_BODY_ASPECT) + ted_png = load_and_crop(SCREENSHOTS / "Signup-TED.com-Filled.png", BROWSER_BODY_ASPECT) + ui_png = load_and_crop(SCREENSHOTS / "Signup-UI.com-Filled.png", BROWSER_BODY_ASPECT) + + # Extension pages (extension scenes) + home_png = load_and_crop(SCREENSHOTS / "Options-1-Home.png", EXTENSION_CARD_ASPECT) + settings_plus_png = load_and_crop(SCREENSHOTS / "Options-2a-Settings-PlusAddressing.png", EXTENSION_CARD_ASPECT) + settings_catchall_png = load_and_crop(SCREENSHOTS / "Options-2b-Settings-CatchAll.png", EXTENSION_CARD_ASPECT) + history_png = load_and_crop(SCREENSHOTS / "Options-3-History.png", EXTENSION_CARD_ASPECT) + help_png = load_and_crop(SCREENSHOTS / "Options-4-Help.png", EXTENSION_CARD_ASPECT) + + # Full window screenshots for split layout (text left, screenshot right) + settings_plus_full_png = load_full_window(SCREENSHOTS / "Options-2a-Settings-PlusAddressing.png") + settings_catchall_full_png = load_full_window(SCREENSHOTS / "Options-2b-Settings-CatchAll.png") + + # --- Marquee assets (mock Netflix + popup, rendered with Playwright) --- + with sync_playwright() as p: + browser = p.chromium.launch() + source_context = browser.new_context(device_scale_factor=2) + scene_context = browser.new_context(device_scale_factor=2) + + source_page = source_context.new_page() + scene_page = scene_context.new_page() + + netflix_marquee_png = render_markup(source_page, netflix_site_html(768, 470), 768, 470) + popup_marquee_png = render_popup(source_page, "netflix.com@yourdomain.com", APP_ICON_URI) + + assets = { + "netflix": png_uri(netflix_png), + "ted": png_uri(ted_png), + "ui": png_uri(ui_png), + "home": png_uri(home_png), + "settings_plus": png_uri(settings_plus_png), + "settings_catchall": png_uri(settings_catchall_png), + "history": png_uri(history_png), + "help": png_uri(help_png), + "settings_plus_full": png_uri(settings_plus_full_png), + "settings_catchall_full": png_uri(settings_catchall_full_png), + "netflix_marquee": png_uri(netflix_marquee_png), + "popup_marquee": png_uri(popup_marquee_png), + } + + shots = [ + # 1 — Netflix signup with popup + ( + "screenshot-1.png", + browser_scene( + headline="One Click. Auto-Filled.", + subtitle="A unique email address for every signup", + url_text="https://netflix.com", + site_image_uri=assets["netflix"], + bg_from="#1B3A2A", + bg_to="#0F2A1C", + ), + 1280, + 800, + ), + # 2 — TED signup with popup + ( + "screenshot-2.png", + browser_scene( + headline="Instant Email Generation", + subtitle="Generate, fill, and copy in one click", + url_text="https://auth.ted.com/users/new", + site_image_uri=assets["ted"], + bg_from="#1A2D42", + bg_to="#132235", + ), + 1280, + 800, + ), + # 3 — Ubiquiti signup with popup + ( + "screenshot-3.png", + browser_scene( + headline="Works on Every Website", + subtitle="Signup forms detected and filled automatically", + url_text="https://account.ui.com/register", + site_image_uri=assets["ui"], + bg_from="#1D2D3A", + bg_to="#14222D", + ), + 1280, + 800, + ), + # 4 — Home page + ( + "screenshot-4.png", + extension_scene( + headline="Simple Setup. Powerful Results.", + subtitle="Configure once, generate emails everywhere", + screenshot_uri=assets["home"], + bg_from="#1B3A2A", + bg_to="#0F2A1C", + ), + 1280, + 800, + ), + # 5 — Settings: Plus Addressing (split layout) + ( + "screenshot-5.png", + split_scene( + headline="Smart Provider Detection", + subtitle="Works with Gmail, Outlook, and 500+ providers", + bullets=[ + "Auto-detects your email provider", + "Plus addressing for Gmail, Outlook, and more", + "Per-site emails like name+site@gmail.com", + ], + screenshot_uri=assets["settings_plus_full"], + bg_from="#2A1F3D", + bg_to="#1A1530", + ), + 1280, + 800, + ), + # 6 — Settings: Catch-All (split layout) + ( + "screenshot-6.png", + split_scene( + headline="Catch-All Email Routing", + subtitle="Custom domain support with catch-all prefix mode", + bullets=[ + "Use your own domain for unique addresses", + "Catch-all prefix like site@yourdomain.com", + "Step-by-step setup guides included", + ], + screenshot_uri=assets["settings_catchall_full"], + bg_from="#1F2D3D", + bg_to="#152535", + ), + 1280, + 800, + ), + # 7 — History page + ( + "screenshot-7.png", + extension_scene( + headline="Every Signup. Tracked.", + subtitle="Search, copy, and manage your email history", + screenshot_uri=assets["history"], + bg_from="#2D2A1F", + bg_to="#201E15", + ), + 1280, + 800, + ), + # 8 — Help / Catch-All setup guide + ( + "screenshot-8.png", + extension_scene( + headline="Step-by-Step Setup Guides", + subtitle="Catch-all instructions for every major provider", + screenshot_uri=assets["help"], + bg_from="#1A3540", + bg_to="#122830", + ), + 1280, + 800, + ), + # Small promo tile + ("small-promo-440x280.png", small_promo_html(), 440, 280), + # Marquee banner + ("marquee-1400x560.png", marquee_html(assets["netflix_marquee"], assets["popup_marquee"]), 1400, 560), + ] + + for filename, markup, width, height in shots: + scene_page.set_viewport_size({"width": width, "height": height}) + scene_page.set_content(markup, wait_until="load") + scene_page.wait_for_timeout(80) + raw = scene_page.screenshot(type="png") + img = Image.open(io.BytesIO(raw)) + img = img.resize((width, height), Image.LANCZOS) + img.save(str(OUT / filename), "PNG") + print(f" ✓ {filename} ({width}x{height})") + + source_context.close() + scene_context.close() + browser.close() + + shutil.copy2(SRC_ICONS / "icon128.png", OUT / "icon-128.png") + print(" ✓ icon-128.png") + print(f"\nDone. Assets written to {OUT}") + + +if __name__ == "__main__": + main() diff --git a/docs/store-listing/images/icon-128.png b/docs/store-listing/images/icon-128.png new file mode 100644 index 0000000..c7ac92a Binary files /dev/null and b/docs/store-listing/images/icon-128.png differ diff --git a/docs/store-listing/images/marquee-1400x560.png b/docs/store-listing/images/marquee-1400x560.png new file mode 100644 index 0000000..20294b7 Binary files /dev/null and b/docs/store-listing/images/marquee-1400x560.png differ diff --git a/docs/store-listing/images/screenshot-1.png b/docs/store-listing/images/screenshot-1.png new file mode 100644 index 0000000..a9b066d Binary files /dev/null and b/docs/store-listing/images/screenshot-1.png differ diff --git a/docs/store-listing/images/screenshot-2.png b/docs/store-listing/images/screenshot-2.png new file mode 100644 index 0000000..cedb495 Binary files /dev/null and b/docs/store-listing/images/screenshot-2.png differ diff --git a/docs/store-listing/images/screenshot-3.png b/docs/store-listing/images/screenshot-3.png new file mode 100644 index 0000000..7e02f76 Binary files /dev/null and b/docs/store-listing/images/screenshot-3.png differ diff --git a/docs/store-listing/images/screenshot-4.png b/docs/store-listing/images/screenshot-4.png new file mode 100644 index 0000000..fe29f6f Binary files /dev/null and b/docs/store-listing/images/screenshot-4.png differ diff --git a/docs/store-listing/images/screenshot-5.png b/docs/store-listing/images/screenshot-5.png new file mode 100644 index 0000000..d30ebb8 Binary files /dev/null and b/docs/store-listing/images/screenshot-5.png differ diff --git a/docs/store-listing/images/screenshot-6.png b/docs/store-listing/images/screenshot-6.png new file mode 100644 index 0000000..6c01bee Binary files /dev/null and b/docs/store-listing/images/screenshot-6.png differ diff --git a/docs/store-listing/images/screenshot-7.png b/docs/store-listing/images/screenshot-7.png new file mode 100644 index 0000000..8588e1c Binary files /dev/null and b/docs/store-listing/images/screenshot-7.png differ diff --git a/docs/store-listing/images/screenshot-8.png b/docs/store-listing/images/screenshot-8.png new file mode 100644 index 0000000..400c333 Binary files /dev/null and b/docs/store-listing/images/screenshot-8.png differ diff --git a/docs/store-listing/images/small-promo-440x280.png b/docs/store-listing/images/small-promo-440x280.png new file mode 100644 index 0000000..b1a2115 Binary files /dev/null and b/docs/store-listing/images/small-promo-440x280.png differ diff --git a/docs/store-listing/privacy-justifications.md b/docs/store-listing/privacy-justifications.md new file mode 100644 index 0000000..6cc3724 --- /dev/null +++ b/docs/store-listing/privacy-justifications.md @@ -0,0 +1,51 @@ +# Chrome Web Store — Permission Justifications + +Ready to paste into the Developer Dashboard privacy tab. + +--- + +## `activeTab` + +**Justification:** +Required to read the current tab's URL when the user clicks the extension icon. The extension extracts the website's domain from the URL to generate a unique email address (e.g., visiting amazon.com generates amazon.com@yourdomain.com). The tab URL is only accessed at the moment of the click and is not stored or transmitted. + +--- + +## `storage` + +**Justification:** +Required to save the user's email domain setting (e.g., "yourdomain.com") in Chrome's sync storage so it persists across browser sessions and syncs across devices. Also used to store email history entries in local storage. No data is transmitted to external servers. + +--- + +## `notifications` + +**Justification:** +Required to show brief confirmation notifications when an email address is successfully generated and filled into a form field, or to alert the user if something went wrong (e.g., no email field found on the page). + +--- + +## `identity` + `identity.email` + +**Justification:** +Used to detect the user's Chrome profile email address during initial setup. This allows the extension to pre-fill the email domain setting and automatically detect the user's email provider for compatibility information. The email is only read locally and is never transmitted. + +--- + +## Host Permission: `https://dns.google/*` + +**Justification:** +Required for MX record lookups via Google's public DNS API (dns.google). When a user enters a custom email domain, the extension queries MX records to detect the email provider (e.g., Gmail, ProtonMail, Fastmail) and determine whether plus addressing is supported. Only the domain name is sent in the DNS query — no personal data is transmitted. + +--- + +## Host Permission: `` (Content Script) + +**Justification:** +The content script needs to run on any webpage the user visits in order to fill email addresses into form fields. When the user clicks the extension icon, the content script receives the generated email and fills it into the appropriate input field on the page. The content script only activates when triggered by the user — it does not run automatically or collect any data from pages. + +--- + +## Single Purpose Description + +Clean Autofill generates unique email addresses based on website domains and fills them into signup forms with one click, helping users organize their inbox and track data sharing. diff --git a/docs/store-listing/texts.md b/docs/store-listing/texts.md new file mode 100644 index 0000000..0af270c --- /dev/null +++ b/docs/store-listing/texts.md @@ -0,0 +1,109 @@ +# Chrome Web Store — Listing Texts + +## Title + +``` +Clean Autofill — One-Click Email Addresses +``` + +_43 characters (limit: 75)_ + +--- + +## Summary + +``` +Generate unique email addresses for every website. One click to fill. Track spam, filter easily, stay private. +``` + +_111 characters (limit: 132)_ + +--- + +## Category + +``` +Productivity +``` + +--- + +## Language + +``` +English +``` + +--- + +## Description + +``` +Stop typing email addresses. One click, done. + +Clean Autofill generates a unique email address for every website you visit — and fills it into signup forms automatically. Perfect for users with catch-all email domains or anyone who uses plus addressing. + +Visit linear.app? Get linear.app@yourdomain.com. +Visit amazon.com? Get amazon.com@yourdomain.com. +Visit github.com? Get github.com@yourdomain.com. + +No more typing. No more reusing the same address everywhere. + + +WHY USE UNIQUE EMAIL ADDRESSES? + +- Track who sells your data — instantly know which company leaked your email +- Easy filtering — create inbox rules based on the sender address +- Spam control — disable a single address without affecting the rest +- Stay organized — every signup has its own address + + +FEATURES + +- One-Click Fill — Click the extension icon and your email is instantly filled +- Automatic Domain Detection — Generates emails based on the current website's domain +- Smart Field Detection — Finds email fields automatically, or fills your focused field +- Email History — Searchable log of every email you've generated +- Provider Detection — Automatically detects your email provider and shows compatibility +- Privacy-First — No data collection, no tracking, works entirely offline +- Cross-Device Sync — Settings sync across Chrome browsers via your Google account + + +HOW IT WORKS + +1. Configure once — Enter your email domain in settings +2. Visit any website — Navigate to a signup or login page +3. Click the icon — Clean Autofill generates and fills the email + + +TWO MODES + +Plus Addressing: Uses the + trick (you+site@gmail.com). Works with Gmail, Outlook, ProtonMail, Fastmail, mailbox.org, and more. No special setup required. + +Catch-All Prefix: Uses your custom domain (site@yourdomain.com). Requires a domain with catch-all email routing. Best for power users who own their email domain. + +The extension detects your email provider automatically and shows which mode works for you. + + +SUPPORTED PROVIDERS + +Gmail, Google Workspace, Outlook, ProtonMail, Fastmail, mailbox.org, Hey, Yahoo, GMX, web.de, iCloud, mail.com, T-Online, Tuta, and custom domains. + + +PRIVACY + +- No data is collected or transmitted +- Your email domain is stored locally in Chrome's sync storage +- No analytics, no tracking, no external servers +- Open source: https://github.com/ZAAI-com/Clean-Autofill + + +PERMISSIONS + +- activeTab: Read the current tab's URL to extract the domain +- storage: Save your email domain setting +- notifications: Show confirmation messages +- identity: Detect your Chrome profile email for easier setup +``` + +_1,893 characters_ diff --git a/manifest.json b/manifest.json index 17c1589..244bd9b 100644 --- a/manifest.json +++ b/manifest.json @@ -1,14 +1,20 @@ { "manifest_version": 3, "name": "Clean Autofill", - "version": "1.1.0", + "version": "2.0.0", "description": "Automatically fill email addresses based on the current website domain", "permissions": [ "activeTab", "storage", - "notifications" + "notifications", + "identity", + "identity.email" + ], + "host_permissions": [ + "https://dns.google/*" ], "action": { + "default_popup": "ui/popup.html", "default_icon": { "16": "icons/icon16.png", "32": "icons/icon32.png", @@ -17,7 +23,7 @@ } }, "background": { - "service_worker": "background.js", + "service_worker": "extension/background.js", "type": "module" }, "icons": { @@ -26,7 +32,7 @@ "48": "icons/icon48.png", "128": "icons/icon128.png" }, - "options_page": "options.html", + "options_page": "ui/options.html", "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'none'" }, @@ -36,8 +42,8 @@ "" ], "js": [ - "utils-content.js", - "content.js" + "email/utils-content.js", + "extension/autofill.js" ], "all_frames": true } diff --git a/package.json b/package.json index 264e46a..ecf3da0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clean-autofill", - "version": "1.1.0", + "version": "2.0.0", "description": "Chrome extension that automatically fills email addresses based on the current website domain", "scripts": { "prepare": "husky toolkit/husky", @@ -17,9 +17,9 @@ "format:check": "biome format --config-path toolkit/biome/biome.json src/", "check": "biome check --config-path toolkit/biome/biome.json src/", "check:fix": "biome check --config-path toolkit/biome/biome.json --write src/", - "test": "bun test --preload ./src/test-setup.ts src/", - "test:watch": "bun test --preload ./src/test-setup.ts src/ --watch", - "test:coverage": "bun test --preload ./src/test-setup.ts src/ --coverage", + "test": "bun --config=toolkit/bun/bunfig.toml test src/", + "test:watch": "bun --config=toolkit/bun/bunfig.toml test src/ --watch", + "test:coverage": "bun --config=toolkit/bun/bunfig.toml test src/ --coverage", "typecheck": "tsc -p toolkit/typescript/tsconfig.json --noEmit", "build:ts": "tsc -p toolkit/typescript/tsconfig.json" }, @@ -36,11 +36,11 @@ "author": "Manuel Gruber", "license": "MIT", "devDependencies": { - "@biomejs/biome": "^2.3.13", - "@happy-dom/global-registrator": "^20.4.0", - "@types/chrome": "^0.1.36", - "esbuild": "^0.27.2", - "happy-dom": "^20.4.0", + "@biomejs/biome": "^2.4.11", + "@happy-dom/global-registrator": "^20.9.0", + "@types/chrome": "^0.1.40", + "esbuild": "^0.27.7", + "happy-dom": "^20.9.0", "husky": "^9.1.7", "typescript": "^5.9.3" }, diff --git a/src/background.test.ts b/src/background.test.ts deleted file mode 100644 index 4a2fb29..0000000 --- a/src/background.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { beforeAll, describe, expect, test } from 'bun:test'; - -// Load utils first -beforeAll(async () => { - await import('./utils.js'); -}); - -// Get utils for testing -const getUtils = () => { - const utils = (globalThis as Record).CleanAutofillUtils as { - extractMainDomain: (hostname: string) => string; - }; - return utils; -}; - -// Test the email generation logic (extracted from background.ts) -function generateEmail(tabUrl: string, userDomain: string): string | null { - const { extractMainDomain } = getUtils(); - - if (!userDomain) { - return null; - } - - // Skip chrome:// and extension:// URLs - if (tabUrl.startsWith('chrome://') || tabUrl.startsWith('chrome-extension://')) { - throw new Error('Cannot generate email for browser pages'); - } - - try { - const url = new URL(tabUrl); - const domain = extractMainDomain(url.hostname); - return `${domain}@${userDomain}`; - } catch { - throw new Error('Unable to parse current website URL'); - } -} - -describe('generateEmail', () => { - describe('valid URLs', () => { - test('generates email for simple domain', () => { - const email = generateEmail('https://example.com', 'mydomain.com'); - expect(email).toBe('example.com@mydomain.com'); - }); - - test('generates email for domain with www', () => { - const email = generateEmail('https://www.example.com', 'mydomain.com'); - expect(email).toBe('example.com@mydomain.com'); - }); - - test('generates email for subdomain', () => { - const email = generateEmail('https://mail.google.com', 'mydomain.com'); - expect(email).toBe('google.com@mydomain.com'); - }); - - test('generates email for deep subdomain', () => { - const email = generateEmail('https://api.v2.github.com', 'mydomain.com'); - expect(email).toBe('github.com@mydomain.com'); - }); - - test('generates email for special TLD (.co.uk)', () => { - const email = generateEmail('https://www.bbc.co.uk', 'mydomain.com'); - expect(email).toBe('bbc.co.uk@mydomain.com'); - }); - - test('generates email for special TLD (.com.au)', () => { - const email = generateEmail('https://news.example.com.au', 'mydomain.com'); - expect(email).toBe('example.com.au@mydomain.com'); - }); - - test('generates email with path in URL', () => { - const email = generateEmail('https://github.com/user/repo', 'mydomain.com'); - expect(email).toBe('github.com@mydomain.com'); - }); - - test('generates email with query string in URL', () => { - const email = generateEmail('https://example.com?foo=bar', 'mydomain.com'); - expect(email).toBe('example.com@mydomain.com'); - }); - - test('generates email for http URL', () => { - const email = generateEmail('http://example.com', 'mydomain.com'); - expect(email).toBe('example.com@mydomain.com'); - }); - - test('generates email for localhost', () => { - const email = generateEmail('http://localhost:3000', 'mydomain.com'); - expect(email).toBe('localhost@mydomain.com'); - }); - - test('generates email for IP address', () => { - const email = generateEmail('http://192.168.1.1:8080', 'mydomain.com'); - expect(email).toBe('192.168.1.1@mydomain.com'); - }); - }); - - describe('no user domain configured', () => { - test('returns null when userDomain is empty', () => { - const email = generateEmail('https://example.com', ''); - expect(email).toBeNull(); - }); - }); - - describe('browser pages', () => { - test('throws error for chrome:// URL', () => { - expect(() => generateEmail('chrome://extensions', 'mydomain.com')).toThrow( - 'Cannot generate email for browser pages', - ); - }); - - test('throws error for chrome-extension:// URL', () => { - expect(() => generateEmail('chrome-extension://abc123/options.html', 'mydomain.com')).toThrow( - 'Cannot generate email for browser pages', - ); - }); - }); - - describe('invalid URLs', () => { - test('throws error for invalid URL', () => { - expect(() => generateEmail('not-a-valid-url', 'mydomain.com')).toThrow( - 'Unable to parse current website URL', - ); - }); - - test('throws error for empty URL', () => { - expect(() => generateEmail('', 'mydomain.com')).toThrow( - 'Unable to parse current website URL', - ); - }); - }); - - describe('various user domains', () => { - test('works with subdomain user domain', () => { - const email = generateEmail('https://example.com', 'mail.mydomain.com'); - expect(email).toBe('example.com@mail.mydomain.com'); - }); - - test('works with short user domain', () => { - const email = generateEmail('https://example.com', 'mg.de'); - expect(email).toBe('example.com@mg.de'); - }); - }); -}); - -describe('message timeout constant', () => { - test('MESSAGE_TIMEOUT should be a reasonable value', () => { - // The actual constant is 5000ms in background.ts - const MESSAGE_TIMEOUT = 5000; - expect(MESSAGE_TIMEOUT).toBeGreaterThanOrEqual(1000); - expect(MESSAGE_TIMEOUT).toBeLessThanOrEqual(30000); - }); -}); diff --git a/src/background.ts b/src/background.ts deleted file mode 100644 index 32a049f..0000000 --- a/src/background.ts +++ /dev/null @@ -1,144 +0,0 @@ -// Import shared utilities as ES module - -import type { FillEmailResponse } from './types'; -import { createTimeout, extractMainDomain } from './utils.js'; - -// Message timeout in milliseconds -const MESSAGE_TIMEOUT = 5000; - -// Handle extension icon clicks -chrome.action.onClicked.addListener(async (tab) => { - try { - // Generate email for current tab - const email = await generateEmailForTab(tab); - - if (!email) { - // Show notification if no email domain is set - chrome.notifications.create({ - type: 'basic', - iconUrl: 'icons/icon48.png', - title: 'Clean-Autofill', - message: 'Please set your email domain in extension options first.', - }); - - // Open options page - chrome.runtime.openOptionsPage(); - return; - } - - // Guard against undefined tab.id - if (tab.id === undefined) { - throw new Error('Unable to get tab ID'); - } - - // Send message to content script with timeout - const response = (await Promise.race([ - chrome.tabs.sendMessage(tab.id, { - action: 'fillEmail', - email: email, - }), - createTimeout(MESSAGE_TIMEOUT, 'Content script did not respond. Please refresh the page.'), - ])) as FillEmailResponse; - - if (response?.success) { - // Show success notification - chrome.notifications.create({ - type: 'basic', - iconUrl: 'icons/icon48.png', - title: 'Clean-Autofill', - message: `Email filled: ${email}`, - }); - } else if (response?.error) { - throw new Error(response.error); - } - // If no response, no frame found a field - silently do nothing - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Failed to fill email'; - - // Handle "Receiving end does not exist" - content script not loaded - if (errorMessage.includes('Receiving end does not exist')) { - chrome.notifications.create({ - type: 'basic', - iconUrl: 'icons/icon48.png', - title: 'Clean-Autofill', - message: 'Please refresh the page and try again.', - }); - return; - } - - // Handle timeout (no frame responded = no field found) - if (errorMessage.includes('Content script did not respond')) { - console.log('Clean-Autofill: No input field found on this page'); - return; - } - - console.error('Clean-Autofill error:', error); - - // Show error notification for actual errors - chrome.notifications.create({ - type: 'basic', - iconUrl: 'icons/icon48.png', - title: 'Clean-Autofill Error', - message: errorMessage, - }); - } -}); - -/** - * Generate an email address based on the current tab's domain and user settings. - * Combines the site's main domain with the user's configured email domain. - * @param tab - The Chrome tab to generate the email for - * @returns The generated email address, or null if no domain is configured - * @throws Error if unable to read settings or parse the tab URL - */ -async function generateEmailForTab(tab: chrome.tabs.Tab): Promise { - // Get user's email domain from storage with error handling - let userDomain: string | undefined; - try { - const result = await chrome.storage.sync.get(['emailDomain']); - userDomain = result.emailDomain as string | undefined; - } catch (error) { - console.error('Failed to read storage:', error); - throw new Error('Unable to read settings. Please try again.'); - } - - if (!userDomain) { - return null; // No domain configured - } - - // Extract domain from tab URL - if (!tab || !tab.url) { - throw new Error('Unable to get current website domain'); - } - - // Skip chrome:// and extension:// URLs - if (tab.url.startsWith('chrome://') || tab.url.startsWith('chrome-extension://')) { - throw new Error('Cannot generate email for browser pages'); - } - - try { - const url = new URL(tab.url); - // Extract only the main domain (without subdomains) - const domain = extractMainDomain(url.hostname); - - // Generate email - return `${domain}@${userDomain}`; - } catch { - throw new Error('Unable to parse current website URL'); - } -} - -// Install event - show welcome message -chrome.runtime.onInstalled.addListener((details) => { - if (details.reason === 'install') { - chrome.notifications.create({ - type: 'basic', - iconUrl: 'icons/icon48.png', - title: 'Clean-Autofill Installed', - message: 'Click the extension icon to fill emails! Configure your domain in options first.', - }); - - // Open options page on first install - chrome.runtime.openOptionsPage(); - } -}); diff --git a/src/email/catch-all-instructions.test.ts b/src/email/catch-all-instructions.test.ts new file mode 100644 index 0000000..db414d8 --- /dev/null +++ b/src/email/catch-all-instructions.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from 'bun:test'; +import type { DetectedProvider } from '../types'; +import { getCatchAllInstructions } from './catch-all-instructions.js'; + +const ALL_PROVIDERS: DetectedProvider[] = [ + 'google-workspace', + 'microsoft-365', + 'fastmail', + 'protonmail', + 'zoho', + 'icloud', + 'mimecast', + 'barracuda', +]; + +describe('getCatchAllInstructions', () => { + test('returns instructions for each known provider', () => { + for (const provider of ALL_PROVIDERS) { + const result = getCatchAllInstructions(provider); + expect(result.providerName).toBeTruthy(); + } + }); + + test('returns generic instructions for null provider', () => { + const result = getCatchAllInstructions(null); + expect(result.providerName).toBe('Your Email Provider'); + expect(result.steps.length).toBeGreaterThan(0); + }); + + test('providers with catch-all support have non-empty steps', () => { + const supportedProviders: DetectedProvider[] = [ + 'google-workspace', + 'microsoft-365', + 'fastmail', + 'protonmail', + 'zoho', + ]; + for (const provider of supportedProviders) { + const result = getCatchAllInstructions(provider); + expect(result.steps.length).toBeGreaterThan(0); + } + }); + + test('iCloud has empty steps and a warning note', () => { + const result = getCatchAllInstructions('icloud'); + expect(result.steps).toEqual([]); + expect(result.notes).toContain('does not support catch-all'); + }); + + test('security gateways explain underlying provider needed', () => { + for (const gateway of ['mimecast', 'barracuda'] as DetectedProvider[]) { + const result = getCatchAllInstructions(gateway); + expect(result.steps.length).toBeGreaterThan(0); + expect(result.notes).toContain('actual email provider'); + } + }); + + test('adminUrl is a valid URL string or null', () => { + for (const provider of ALL_PROVIDERS) { + const result = getCatchAllInstructions(provider); + if (result.adminUrl !== null) { + expect(result.adminUrl).toMatch(/^https:\/\//); + } + } + }); + + test('providers with admin panels have adminUrl set', () => { + const withAdmin: DetectedProvider[] = [ + 'google-workspace', + 'microsoft-365', + 'fastmail', + 'protonmail', + 'zoho', + ]; + for (const provider of withAdmin) { + const result = getCatchAllInstructions(provider); + expect(result.adminUrl).not.toBeNull(); + } + }); + + test('generic instructions have no adminUrl', () => { + const result = getCatchAllInstructions(null); + expect(result.adminUrl).toBeNull(); + }); +}); diff --git a/src/email/catch-all-instructions.ts b/src/email/catch-all-instructions.ts new file mode 100644 index 0000000..82d5a95 --- /dev/null +++ b/src/email/catch-all-instructions.ts @@ -0,0 +1,132 @@ +import type { DetectedProvider } from '../types'; + +export interface CatchAllInstructions { + providerName: string; + steps: string[]; + adminUrl: string | null; + notes: string | null; +} + +const INSTRUCTIONS: Record = { + 'google-workspace': { + providerName: 'Google Workspace', + steps: [ + 'Open the Google Admin Console', + 'Go to Apps > Google Workspace > Gmail > Default routing', + 'Click "Add setting" or edit an existing catch-all rule', + 'Under "Envelope recipients", select "Pattern match" and enter .*', + 'Under "Route", choose "Modify message" and set "Also deliver to" with your catch-all mailbox', + 'Save the routing rule', + ], + adminUrl: 'https://admin.google.com/ac/apps/gmail/defaultrouting', + notes: 'Requires Google Workspace admin access. Changes may take up to 24 hours to propagate.', + }, + 'microsoft-365': { + providerName: 'Microsoft 365', + steps: [ + 'Open the Exchange Admin Center', + 'Go to Mail flow > Rules', + 'Click "+ Add a rule" > "Create a new rule"', + 'Name it "Catch-All" and set condition: "The recipient domain is..." > your domain', + 'Add exception: "The recipient... is a member of the organization"', + 'Set action: "Redirect the message to..." > your catch-all mailbox', + 'Save and enable the rule', + ], + adminUrl: 'https://admin.exchange.microsoft.com/#/transportrules', + notes: + 'Requires Exchange admin access. You may also need to set the domain as "Internal relay" under Accepted domains.', + }, + fastmail: { + providerName: 'Fastmail', + steps: [ + 'Open Fastmail Settings', + 'Go to Domains > select your domain', + 'Enable "Accept all mail" (catch-all)', + 'Choose which mailbox should receive the catch-all emails', + ], + adminUrl: 'https://app.fastmail.com/settings/domains', + notes: null, + }, + protonmail: { + providerName: 'Proton Mail', + steps: [ + 'Open Proton Mail Settings', + 'Go to Domain addresses under your custom domain', + 'Enable the catch-all toggle for your domain', + 'Select which address should receive catch-all emails', + ], + adminUrl: 'https://account.proton.me/u/0/mail/domain-addresses', + notes: 'Requires a Proton Mail paid plan with custom domain support.', + }, + zoho: { + providerName: 'Zoho Mail', + steps: [ + 'Open the Zoho Mail Admin Console', + 'Go to Domains > select your domain', + 'Navigate to Email routing / Catch-all settings', + 'Enable catch-all and set the target mailbox', + ], + adminUrl: 'https://mailadmin.zoho.com/cpanel/index.do#domains/', + notes: 'Requires Zoho Mail admin access.', + }, + icloud: { + providerName: 'iCloud Mail', + steps: [], + adminUrl: null, + notes: + 'iCloud Mail does not support catch-all for custom domains. Consider using a different email provider for catch-all mode.', + }, + mimecast: { + providerName: 'Mimecast (Security Gateway)', + steps: [ + 'Mimecast is a security gateway — your actual email provider is behind it', + 'Configure catch-all in your underlying email provider (e.g., Google Workspace or Microsoft 365)', + 'Ensure Mimecast is configured to forward unrecognized recipients to your mail server', + ], + adminUrl: null, + notes: 'Catch-all must be configured in your actual email provider, not in Mimecast itself.', + }, + barracuda: { + providerName: 'Barracuda (Security Gateway)', + steps: [ + 'Barracuda is a security gateway — your actual email provider is behind it', + 'Configure catch-all in your underlying email provider (e.g., Google Workspace or Microsoft 365)', + 'Ensure Barracuda is configured to forward unrecognized recipients to your mail server', + ], + adminUrl: null, + notes: 'Catch-all must be configured in your actual email provider, not in Barracuda itself.', + }, + generic: { + providerName: 'Your Email Provider', + steps: [ + "Log in to your email provider's admin panel", + 'Look for "Catch-all", "Default routing", or "Accept all mail" settings', + 'Enable catch-all and set the target mailbox to receive unmatched emails', + 'Save and test by sending an email to a random address on your domain', + ], + adminUrl: null, + notes: + "The exact steps vary by provider. Check your email provider's documentation for catch-all setup instructions.", + }, +}; + +export function getCatchAllInstructions(provider: DetectedProvider | null): CatchAllInstructions { + return INSTRUCTIONS[provider ?? 'generic']; +} + +const HELP_PAGE_PROVIDERS: (DetectedProvider | 'generic')[] = [ + 'google-workspace', + 'microsoft-365', + 'fastmail', + 'protonmail', + 'zoho', + 'icloud', + 'generic', +]; + +export function getAllCatchAllInstructions(): { + key: string; + instructions: CatchAllInstructions; +}[] { + return HELP_PAGE_PROVIDERS.map((key) => ({ key, instructions: INSTRUCTIONS[key] })); +} diff --git a/src/email/mx-lookup.test.ts b/src/email/mx-lookup.test.ts new file mode 100644 index 0000000..aee6ce9 --- /dev/null +++ b/src/email/mx-lookup.test.ts @@ -0,0 +1,382 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test'; +import type { MxRecord } from '../types'; + +// Mock chrome.storage.local +let store: Record = {}; +const mockChrome = { + storage: { + local: { + get: mock(async (key: string) => { + const val = store[key]; + return val !== undefined ? { [key]: val } : {}; + }), + set: mock(async (items: Record) => { + Object.assign(store, items); + }), + }, + }, +}; +(globalThis as Record).chrome = mockChrome; + +// Mock fetch +let fetchResponse: { ok: boolean; status: number; json: () => Promise }; +const mockFetch = mock(async () => fetchResponse); +(globalThis as Record).fetch = mockFetch; + +const { detectProviderFromMx, getProviderInfo, lookupMxRecords, clearMemoryCache } = await import( + './mx-lookup.js' +); + +beforeEach(() => { + store = {}; + mockFetch.mockClear(); + mockChrome.storage.local.get.mockClear(); + mockChrome.storage.local.set.mockClear(); + clearMemoryCache(); +}); + +// ── detectProviderFromMx ── + +describe('detectProviderFromMx', () => { + test('detects Google Workspace (smtp.google.com)', () => { + const records: MxRecord[] = [{ priority: 10, exchange: 'smtp.google.com' }]; + expect(detectProviderFromMx(records)).toBe('google-workspace'); + }); + + test('detects Google Workspace (legacy aspmx.l.google.com)', () => { + const records: MxRecord[] = [ + { priority: 1, exchange: 'aspmx.l.google.com' }, + { priority: 5, exchange: 'alt1.aspmx.l.google.com' }, + { priority: 5, exchange: 'alt2.aspmx.l.google.com' }, + { priority: 10, exchange: 'alt3.aspmx.l.google.com' }, + { priority: 10, exchange: 'alt4.aspmx.l.google.com' }, + ]; + expect(detectProviderFromMx(records)).toBe('google-workspace'); + }); + + test('detects Google Workspace (gmail-smtp-in)', () => { + const records: MxRecord[] = [ + { priority: 5, exchange: 'gmail-smtp-in.l.google.com' }, + { priority: 10, exchange: 'alt1.gmail-smtp-in.l.google.com' }, + ]; + expect(detectProviderFromMx(records)).toBe('google-workspace'); + }); + + test('detects Microsoft 365', () => { + const records: MxRecord[] = [ + { priority: 0, exchange: 'company-com.mail.protection.outlook.com' }, + ]; + expect(detectProviderFromMx(records)).toBe('microsoft-365'); + }); + + test('detects Fastmail', () => { + const records: MxRecord[] = [ + { priority: 10, exchange: 'in1-smtp.messagingengine.com' }, + { priority: 20, exchange: 'in2-smtp.messagingengine.com' }, + ]; + expect(detectProviderFromMx(records)).toBe('fastmail'); + }); + + test('detects Proton Mail', () => { + const records: MxRecord[] = [ + { priority: 5, exchange: 'mail.protonmail.ch' }, + { priority: 10, exchange: 'mailsec.protonmail.ch' }, + ]; + expect(detectProviderFromMx(records)).toBe('protonmail'); + }); + + test('detects Zoho', () => { + const records: MxRecord[] = [ + { priority: 10, exchange: 'smtpin.zoho.com' }, + { priority: 20, exchange: 'smtpin2.zoho.com' }, + ]; + expect(detectProviderFromMx(records)).toBe('zoho'); + }); + + test('detects iCloud', () => { + const records: MxRecord[] = [ + { priority: 10, exchange: 'mx01.mail.icloud.com' }, + { priority: 10, exchange: 'mx02.mail.icloud.com' }, + ]; + expect(detectProviderFromMx(records)).toBe('icloud'); + }); + + test('detects Mimecast', () => { + const records: MxRecord[] = [{ priority: 10, exchange: 'us-smtp-inbound-1.mimecast.com' }]; + expect(detectProviderFromMx(records)).toBe('mimecast'); + }); + + test('detects Barracuda', () => { + const records: MxRecord[] = [{ priority: 10, exchange: 'cluster1.us.barracudanetworks.com' }]; + expect(detectProviderFromMx(records)).toBe('barracuda'); + }); + + test('returns null for unknown MX records', () => { + const records: MxRecord[] = [{ priority: 10, exchange: 'mx.example.com' }]; + expect(detectProviderFromMx(records)).toBeNull(); + }); + + test('returns null for empty records', () => { + expect(detectProviderFromMx([])).toBeNull(); + }); + + test('handles trailing dots in exchange names', () => { + const records: MxRecord[] = [{ priority: 10, exchange: 'smtp.google.com.' }]; + expect(detectProviderFromMx(records)).toBe('google-workspace'); + }); + + test('handles uppercase exchange names', () => { + const records: MxRecord[] = [{ priority: 10, exchange: 'SMTP.GOOGLE.COM' }]; + expect(detectProviderFromMx(records)).toBe('google-workspace'); + }); + + test('uses highest priority record for detection', () => { + const records: MxRecord[] = [ + { priority: 20, exchange: 'mx.example.com' }, + { priority: 1, exchange: 'smtp.google.com' }, + ]; + expect(detectProviderFromMx(records)).toBe('google-workspace'); + }); +}); + +// ── getProviderInfo ── + +describe('getProviderInfo', () => { + test('returns Google Workspace info', () => { + const info = getProviderInfo('google-workspace'); + expect(info.name).toBe('Google Workspace'); + expect(info.plusAddressingSupported).toBe(true); + }); + + test('returns Microsoft 365 info', () => { + const info = getProviderInfo('microsoft-365'); + expect(info.name).toBe('Microsoft 365'); + expect(info.plusAddressingSupported).toBe(true); + }); + + test('returns iCloud info with no plus support', () => { + const info = getProviderInfo('icloud'); + expect(info.name).toBe('iCloud Mail'); + expect(info.plusAddressingSupported).toBe(false); + }); + + test('returns Fastmail info', () => { + const info = getProviderInfo('fastmail'); + expect(info.name).toBe('Fastmail'); + expect(info.plusAddressingSupported).toBe(true); + }); + + test('returns Proton Mail info', () => { + const info = getProviderInfo('protonmail'); + expect(info.name).toBe('Proton Mail'); + expect(info.plusAddressingSupported).toBe(true); + }); +}); + +// ── lookupMxRecords ── + +describe('lookupMxRecords', () => { + function setFetchResponse(answers: Array<{ type: number; TTL: number; data: string }>) { + fetchResponse = { + ok: true, + status: 200, + json: async () => ({ + Status: 0, + Answer: answers.map((a) => ({ name: 'test.com', ...a })), + }), + }; + } + + test('fetches and returns MX result for Google Workspace domain', async () => { + setFetchResponse([{ type: 15, TTL: 3600, data: '10 smtp.google.com.' }]); + + const result = await lookupMxRecords('company.com'); + + expect(result.domain).toBe('company.com'); + expect(result.provider).toBe('google-workspace'); + expect(result.status).toBe('plus-supported'); + expect(result.mxRecords).toHaveLength(1); + expect(result.mxRecords[0].exchange).toBe('smtp.google.com'); + expect(result.mxRecords[0].priority).toBe(10); + }); + + test('returns plus-unsupported for iCloud domain', async () => { + setFetchResponse([ + { type: 15, TTL: 3600, data: '10 mx01.mail.icloud.com.' }, + { type: 15, TTL: 3600, data: '10 mx02.mail.icloud.com.' }, + ]); + + const result = await lookupMxRecords('example.com'); + + expect(result.provider).toBe('icloud'); + expect(result.status).toBe('plus-unsupported'); + }); + + test('returns custom status for security gateways', async () => { + setFetchResponse([{ type: 15, TTL: 3600, data: '10 us-smtp-inbound-1.mimecast.com.' }]); + + const result = await lookupMxRecords('corp.com'); + + expect(result.provider).toBe('mimecast'); + expect(result.status).toBe('custom'); + }); + + test('returns custom status when no MX records match', async () => { + setFetchResponse([{ type: 15, TTL: 3600, data: '10 mx.unknown-provider.com.' }]); + + const result = await lookupMxRecords('random.xyz'); + + expect(result.provider).toBeNull(); + expect(result.status).toBe('custom'); + }); + + test('returns custom status on network error', async () => { + fetchResponse = { ok: false, status: 500, json: async () => ({}) }; + + const result = await lookupMxRecords('error.com'); + + expect(result.provider).toBeNull(); + expect(result.status).toBe('custom'); + expect(result.ttl).toBe(300); // short TTL for retry + }); + + test('returns custom status when fetch throws', async () => { + mockFetch.mockImplementationOnce(async () => { + throw new Error('Network error'); + }); + + const result = await lookupMxRecords('offline.com'); + + expect(result.provider).toBeNull(); + expect(result.status).toBe('custom'); + }); + + test('returns custom status when fetch times out', async () => { + mockFetch.mockImplementationOnce( + (_url: string, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => + reject(new DOMException('The operation was aborted.', 'AbortError')), + ); + }), + ); + + const result = await lookupMxRecords('hanging.com'); + + expect(result.provider).toBeNull(); + expect(result.status).toBe('custom'); + expect(result.ttl).toBe(300); + }, 10000); + + test('handles DNS NXDOMAIN (no Answer array)', async () => { + fetchResponse = { + ok: true, + status: 200, + json: async () => ({ Status: 3 }), // NXDOMAIN + }; + + const result = await lookupMxRecords('nonexistent.com'); + + expect(result.provider).toBeNull(); + expect(result.mxRecords).toHaveLength(0); + expect(result.status).toBe('custom'); + }); + + test('filters out non-MX records from Answer', async () => { + fetchResponse = { + ok: true, + status: 200, + json: async () => ({ + Status: 0, + Answer: [ + { name: 'test.com', type: 1, TTL: 300, data: '1.2.3.4' }, // A record + { name: 'test.com', type: 15, TTL: 3600, data: '10 smtp.google.com.' }, // MX record + ], + }), + }; + + const result = await lookupMxRecords('mixed.com'); + + expect(result.mxRecords).toHaveLength(1); + expect(result.provider).toBe('google-workspace'); + }); + + test('normalizes domain to lowercase', async () => { + setFetchResponse([{ type: 15, TTL: 3600, data: '10 smtp.google.com.' }]); + + const result = await lookupMxRecords('COMPANY.COM'); + + expect(result.domain).toBe('company.com'); + }); + + // ── Caching ── + + test('returns cached result on second call', async () => { + setFetchResponse([{ type: 15, TTL: 3600, data: '10 smtp.google.com.' }]); + + const first = await lookupMxRecords('cached.com'); + const second = await lookupMxRecords('cached.com'); + + expect(first.provider).toBe('google-workspace'); + expect(second.provider).toBe('google-workspace'); + // fetch should be called only once + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + test('caches result in chrome.storage.local', async () => { + setFetchResponse([{ type: 15, TTL: 3600, data: '10 smtp.google.com.' }]); + + await lookupMxRecords('stored.com'); + + expect(mockChrome.storage.local.set).toHaveBeenCalled(); + const cached = store.mxCache as Record; + expect(cached).toBeDefined(); + expect(cached['stored.com']).toBeDefined(); + }); + + test('reads from chrome.storage.local when memory cache is empty', async () => { + // Pre-populate storage cache + store.mxCache = { + 'precached.com': { + domain: 'precached.com', + provider: 'microsoft-365', + mxRecords: [{ priority: 0, exchange: 'company.mail.protection.outlook.com' }], + status: 'plus-supported', + timestamp: Date.now(), + ttl: 3600, + }, + }; + + const result = await lookupMxRecords('precached.com'); + + expect(result.provider).toBe('microsoft-365'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + test('storage failure is non-fatal', async () => { + mockChrome.storage.local.get.mockImplementationOnce(async () => { + throw new Error('Storage error'); + }); + setFetchResponse([{ type: 15, TTL: 3600, data: '10 smtp.google.com.' }]); + + const result = await lookupMxRecords('storagefail.com'); + + expect(result.provider).toBe('google-workspace'); + }); + + test('clamps TTL to minimum of 1 hour', async () => { + setFetchResponse([{ type: 15, TTL: 60, data: '10 smtp.google.com.' }]); // 60s TTL + + const result = await lookupMxRecords('shortttl.com'); + + expect(result.ttl).toBe(3600); // clamped to 1 hour + }); + + test('clamps TTL to maximum of 24 hours', async () => { + setFetchResponse([{ type: 15, TTL: 999999, data: '10 smtp.google.com.' }]); + + const result = await lookupMxRecords('longttl.com'); + + expect(result.ttl).toBe(86400); // clamped to 24 hours + }); +}); diff --git a/src/email/mx-lookup.ts b/src/email/mx-lookup.ts new file mode 100644 index 0000000..3d5e8a9 --- /dev/null +++ b/src/email/mx-lookup.ts @@ -0,0 +1,189 @@ +import type { DetectedProvider, MxLookupResult, MxRecord, ProviderInfo } from '../types'; +import type { ProviderStatus } from './providers.js'; + +const DNS_API_URL = 'https://dns.google/resolve'; +const MX_RECORD_TYPE = 15; +const MIN_TTL = 3600; // 1 hour +const MAX_TTL = 86400; // 24 hours +const ERROR_TTL = 300; // 5 minutes — retry sooner on failure +const MX_FETCH_TIMEOUT = 5000; +const MX_CACHE_STORAGE_KEY = 'mxCache'; + +// MX exchange patterns mapped to detected providers +const PROVIDER_MX_PATTERNS: [DetectedProvider, RegExp[]][] = [ + ['google-workspace', [/\.google\.com$/, /\.googlemail\.com$/, /gmail-smtp-in\.l\.google\.com$/]], + ['microsoft-365', [/\.mail\.protection\.outlook\.com$/]], + ['fastmail', [/\.messagingengine\.com$/]], + ['protonmail', [/\.protonmail\.ch$/]], + ['zoho', [/\.zoho\.com$/, /\.zohomail\.com$/]], + ['icloud', [/\.mail\.icloud\.com$/]], + ['mimecast', [/\.mimecast\.com$/]], + ['barracuda', [/\.barracudanetworks\.com$/]], +]; + +const PROVIDER_INFO: Record = { + 'google-workspace': { name: 'Google Workspace', plusAddressingSupported: true }, + 'microsoft-365': { name: 'Microsoft 365', plusAddressingSupported: true }, + fastmail: { name: 'Fastmail', plusAddressingSupported: true }, + protonmail: { name: 'Proton Mail', plusAddressingSupported: true }, + zoho: { name: 'Zoho Mail', plusAddressingSupported: true }, + icloud: { name: 'iCloud Mail', plusAddressingSupported: false }, + mimecast: { name: 'Mimecast', plusAddressingSupported: false }, + barracuda: { name: 'Barracuda', plusAddressingSupported: false }, +}; + +// Security gateways — MX points here but actual mail provider is unknown +const SECURITY_GATEWAYS: ReadonlySet = new Set(['mimecast', 'barracuda']); + +interface DnsResponse { + Status: number; + Answer?: Array<{ + name: string; + type: number; + TTL: number; + data: string; // "10 smtp.google.com." format for MX records + }>; +} + +// In-memory cache for the current session +const memoryCache = new Map(); + +export function detectProviderFromMx(mxRecords: MxRecord[]): DetectedProvider | null { + const sorted = [...mxRecords].sort((a, b) => a.priority - b.priority); + for (const record of sorted) { + const exchange = record.exchange.toLowerCase().replace(/\.$/, ''); + for (const [provider, patterns] of PROVIDER_MX_PATTERNS) { + if (patterns.some((pattern) => pattern.test(exchange))) { + return provider; + } + } + } + return null; +} + +export function getProviderInfo(provider: DetectedProvider): ProviderInfo { + return PROVIDER_INFO[provider]; +} + +async function fetchMxRecords(domain: string): Promise<{ records: MxRecord[]; ttl: number }> { + const url = `${DNS_API_URL}?name=${encodeURIComponent(domain)}&type=MX`; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), MX_FETCH_TIMEOUT); + try { + const response = await fetch(url, { signal: controller.signal }); + if (!response.ok) { + throw new Error(`DNS lookup failed: ${response.status}`); + } + const data: DnsResponse = await response.json(); + + if (data.Status !== 0) { + return { records: [], ttl: MIN_TTL }; + } + + const mxAnswers = (data.Answer ?? []).filter((a) => a.type === MX_RECORD_TYPE); + let minTtl = MAX_TTL; + + const records: MxRecord[] = []; + for (const a of mxAnswers) { + if (a.TTL < minTtl) minTtl = a.TTL; + const spaceIndex = a.data.indexOf(' '); + if (spaceIndex === -1) continue; + const priority = Number.parseInt(a.data.substring(0, spaceIndex), 10); + if (Number.isNaN(priority)) continue; + const exchange = a.data.substring(spaceIndex + 1).replace(/\.$/, ''); + records.push({ priority, exchange }); + } + + const ttl = Math.max(MIN_TTL, Math.min(minTtl, MAX_TTL)); + return { records, ttl }; + } finally { + clearTimeout(timeoutId); + } +} + +function isExpired(result: MxLookupResult): boolean { + return Date.now() > result.timestamp + result.ttl * 1000; +} + +async function getCachedResult(domain: string): Promise { + const memResult = memoryCache.get(domain); + if (memResult && !isExpired(memResult)) return memResult; + + try { + const data = await chrome.storage.local.get(MX_CACHE_STORAGE_KEY); + const cache = (data[MX_CACHE_STORAGE_KEY] ?? {}) as Record; + const stored = cache[domain]; + if (stored && !isExpired(stored)) { + memoryCache.set(domain, stored); + return stored; + } + } catch { + // Storage unavailable, continue to network + } + return null; +} + +async function setCachedResult(result: MxLookupResult): Promise { + memoryCache.set(result.domain, result); + try { + const data = await chrome.storage.local.get(MX_CACHE_STORAGE_KEY); + const cache = (data[MX_CACHE_STORAGE_KEY] ?? {}) as Record; + // Prune expired entries + const now = Date.now(); + for (const key of Object.keys(cache)) { + if (now > cache[key].timestamp + cache[key].ttl * 1000) { + delete cache[key]; + } + } + cache[result.domain] = result; + await chrome.storage.local.set({ [MX_CACHE_STORAGE_KEY]: cache }); + } catch { + // Storage failure is non-fatal + } +} + +function deriveStatus(provider: DetectedProvider | null): ProviderStatus { + if (!provider) return 'custom'; + if (SECURITY_GATEWAYS.has(provider)) return 'custom'; + const info = PROVIDER_INFO[provider]; + return info.plusAddressingSupported ? 'plus-supported' : 'plus-unsupported'; +} + +export async function lookupMxRecords(domain: string): Promise { + const lower = domain.toLowerCase(); + + const cached = await getCachedResult(lower); + if (cached) return cached; + + try { + const { records, ttl } = await fetchMxRecords(lower); + const provider = detectProviderFromMx(records); + const status = deriveStatus(provider); + + const result: MxLookupResult = { + domain: lower, + provider, + mxRecords: records, + status, + timestamp: Date.now(), + ttl, + }; + + await setCachedResult(result); + return result; + } catch { + return { + domain: lower, + provider: null, + mxRecords: [], + status: 'custom', + timestamp: Date.now(), + ttl: ERROR_TTL, + }; + } +} + +/** Clear the in-memory cache (for testing). */ +export function clearMemoryCache(): void { + memoryCache.clear(); +} diff --git a/src/email/provider-domains.test.ts b/src/email/provider-domains.test.ts new file mode 100644 index 0000000..226bb35 --- /dev/null +++ b/src/email/provider-domains.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from 'bun:test'; +import { PLUS_SUPPORTED_DOMAINS, PLUS_UNSUPPORTED_DOMAINS } from './provider-domains.js'; + +describe('PLUS_SUPPORTED_DOMAINS', () => { + test('is non-empty', () => { + expect(PLUS_SUPPORTED_DOMAINS.size).toBeGreaterThan(0); + }); + + test('contains major supported providers', () => { + expect(PLUS_SUPPORTED_DOMAINS.has('gmail.com')).toBe(true); + expect(PLUS_SUPPORTED_DOMAINS.has('googlemail.com')).toBe(true); + expect(PLUS_SUPPORTED_DOMAINS.has('outlook.com')).toBe(true); + expect(PLUS_SUPPORTED_DOMAINS.has('hotmail.com')).toBe(true); + expect(PLUS_SUPPORTED_DOMAINS.has('protonmail.com')).toBe(true); + expect(PLUS_SUPPORTED_DOMAINS.has('fastmail.com')).toBe(true); + expect(PLUS_SUPPORTED_DOMAINS.has('mailbox.org')).toBe(true); + }); + + test('all entries are lowercase', () => { + for (const domain of PLUS_SUPPORTED_DOMAINS) { + expect(domain).toBe(domain.toLowerCase()); + } + }); + + test('all entries contain at least one dot', () => { + for (const domain of PLUS_SUPPORTED_DOMAINS) { + expect(domain).toContain('.'); + } + }); + + test('no empty strings', () => { + for (const domain of PLUS_SUPPORTED_DOMAINS) { + expect(domain.length).toBeGreaterThan(0); + } + }); +}); + +describe('PLUS_UNSUPPORTED_DOMAINS', () => { + test('is non-empty', () => { + expect(PLUS_UNSUPPORTED_DOMAINS.size).toBeGreaterThan(0); + }); + + test('contains major unsupported providers', () => { + expect(PLUS_UNSUPPORTED_DOMAINS.has('yahoo.com')).toBe(true); + expect(PLUS_UNSUPPORTED_DOMAINS.has('icloud.com')).toBe(true); + expect(PLUS_UNSUPPORTED_DOMAINS.has('gmx.com')).toBe(true); + expect(PLUS_UNSUPPORTED_DOMAINS.has('mail.com')).toBe(true); + expect(PLUS_UNSUPPORTED_DOMAINS.has('hey.com')).toBe(true); + expect(PLUS_UNSUPPORTED_DOMAINS.has('qq.com')).toBe(true); + }); + + test('all entries are lowercase', () => { + for (const domain of PLUS_UNSUPPORTED_DOMAINS) { + expect(domain).toBe(domain.toLowerCase()); + } + }); + + test('all entries contain at least one dot', () => { + for (const domain of PLUS_UNSUPPORTED_DOMAINS) { + expect(domain).toContain('.'); + } + }); + + test('no empty strings', () => { + for (const domain of PLUS_UNSUPPORTED_DOMAINS) { + expect(domain.length).toBeGreaterThan(0); + } + }); +}); + +describe('domain sets are disjoint', () => { + test('no domain appears in both sets', () => { + const overlap: string[] = []; + for (const domain of PLUS_SUPPORTED_DOMAINS) { + if (PLUS_UNSUPPORTED_DOMAINS.has(domain)) { + overlap.push(domain); + } + } + expect(overlap).toEqual([]); + }); +}); diff --git a/src/email/provider-domains.ts b/src/email/provider-domains.ts new file mode 100644 index 0000000..ba89add --- /dev/null +++ b/src/email/provider-domains.ts @@ -0,0 +1,339 @@ +// Providers with verified plus-addressing support (name+tag@domain works without setup) +export const PLUS_SUPPORTED_DOMAINS = new Set([ + // Gmail + 'gmail.com', + 'googlemail.com', + // Microsoft (Exchange Online / Outlook.com) + 'outlook.com', + 'hotmail.com', + 'live.com', + 'msn.com', + // Proton Mail + 'protonmail.com', + 'proton.me', + 'pm.me', + 'protonmail.ch', + // Fastmail (official domain list from https://www.fastmail.help/hc/en-us/articles/360060591053) + 'fastmail.com', + 'fastmail.fm', + 'fastmail.net', + 'fastmail.org', + 'fastmail.co.uk', + 'fastmail.com.au', + 'fastmail.de', + 'fastmail.es', + 'fastmail.fr', + 'fastmail.im', + 'fastmail.in', + 'fastmail.jp', + 'fastmail.mx', + 'fastmail.nl', + 'fastmail.se', + 'fastmail.to', + 'fastmail.tw', + 'fastmail.uk', + 'fastmail.us', + 'fastmail.ca', + 'fastmail.cn', + '123mail.org', + '150mail.com', + '150ml.com', + '16mail.com', + '2-mail.com', + '4email.net', + '50mail.com', + 'airpost.net', + 'allmail.net', + 'bestmail.us', + 'cluemail.com', + 'elitemail.org', + 'emailcorner.net', + 'emailengine.net', + 'emailengine.org', + 'emailgroups.net', + 'emailplus.org', + 'emailuser.net', + 'eml.cc', + 'f-m.fm', + 'fast-email.com', + 'fast-mail.org', + 'fastem.com', + 'fastemailer.com', + 'fastemail.us', + 'fastest.cc', + 'fastimap.com', + 'fastmailbox.net', + 'fastmessaging.com', + 'fea.st', + 'fmail.co.uk', + 'fmailbox.com', + 'fmgirl.com', + 'fmguy.com', + 'ftml.net', + 'h-mail.us', + 'hailmail.net', + 'imap-mail.com', + 'imap.cc', + 'imapmail.org', + 'internet-e-mail.com', + 'internet-mail.org', + 'internetemails.net', + 'internetmailing.net', + 'jetemail.net', + 'justemail.net', + 'letterboxes.org', + 'mail-central.com', + 'mail-page.com', + 'mailas.com', + 'mailbolt.com', + 'mailc.net', + 'mailcan.com', + 'mailforce.net', + 'mailhaven.com', + 'mailingaddress.org', + 'mailite.com', + 'mailmight.com', + 'mailnew.com', + 'mailsent.net', + 'mailservice.ms', + 'mailup.net', + 'mailworks.org', + 'ml1.net', + 'mm.st', + 'myfastmail.com', + 'mymacmail.com', + 'nospammail.net', + 'ownmail.net', + 'petml.com', + 'pobox.com', + 'postinbox.com', + 'postpro.net', + 'proinbox.com', + 'promessage.com', + 'realemail.net', + 'reallyfast.biz', + 'reallyfast.info', + 'rushpost.com', + 'sent.as', + 'sent.at', + 'sent.com', + 'speedpost.net', + 'speedymail.org', + 'ssl-mail.com', + 'swift-mail.com', + 'the-fastest.net', + 'the-quickest.com', + 'theinternetemail.com', + 'veryfast.biz', + 'veryspeedy.net', + 'warpmail.net', + 'xsmail.com', + 'yepmail.net', + 'your-mail.com', + 'aliencamel.com', + 'foobox.com', + 'foobox.net', + 'immerbox.com', + 'immermail.com', + 'inoutbox.com', + 'lifetimeaddress.com', + 'mailzone.com', + 'onepost.net', + 'permanentmail.com', + 'siemprebox.com', + 'siempremail.com', + 'veribox.net', + 'webname.com', + // mailbox.org + 'mailbox.org', + // Yandex Mail (Russia) + 'yandex.com', + 'yandex.ru', + 'ya.ru', +]); + +// Providers that do NOT support classic plus addressing +export const PLUS_UNSUPPORTED_DOMAINS = new Set([ + // Yahoo Mail + 'yahoo.com', + 'ymail.com', + 'rocketmail.com', + // GMX + 'gmx.com', + 'gmx.de', + 'gmx.net', + // web.de + 'web.de', + // T-Online + 't-online.de', + // Tuta (Tutanota) + 'tuta.com', + 'tutanota.com', + // iCloud Mail (Apple offers aliases and Hide My Email, not +tag) + 'icloud.com', + 'me.com', + 'mac.com', + // mail.com and its 100+ novelty domains + 'mail.com', + 'email.com', + 'post.com', + 'usa.com', + 'europe.com', + 'asia.com', + 'berlin.com', + 'dublin.com', + 'munich.com', + 'dr.com', + 'accountant.com', + 'activist.com', + 'adexec.com', + 'alumni.com', + 'archaeologist.com', + 'bartender.net', + 'chef.net', + 'chemist.com', + 'collector.org', + 'columnist.com', + 'comic.com', + 'consultant.com', + 'contractor.net', + 'counsellor.com', + 'diplomats.com', + 'engineer.com', + 'financier.com', + 'fireman.net', + 'gardener.com', + 'geologist.com', + 'graduate.org', + 'graphic-designer.com', + 'hairdresser.net', + 'legislator.com', + 'lobbyist.com', + 'minister.com', + 'musician.org', + 'optician.com', + 'orthodontist.net', + 'pediatrician.com', + 'photographer.net', + 'physicist.net', + 'politician.com', + 'priest.com', + 'programmer.net', + 'publicist.com', + 'realtyagent.com', + 'registerednurses.com', + 'repairman.com', + 'secretary.net', + 'socialworker.net', + 'sociologist.com', + 'songwriter.net', + 'teachers.org', + 'techie.com', + 'technologist.com', + 'therapist.net', + 'artlover.com', + 'bikerider.com', + 'birdlover.com', + 'boardermail.com', + 'brew-master.com', + 'catlover.com', + 'clubmember.org', + 'doglover.com', + 'kittymail.com', + 'lovecat.com', + 'marchmail.com', + 'nonpartisan.com', + 'petlover.com', + 'greenmail.net', + 'hackermail.com', + 'theplate.com', + 'bsdmail.com', + 'cyberdude.com', + 'cybergal.com', + 'cyberservices.com', + 'cyber-wizard.com', + 'linuxmail.org', + 'null.net', + 'acdcfan.com', + 'angelic.com', + 'discofan.com', + 'elvisfan.com', + 'hiphopfan.com', + 'kissfans.com', + 'madonnafan.com', + 'metalfan.com', + 'ninfan.com', + 'ravemail.com', + 'reggaefan.com', + 'californiamail.com', + 'dallasmail.com', + 'nycmail.com', + 'sanfranmail.com', + 'africamail.com', + 'australiamail.com', + 'brazilmail.com', + 'chinamail.com', + 'dutchmail.com', + 'englandmail.com', + 'europemail.com', + 'germanymail.com', + 'irelandmail.com', + 'israelmail.com', + 'italymail.com', + 'koreamail.com', + 'mexicomail.com', + 'moscowmail.com', + 'polandmail.com', + 'safrica.com', + 'scotlandmail.com', + 'spainmail.com', + 'swedenmail.com', + 'swissmail.com', + 'torontomail.com', + 'arcticmail.com', + 'atheist.com', + 'disciples.com', + 'muslim.com', + 'protestant.com', + 'reborn.com', + 'reincarnate.com', + 'religious.com', + 'saintly.com', + 'cutey.com', + 'dbzmail.com', + 'doramail.com', + 'galaxyhit.com', + 'hilarious.com', + 'iname.com', + '2trom.com', + 'innocent.com', + 'keromail.com', + 'myself.com', + 'toothfairy.com', + 'toke.com', + 'tvstar.com', + 'uymail.com', + 'execs.com', + 'cheerful.com', + // Hey (no plus addressing, only aliases via HEY for Domains) + 'hey.com', + // Mail.ru (uses anonymous addresses, not plus addressing) + 'mail.ru', + 'inbox.ru', + 'list.ru', + 'bk.ru', + // NetEase (China) + '163.com', + '126.com', + 'yeah.net', + // QQ Mail (China) + 'qq.com', + 'foxmail.com', + // Libero (Italy) + 'libero.it', + // La Poste (France) + 'laposte.net', + // Rediffmail (India) + 'rediffmail.com', + 'rediff.com', +]); diff --git a/src/email/providers.test.ts b/src/email/providers.test.ts new file mode 100644 index 0000000..2a9771b --- /dev/null +++ b/src/email/providers.test.ts @@ -0,0 +1,357 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test'; +import { + domainRegex, + extractDomainFromEmail, + extractLocalPart, + getProviderStatus, + getProviderStatusWithMx, + PLUS_SUPPORTED_DOMAINS, + PLUS_UNSUPPORTED_DOMAINS, +} from './providers.js'; + +// Mock chrome.storage.local (needed by mx-lookup, used by getProviderStatusWithMx) +let store: Record = {}; +const mockChrome = { + storage: { + local: { + get: mock(async (key: string) => { + const val = store[key]; + return val !== undefined ? { [key]: val } : {}; + }), + set: mock(async (items: Record) => { + Object.assign(store, items); + }), + }, + }, +}; +(globalThis as Record).chrome = mockChrome; + +// Mock fetch (needed by mx-lookup for DNS queries) +let fetchResponse: { ok: boolean; status: number; json: () => Promise }; +const mockFetch = mock(async () => fetchResponse); +(globalThis as Record).fetch = mockFetch; + +beforeEach(() => { + store = {}; + mockFetch.mockClear(); + mockChrome.storage.local.get.mockClear(); + mockChrome.storage.local.set.mockClear(); +}); + +// ── domainRegex ── + +describe('domainRegex', () => { + describe('valid domains', () => { + test('accepts simple domain', () => { + expect(domainRegex.test('example.com')).toBe(true); + }); + + test('accepts domain with subdomain', () => { + expect(domainRegex.test('mail.example.com')).toBe(true); + }); + + test('accepts domain with multiple subdomains', () => { + expect(domainRegex.test('sub.mail.example.com')).toBe(true); + }); + + test('accepts short domain names', () => { + expect(domainRegex.test('mg.de')).toBe(true); + }); + + test('accepts single char subdomain', () => { + expect(domainRegex.test('a.example.com')).toBe(true); + }); + + test('accepts domain with hyphens', () => { + expect(domainRegex.test('my-domain.com')).toBe(true); + }); + + test('accepts .co.uk TLD', () => { + expect(domainRegex.test('example.co.uk')).toBe(true); + }); + + test('accepts longer TLDs', () => { + expect(domainRegex.test('example.technology')).toBe(true); + }); + }); + + describe('invalid domains', () => { + test('rejects empty string', () => { + expect(domainRegex.test('')).toBe(false); + }); + + test('rejects domain without TLD', () => { + expect(domainRegex.test('localhost')).toBe(false); + }); + + test('rejects domain starting with hyphen', () => { + expect(domainRegex.test('-example.com')).toBe(false); + }); + + test('rejects domain ending with hyphen', () => { + expect(domainRegex.test('example-.com')).toBe(false); + }); + + test('rejects domain with spaces', () => { + expect(domainRegex.test('example .com')).toBe(false); + }); + + test('rejects domain with underscore', () => { + expect(domainRegex.test('example_domain.com')).toBe(false); + }); + + test('rejects single letter TLD', () => { + expect(domainRegex.test('example.c')).toBe(false); + }); + + test('rejects IP address', () => { + expect(domainRegex.test('192.168.1.1')).toBe(false); + }); + + test('rejects domain with protocol', () => { + expect(domainRegex.test('https://example.com')).toBe(false); + }); + + test('rejects domain with path', () => { + expect(domainRegex.test('example.com/path')).toBe(false); + }); + }); +}); + +// ── extractDomainFromEmail ── + +describe('extractDomainFromEmail', () => { + test('extracts domain from standard email', () => { + expect(extractDomainFromEmail('user@example.com')).toBe('example.com'); + }); + + test('extracts domain from email with subdomain', () => { + expect(extractDomainFromEmail('user@mail.example.com')).toBe('mail.example.com'); + }); + + test('extracts domain from email with plus addressing', () => { + expect(extractDomainFromEmail('user+tag@example.com')).toBe('example.com'); + }); + + test('returns null for empty string', () => { + expect(extractDomainFromEmail('')).toBeNull(); + }); + + test('returns null for whitespace-only string', () => { + expect(extractDomainFromEmail(' ')).toBeNull(); + }); + + test('returns null for string without @', () => { + expect(extractDomainFromEmail('no-at-symbol')).toBeNull(); + }); + + test('returns null for string ending with @', () => { + expect(extractDomainFromEmail('user@')).toBeNull(); + }); + + test('returns null for string starting with @', () => { + expect(extractDomainFromEmail('@domain.com')).toBeNull(); + }); + + test('handles email with multiple @ by using last one', () => { + expect(extractDomainFromEmail('weird@local@domain.com')).toBe('domain.com'); + }); + + test('trims whitespace from input', () => { + expect(extractDomainFromEmail(' user@example.com ')).toBe('example.com'); + }); +}); + +// ── extractLocalPart ── + +describe('extractLocalPart', () => { + test('extracts local part from standard email', () => { + expect(extractLocalPart('user@example.com')).toBe('user'); + }); + + test('extracts local part with dots', () => { + expect(extractLocalPart('first.last@example.com')).toBe('first.last'); + }); + + test('extracts local part with plus', () => { + expect(extractLocalPart('user+tag@example.com')).toBe('user+tag'); + }); + + test('returns null for empty string', () => { + expect(extractLocalPart('')).toBeNull(); + }); + + test('returns null for string without @', () => { + expect(extractLocalPart('no-at-symbol')).toBeNull(); + }); + + test('returns null for string starting with @', () => { + expect(extractLocalPart('@domain.com')).toBeNull(); + }); + + test('handles multiple @ by using last one', () => { + expect(extractLocalPart('weird@local@domain.com')).toBe('weird@local'); + }); +}); + +// ── getProviderStatus ── + +describe('getProviderStatus', () => { + describe('plus-supported providers', () => { + const supported = [ + 'gmail.com', + 'googlemail.com', + 'outlook.com', + 'hotmail.com', + 'live.com', + 'msn.com', + 'protonmail.com', + 'proton.me', + 'pm.me', + 'protonmail.ch', + 'fastmail.com', + 'fastmail.fm', + 'pobox.com', + 'sent.com', + 'mailbox.org', + 'yandex.com', + 'yandex.ru', + 'ya.ru', + ]; + + for (const domain of supported) { + test(`${domain} is plus-supported`, () => { + expect(getProviderStatus(domain)).toBe('plus-supported'); + }); + } + + test('is case-insensitive', () => { + expect(getProviderStatus('Gmail.com')).toBe('plus-supported'); + expect(getProviderStatus('OUTLOOK.COM')).toBe('plus-supported'); + }); + }); + + describe('plus-unsupported providers', () => { + const unsupported = [ + 'yahoo.com', + 'ymail.com', + 'rocketmail.com', + 'gmx.com', + 'gmx.de', + 'gmx.net', + 'web.de', + 'mail.com', + 'email.com', + 't-online.de', + 'tuta.com', + 'tutanota.com', + 'icloud.com', + 'me.com', + 'mac.com', + '163.com', + 'qq.com', + 'foxmail.com', + 'libero.it', + 'laposte.net', + 'rediffmail.com', + 'hey.com', + 'mail.ru', + 'inbox.ru', + 'bk.ru', + ]; + + for (const domain of unsupported) { + test(`${domain} is plus-unsupported`, () => { + expect(getProviderStatus(domain)).toBe('plus-unsupported'); + }); + } + }); + + describe('custom domains', () => { + test('unknown domain returns custom', () => { + expect(getProviderStatus('company.com')).toBe('custom'); + }); + + test('personal domain returns custom', () => { + expect(getProviderStatus('manuelgruber.com')).toBe('custom'); + }); + + test('subdomain of known provider returns custom', () => { + expect(getProviderStatus('mail.gmail.com')).toBe('custom'); + }); + + test('zoho.com returns custom (unverified)', () => { + expect(getProviderStatus('zoho.com')).toBe('custom'); + }); + }); + + describe('provider lists are disjoint', () => { + test('no domain appears in both supported and unsupported lists', () => { + for (const domain of PLUS_SUPPORTED_DOMAINS) { + expect(PLUS_UNSUPPORTED_DOMAINS.has(domain)).toBe(false); + } + for (const domain of PLUS_UNSUPPORTED_DOMAINS) { + expect(PLUS_SUPPORTED_DOMAINS.has(domain)).toBe(false); + } + }); + }); +}); + +// ── getProviderStatusWithMx ── + +describe('getProviderStatusWithMx', () => { + test('returns sync status for known supported domain without MX lookup', async () => { + const result = await getProviderStatusWithMx('gmail.com'); + expect(result.status).toBe('plus-supported'); + expect(result.mxResult).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + test('returns sync status for known unsupported domain without MX lookup', async () => { + const result = await getProviderStatusWithMx('yahoo.com'); + expect(result.status).toBe('plus-unsupported'); + expect(result.mxResult).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + test('falls through to MX lookup for custom domain', async () => { + fetchResponse = { + ok: true, + status: 200, + json: async () => ({ + Status: 0, + Answer: [{ name: 'company.com', type: 15, TTL: 3600, data: '10 smtp.google.com.' }], + }), + }; + + const result = await getProviderStatusWithMx('company.com'); + expect(result.status).toBe('plus-supported'); + expect(result.mxResult).not.toBeNull(); + expect(result.mxResult?.provider).toBe('google-workspace'); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + test('returns custom status when MX lookup finds unknown provider', async () => { + fetchResponse = { + ok: true, + status: 200, + json: async () => ({ + Status: 0, + Answer: [{ name: 'custom.com', type: 15, TTL: 3600, data: '10 mx.unknown.com.' }], + }), + }; + + const result = await getProviderStatusWithMx('custom.com'); + expect(result.status).toBe('custom'); + expect(result.mxResult).not.toBeNull(); + expect(result.mxResult?.provider).toBeNull(); + }); + + test('returns custom status when MX lookup fails', async () => { + fetchResponse = { ok: false, status: 500, json: async () => ({}) }; + + const result = await getProviderStatusWithMx('broken.com'); + expect(result.status).toBe('custom'); + expect(result.mxResult).not.toBeNull(); + }); +}); diff --git a/src/email/providers.ts b/src/email/providers.ts new file mode 100644 index 0000000..c97d1c0 --- /dev/null +++ b/src/email/providers.ts @@ -0,0 +1,41 @@ +export type ProviderStatus = 'plus-supported' | 'plus-unsupported' | 'custom'; + +export { PLUS_SUPPORTED_DOMAINS, PLUS_UNSUPPORTED_DOMAINS } from './provider-domains.js'; + +import type { MxLookupResult } from '../types'; +import { lookupMxRecords } from './mx-lookup.js'; +import { PLUS_SUPPORTED_DOMAINS, PLUS_UNSUPPORTED_DOMAINS } from './provider-domains.js'; + +export function getProviderStatus(domain: string): ProviderStatus { + const lower = domain.toLowerCase(); + if (PLUS_SUPPORTED_DOMAINS.has(lower)) return 'plus-supported'; + if (PLUS_UNSUPPORTED_DOMAINS.has(lower)) return 'plus-unsupported'; + return 'custom'; +} + +export function extractDomainFromEmail(email: string): string | null { + const trimmed = email.trim(); + if (!trimmed) return null; + const atIndex = trimmed.lastIndexOf('@'); + if (atIndex === -1 || atIndex === 0 || atIndex === trimmed.length - 1) return null; + return trimmed.substring(atIndex + 1); +} + +export function extractLocalPart(email: string): string | null { + const trimmed = email.trim(); + if (!trimmed) return null; + const atIndex = trimmed.lastIndexOf('@'); + if (atIndex <= 0) return null; + return trimmed.substring(0, atIndex); +} + +export const domainRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z]{2,})+$/; + +export async function getProviderStatusWithMx( + domain: string, +): Promise<{ status: ProviderStatus; mxResult: MxLookupResult | null }> { + const syncStatus = getProviderStatus(domain); + if (syncStatus !== 'custom') return { status: syncStatus, mxResult: null }; + const mxResult = await lookupMxRecords(domain); + return { status: mxResult.status, mxResult }; +} diff --git a/src/utils.test.ts b/src/email/utils.test.ts similarity index 99% rename from src/utils.test.ts rename to src/email/utils.test.ts index 2170609..e5f230c 100644 --- a/src/utils.test.ts +++ b/src/email/utils.test.ts @@ -3,7 +3,7 @@ import { beforeAll, describe, expect, test } from 'bun:test'; // Load utils.js by executing it (it sets globalThis.CleanAutofillUtils) beforeAll(async () => { // Import the utils file to populate globalThis - await import('./utils.js'); + await import('../email/utils.js'); }); // Helper to get utils after they're loaded diff --git a/src/utils.ts b/src/email/utils.ts similarity index 96% rename from src/utils.ts rename to src/email/utils.ts index abf907c..0d6c121 100644 --- a/src/utils.ts +++ b/src/email/utils.ts @@ -1,6 +1,6 @@ // Shared utilities for Clean-Autofill extension import psl from 'psl'; -import type { CleanAutofillUtils } from './types'; +import type { CleanAutofillUtils } from '../types'; /** * List of special multi-part TLDs (e.g., co.uk, com.au) used for domain extraction. @@ -187,4 +187,4 @@ if (typeof globalThis !== 'undefined') { (globalThis as { CleanAutofillUtils?: CleanAutofillUtils }).CleanAutofillUtils = utils; } -export { extractMainDomain, isValidEmail, createTimeout, debounce, SPECIAL_TLDS }; +export { createTimeout, debounce, extractMainDomain, isValidEmail, SPECIAL_TLDS }; diff --git a/src/content.test.ts b/src/extension/autofill.test.ts similarity index 98% rename from src/content.test.ts rename to src/extension/autofill.test.ts index 506432f..7e6f9b0 100644 --- a/src/content.test.ts +++ b/src/extension/autofill.test.ts @@ -20,10 +20,10 @@ let isElementVisible: (element: Element | null) => boolean; beforeAll(async () => { // Load utils first (content.ts depends on it) - await import('./utils.js'); + await import('../email/utils.js'); // Now import the exported pure functions from production code - const content = await import('./content.js'); + const content = await import('../extension/autofill.js'); isInputField = content.isInputField; findEmailFields = content.findEmailFields; findTextFields = content.findTextFields; diff --git a/src/content.ts b/src/extension/autofill.ts similarity index 98% rename from src/content.ts rename to src/extension/autofill.ts index 66b4d0f..8d6dade 100644 --- a/src/content.ts +++ b/src/extension/autofill.ts @@ -1,5 +1,5 @@ // Access shared utilities (loaded via manifest before this script) -import type { CleanAutofillUtils, FillEmailRequest, FillEmailResponse } from './types'; +import type { CleanAutofillUtils, FillEmailRequest, FillEmailResponse } from '../types'; // Get utils object reference (use different name to avoid conflict with 'utils' declared in utils-content.js) const cleanAutofillUtils = (globalThis as { CleanAutofillUtils?: CleanAutofillUtils }) @@ -32,7 +32,7 @@ chrome.runtime.onMessage.addListener( ) => { if (request.action === 'fillEmail') { // Validate email before using - if (!cleanAutofillUtils?.isValidEmail || !cleanAutofillUtils.isValidEmail(request.email)) { + if (!cleanAutofillUtils?.isValidEmail?.(request.email)) { sendResponse({ success: false, error: 'Invalid email format received' }); return true; } diff --git a/src/extension/background.test.ts b/src/extension/background.test.ts new file mode 100644 index 0000000..25a7775 --- /dev/null +++ b/src/extension/background.test.ts @@ -0,0 +1,354 @@ +import { beforeAll, describe, expect, test } from 'bun:test'; + +import type { EmailMode } from '../types'; + +// Load utils first +beforeAll(async () => { + await import('../email/utils.js'); +}); + +// Get utils for testing +const getUtils = () => { + const utils = (globalThis as Record).CleanAutofillUtils as { + extractMainDomain: (hostname: string) => string; + }; + return utils; +}; + +// Test the email generation logic (extracted from background.ts) +function generateEmail( + tabUrl: string, + userDomain: string, + mode: EmailMode = 'catchAll', + baseEmail?: string, +): string | null { + const { extractMainDomain } = getUtils(); + + // Check required config for active mode + if (mode === 'plusAddressing') { + if (!baseEmail?.includes('@')) return null; + } else { + if (!userDomain) return null; + } + + // Skip chrome:// and extension:// URLs + if (tabUrl.startsWith('chrome://') || tabUrl.startsWith('chrome-extension://')) { + throw new Error("Email addresses can't be generated on browser pages."); + } + + try { + const url = new URL(tabUrl); + const siteDomain = extractMainDomain(url.hostname); + + if (mode === 'plusAddressing') { + const atIndex = (baseEmail as string).lastIndexOf('@'); + const localPart = (baseEmail as string).substring(0, atIndex); + const emailDomain = (baseEmail as string).substring(atIndex + 1); + return `${localPart}+${siteDomain}@${emailDomain}`; + } + + return `${siteDomain}@${userDomain}`; + } catch { + throw new Error('Unable to parse current website URL'); + } +} + +describe('generateEmail', () => { + describe('catch-all mode (default)', () => { + test('generates email for simple domain', () => { + const email = generateEmail('https://example.com', 'mydomain.com'); + expect(email).toBe('example.com@mydomain.com'); + }); + + test('generates email for domain with www', () => { + const email = generateEmail('https://www.example.com', 'mydomain.com'); + expect(email).toBe('example.com@mydomain.com'); + }); + + test('generates email for subdomain', () => { + const email = generateEmail('https://mail.google.com', 'mydomain.com'); + expect(email).toBe('google.com@mydomain.com'); + }); + + test('generates email for deep subdomain', () => { + const email = generateEmail('https://api.v2.github.com', 'mydomain.com'); + expect(email).toBe('github.com@mydomain.com'); + }); + + test('generates email for special TLD (.co.uk)', () => { + const email = generateEmail('https://www.bbc.co.uk', 'mydomain.com'); + expect(email).toBe('bbc.co.uk@mydomain.com'); + }); + + test('generates email for special TLD (.com.au)', () => { + const email = generateEmail('https://news.example.com.au', 'mydomain.com'); + expect(email).toBe('example.com.au@mydomain.com'); + }); + + test('generates email with path in URL', () => { + const email = generateEmail('https://github.com/user/repo', 'mydomain.com'); + expect(email).toBe('github.com@mydomain.com'); + }); + + test('generates email with query string in URL', () => { + const email = generateEmail('https://example.com?foo=bar', 'mydomain.com'); + expect(email).toBe('example.com@mydomain.com'); + }); + + test('generates email for http URL', () => { + const email = generateEmail('http://example.com', 'mydomain.com'); + expect(email).toBe('example.com@mydomain.com'); + }); + + test('generates email for localhost', () => { + const email = generateEmail('http://localhost:3000', 'mydomain.com'); + expect(email).toBe('localhost@mydomain.com'); + }); + + test('generates email for IP address', () => { + const email = generateEmail('http://192.168.1.1:8080', 'mydomain.com'); + expect(email).toBe('192.168.1.1@mydomain.com'); + }); + + test('works with explicit catchAll mode', () => { + const email = generateEmail('https://example.com', 'mydomain.com', 'catchAll'); + expect(email).toBe('example.com@mydomain.com'); + }); + + test('ignores baseEmail when catch-all mode is selected', () => { + const email = generateEmail( + 'https://example.com', + 'mydomain.com', + 'catchAll', + 'user@gmail.com', + ); + expect(email).toBe('example.com@mydomain.com'); + }); + }); + + describe('plus addressing mode', () => { + test('generates plus-addressed email for simple domain', () => { + const email = generateEmail('https://zalando.de', '', 'plusAddressing', 'name@gmail.com'); + expect(email).toBe('name+zalando.de@gmail.com'); + }); + + test('generates plus-addressed email for subdomain site', () => { + const email = generateEmail( + 'https://mail.google.com', + '', + 'plusAddressing', + 'name@gmail.com', + ); + expect(email).toBe('name+google.com@gmail.com'); + }); + + test('generates plus-addressed email for .co.uk site', () => { + const email = generateEmail('https://www.bbc.co.uk', '', 'plusAddressing', 'name@gmail.com'); + expect(email).toBe('name+bbc.co.uk@gmail.com'); + }); + + test('generates plus-addressed email with company domain', () => { + const email = generateEmail( + 'https://salesforce.com', + '', + 'plusAddressing', + 'employee@company.com', + ); + expect(email).toBe('employee+salesforce.com@company.com'); + }); + + test('handles local part with dots', () => { + const email = generateEmail( + 'https://zalando.de', + '', + 'plusAddressing', + 'first.last@gmail.com', + ); + expect(email).toBe('first.last+zalando.de@gmail.com'); + }); + + test('handles localhost', () => { + const email = generateEmail('http://localhost:3000', '', 'plusAddressing', 'name@gmail.com'); + expect(email).toBe('name+localhost@gmail.com'); + }); + + test('handles IP address', () => { + const email = generateEmail( + 'http://192.168.1.1:8080', + '', + 'plusAddressing', + 'name@gmail.com', + ); + expect(email).toBe('name+192.168.1.1@gmail.com'); + }); + + test('returns null when baseEmail is missing', () => { + const email = generateEmail('https://example.com', '', 'plusAddressing'); + expect(email).toBeNull(); + }); + + test('returns null when baseEmail is empty', () => { + const email = generateEmail('https://example.com', '', 'plusAddressing', ''); + expect(email).toBeNull(); + }); + + test('returns null when baseEmail has no @', () => { + const email = generateEmail('https://example.com', '', 'plusAddressing', 'invalid-email'); + expect(email).toBeNull(); + }); + + test('uses baseEmail even when userDomain is also present', () => { + const email = generateEmail( + 'https://example.com', + 'mydomain.com', + 'plusAddressing', + 'user@gmail.com', + ); + expect(email).toBe('user+example.com@gmail.com'); + }); + }); + + describe('no user domain configured', () => { + test('returns null when userDomain is empty in catchAll mode', () => { + const email = generateEmail('https://example.com', ''); + expect(email).toBeNull(); + }); + }); + + describe('browser pages', () => { + test('throws error for chrome:// URL', () => { + expect(() => generateEmail('chrome://extensions', 'mydomain.com')).toThrow( + "Email addresses can't be generated on browser pages.", + ); + }); + + test('throws error for chrome-extension:// URL', () => { + expect(() => generateEmail('chrome-extension://abc123/options.html', 'mydomain.com')).toThrow( + "Email addresses can't be generated on browser pages.", + ); + }); + + test('throws error for chrome:// URL in plus addressing mode', () => { + expect(() => + generateEmail('chrome://extensions', '', 'plusAddressing', 'name@gmail.com'), + ).toThrow("Email addresses can't be generated on browser pages."); + }); + }); + + describe('invalid URLs', () => { + test('throws error for invalid URL', () => { + expect(() => generateEmail('not-a-valid-url', 'mydomain.com')).toThrow( + 'Unable to parse current website URL', + ); + }); + + test('throws error for empty URL', () => { + expect(() => generateEmail('', 'mydomain.com')).toThrow( + 'Unable to parse current website URL', + ); + }); + }); + + describe('various user domains', () => { + test('works with subdomain user domain', () => { + const email = generateEmail('https://example.com', 'mail.mydomain.com'); + expect(email).toBe('example.com@mail.mydomain.com'); + }); + + test('works with short user domain', () => { + const email = generateEmail('https://example.com', 'mg.de'); + expect(email).toBe('example.com@mg.de'); + }); + }); +}); + +describe('message timeout constant', () => { + test('MESSAGE_TIMEOUT should be a reasonable value', () => { + const MESSAGE_TIMEOUT = 5000; + expect(MESSAGE_TIMEOUT).toBeGreaterThanOrEqual(1000); + expect(MESSAGE_TIMEOUT).toBeLessThanOrEqual(30000); + }); +}); + +describe('initializeDefaultSettings', () => { + // Extracted logic from background.ts initializeDefaultSettings + async function initializeDefaultSettings( + getStorage: () => Promise>, + setStorage: (items: Record) => Promise, + getProfileEmail: () => Promise<{ email: string }>, + ): Promise<{ saved: boolean; email?: string }> { + try { + const existing = await getStorage(); + if (existing.emailMode || existing.emailDomain || existing.baseEmail) { + return { saved: false }; + } + + const userInfo = await getProfileEmail(); + if (userInfo.email?.includes('@')) { + await setStorage({ emailMode: 'plusAddressing', baseEmail: userInfo.email }); + return { saved: true, email: userInfo.email }; + } + return { saved: false }; + } catch { + return { saved: false }; + } + } + + test('saves plus addressing defaults when profile email is available', async () => { + const stored: Record = {}; + const result = await initializeDefaultSettings( + async () => ({}), + async (items) => Object.assign(stored, items), + async () => ({ email: 'user@gmail.com' }), + ); + expect(result.saved).toBe(true); + expect(result.email).toBe('user@gmail.com'); + expect(stored.emailMode).toBe('plusAddressing'); + expect(stored.baseEmail).toBe('user@gmail.com'); + }); + + test('does not save when no profile email is available', async () => { + const stored: Record = {}; + const result = await initializeDefaultSettings( + async () => ({}), + async (items) => Object.assign(stored, items), + async () => ({ email: '' }), + ); + expect(result.saved).toBe(false); + expect(stored.emailMode).toBeUndefined(); + }); + + test('does not overwrite existing settings', async () => { + const stored: Record = { emailMode: 'catchAll', emailDomain: 'mg.de' }; + const result = await initializeDefaultSettings( + async () => ({ emailMode: 'catchAll', emailDomain: 'mg.de' }), + async (items) => Object.assign(stored, items), + async () => ({ email: 'user@gmail.com' }), + ); + expect(result.saved).toBe(false); + expect(stored.emailMode).toBe('catchAll'); + expect(stored.emailDomain).toBe('mg.de'); + }); + + test('handles getProfileEmail error gracefully', async () => { + const result = await initializeDefaultSettings( + async () => ({}), + async () => {}, + async () => { + throw new Error('API unavailable'); + }, + ); + expect(result.saved).toBe(false); + }); + + test('rejects email without @ sign', async () => { + const stored: Record = {}; + const result = await initializeDefaultSettings( + async () => ({}), + async (items) => Object.assign(stored, items), + async () => ({ email: 'invalid-email' }), + ); + expect(result.saved).toBe(false); + expect(stored.emailMode).toBeUndefined(); + }); +}); diff --git a/src/extension/background.ts b/src/extension/background.ts new file mode 100644 index 0000000..1e45cbe --- /dev/null +++ b/src/extension/background.ts @@ -0,0 +1,186 @@ +// Import shared utilities as ES module + +import { createTimeout, extractMainDomain } from '../email/utils.js'; +import type { EmailMode, FillEmailResponse, GenerateAndFillResponse } from '../types'; +import { addEntry } from '../ui/history.js'; + +// Message timeout in milliseconds +const MESSAGE_TIMEOUT = 5000; + +// Handle messages from popup +chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { + if (request.action === 'generateAndFill') { + handleGenerateAndFill().then(sendResponse); + return true; // keep message channel open for async response + } +}); + +async function handleGenerateAndFill(): Promise { + try { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + + if (!tab) { + return { success: false, error: 'No active tab found' }; + } + + const result = await generateEmailForTab(tab); + + if (!result) { + return { success: false, needsConfig: true }; + } + + const { email } = result; + + // Save to history + addEntry({ + email, + domain: result.domain, + pageUrl: tab.url ? new URL(tab.url).origin + new URL(tab.url).pathname : '', + pageTitle: tab.title ?? '', + createdAt: new Date().toISOString(), + mode: result.mode, + }).catch((err) => console.error('Failed to save history:', err)); + + if (tab.id === undefined) { + return { success: true, email, message: 'Email generated (no tab to fill)' }; + } + + // Try to fill the email field + try { + const response = (await Promise.race([ + chrome.tabs.sendMessage(tab.id, { action: 'fillEmail', email }), + createTimeout(MESSAGE_TIMEOUT, 'Content script did not respond'), + ])) as FillEmailResponse; + + if (response?.success) { + return { success: true, email, message: response.message }; + } + if (response?.error) { + return { success: true, email, message: `Email generated (${response.error})` }; + } + // No response — no field found, but email still generated + return { success: true, email, message: 'Email generated (no field found to fill)' }; + } catch (fillError) { + const msg = fillError instanceof Error ? fillError.message : 'Fill failed'; + + if (msg.includes('Receiving end does not exist')) { + return { + success: true, + email, + message: 'Email generated (please refresh the page to autofill)', + }; + } + if (msg.includes('Content script did not respond')) { + return { success: true, email, message: 'Email generated (no field found to fill)' }; + } + return { success: true, email, message: `Email generated (${msg})` }; + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to generate email'; + return { success: false, error: errorMessage }; + } +} + +interface GenerateResult { + email: string; + domain: string; + mode: EmailMode; +} + +/** + * Generate an email address based on the current tab's domain and user settings. + * Combines the site's main domain with the user's configured email domain. + * @param tab - The Chrome tab to generate the email for + * @returns The generated email and metadata, or null if no domain is configured + * @throws Error if unable to read settings or parse the tab URL + */ +async function generateEmailForTab(tab: chrome.tabs.Tab): Promise { + // Get user settings from storage + let mode: EmailMode; + let userDomain: string | undefined; + let baseEmail: string | undefined; + try { + const result = await chrome.storage.sync.get(['emailDomain', 'emailMode', 'baseEmail']); + mode = (result.emailMode as EmailMode) ?? 'catchAll'; + userDomain = result.emailDomain as string | undefined; + baseEmail = result.baseEmail as string | undefined; + } catch (error) { + console.error('Failed to read storage:', error); + throw new Error('Unable to read settings. Please try again.'); + } + + // Check required config for active mode + if (mode === 'plusAddressing') { + if (!baseEmail?.includes('@')) return null; + } else { + if (!userDomain) return null; + } + + // Extract domain from tab URL + if (!tab?.url) { + throw new Error('Unable to get current website domain'); + } + + // Skip chrome:// and extension:// URLs + if (tab.url.startsWith('chrome://') || tab.url.startsWith('chrome-extension://')) { + throw new Error("Email addresses can't be generated on browser pages."); + } + + try { + const url = new URL(tab.url); + const siteDomain = extractMainDomain(url.hostname); + + if (mode === 'plusAddressing') { + const atIndex = (baseEmail as string).lastIndexOf('@'); + const localPart = (baseEmail as string).substring(0, atIndex); + const emailDomain = (baseEmail as string).substring(atIndex + 1); + return { email: `${localPart}+${siteDomain}@${emailDomain}`, domain: siteDomain, mode }; + } + + return { email: `${siteDomain}@${userDomain}`, domain: siteDomain, mode }; + } catch { + throw new Error('Unable to parse current website URL'); + } +} + +// Auto-detect Chrome profile email and save default settings on first install +async function initializeDefaultSettings(): Promise { + try { + const existing = await chrome.storage.sync.get(['emailMode', 'emailDomain', 'baseEmail']); + if (existing.emailMode || existing.emailDomain || existing.baseEmail) return; + + const userInfo = await chrome.identity.getProfileUserInfo({ accountStatus: 'ANY' }); + if (userInfo.email?.includes('@')) { + await chrome.storage.sync.set({ emailMode: 'plusAddressing', baseEmail: userInfo.email }); + chrome.notifications.create({ + type: 'basic', + iconUrl: 'icons/icon48.png', + title: 'Clean-Autofill Installed', + message: `Ready to go! Using ${userInfo.email} for plus addressing.`, + }); + } else { + chrome.notifications.create({ + type: 'basic', + iconUrl: 'icons/icon48.png', + title: 'Clean-Autofill Installed', + message: 'Click the extension icon to fill emails! Configure your email in options first.', + }); + } + } catch { + chrome.notifications.create({ + type: 'basic', + iconUrl: 'icons/icon48.png', + title: 'Clean-Autofill Installed', + message: 'Click the extension icon to fill emails! Configure your email in options first.', + }); + } +} + +// Install event - initialize defaults and open options +chrome.runtime.onInstalled.addListener((details) => { + if (details.reason === 'install') { + initializeDefaultSettings().then(() => { + chrome.runtime.openOptionsPage(); + }); + } +}); diff --git a/src/icons/icon.svg b/src/icons/icon.svg index b59346f..73adf18 100644 --- a/src/icons/icon.svg +++ b/src/icons/icon.svg @@ -1,14 +1,14 @@ - - - + + + - + - + - @ + @ diff --git a/src/icons/icon128.png b/src/icons/icon128.png index 1192dd0..c7ac92a 100644 Binary files a/src/icons/icon128.png and b/src/icons/icon128.png differ diff --git a/src/icons/icon16.png b/src/icons/icon16.png index 6ec13d9..fe326e7 100644 Binary files a/src/icons/icon16.png and b/src/icons/icon16.png differ diff --git a/src/icons/icon32.png b/src/icons/icon32.png index a6d1c97..eb90c88 100644 Binary files a/src/icons/icon32.png and b/src/icons/icon32.png differ diff --git a/src/icons/icon48.png b/src/icons/icon48.png index 2bfcd5c..4de0f52 100644 Binary files a/src/icons/icon48.png and b/src/icons/icon48.png differ diff --git a/src/icons/providers/fastmail.png b/src/icons/providers/fastmail.png new file mode 100644 index 0000000..86450e5 Binary files /dev/null and b/src/icons/providers/fastmail.png differ diff --git a/src/icons/providers/gmail.png b/src/icons/providers/gmail.png new file mode 100644 index 0000000..ed9c158 Binary files /dev/null and b/src/icons/providers/gmail.png differ diff --git a/src/icons/providers/gmx.png b/src/icons/providers/gmx.png new file mode 100644 index 0000000..7a56aa8 Binary files /dev/null and b/src/icons/providers/gmx.png differ diff --git a/src/icons/providers/google-workspace.png b/src/icons/providers/google-workspace.png new file mode 100644 index 0000000..8642fc1 Binary files /dev/null and b/src/icons/providers/google-workspace.png differ diff --git a/src/icons/providers/hey.png b/src/icons/providers/hey.png new file mode 100644 index 0000000..4a254e1 Binary files /dev/null and b/src/icons/providers/hey.png differ diff --git a/src/icons/providers/icloud.png b/src/icons/providers/icloud.png new file mode 100644 index 0000000..c046482 Binary files /dev/null and b/src/icons/providers/icloud.png differ diff --git a/src/icons/providers/laposte.png b/src/icons/providers/laposte.png new file mode 100644 index 0000000..84606a5 Binary files /dev/null and b/src/icons/providers/laposte.png differ diff --git a/src/icons/providers/libero.png b/src/icons/providers/libero.png new file mode 100644 index 0000000..af6f936 Binary files /dev/null and b/src/icons/providers/libero.png differ diff --git a/src/icons/providers/mailbox-org.png b/src/icons/providers/mailbox-org.png new file mode 100644 index 0000000..7937047 Binary files /dev/null and b/src/icons/providers/mailbox-org.png differ diff --git a/src/icons/providers/mailcom.png b/src/icons/providers/mailcom.png new file mode 100644 index 0000000..a3cf2fa Binary files /dev/null and b/src/icons/providers/mailcom.png differ diff --git a/src/icons/providers/mailru.png b/src/icons/providers/mailru.png new file mode 100644 index 0000000..9fcfe79 Binary files /dev/null and b/src/icons/providers/mailru.png differ diff --git a/src/icons/providers/netease.png b/src/icons/providers/netease.png new file mode 100644 index 0000000..209d360 Binary files /dev/null and b/src/icons/providers/netease.png differ diff --git a/src/icons/providers/outlook.png b/src/icons/providers/outlook.png new file mode 100644 index 0000000..3618712 Binary files /dev/null and b/src/icons/providers/outlook.png differ diff --git a/src/icons/providers/protonmail.png b/src/icons/providers/protonmail.png new file mode 100644 index 0000000..b89c84a Binary files /dev/null and b/src/icons/providers/protonmail.png differ diff --git a/src/icons/providers/qq.png b/src/icons/providers/qq.png new file mode 100644 index 0000000..c21d5ef Binary files /dev/null and b/src/icons/providers/qq.png differ diff --git a/src/icons/providers/rediffmail.png b/src/icons/providers/rediffmail.png new file mode 100644 index 0000000..698b8d5 Binary files /dev/null and b/src/icons/providers/rediffmail.png differ diff --git a/src/icons/providers/t-online.png b/src/icons/providers/t-online.png new file mode 100644 index 0000000..4f872f5 Binary files /dev/null and b/src/icons/providers/t-online.png differ diff --git a/src/icons/providers/tutanota.png b/src/icons/providers/tutanota.png new file mode 100644 index 0000000..592d7b4 Binary files /dev/null and b/src/icons/providers/tutanota.png differ diff --git a/src/icons/providers/webde.png b/src/icons/providers/webde.png new file mode 100644 index 0000000..5c43da5 Binary files /dev/null and b/src/icons/providers/webde.png differ diff --git a/src/icons/providers/yahoo.png b/src/icons/providers/yahoo.png new file mode 100644 index 0000000..1a1f380 Binary files /dev/null and b/src/icons/providers/yahoo.png differ diff --git a/src/icons/providers/yandex.png b/src/icons/providers/yandex.png new file mode 100644 index 0000000..89d2d55 Binary files /dev/null and b/src/icons/providers/yandex.png differ diff --git a/src/icons/providers/zoho.png b/src/icons/providers/zoho.png new file mode 100644 index 0000000..27375bf Binary files /dev/null and b/src/icons/providers/zoho.png differ diff --git a/src/options.html b/src/options.html deleted file mode 100644 index abb32b8..0000000 --- a/src/options.html +++ /dev/null @@ -1,261 +0,0 @@ - - - - - Clean-Autofill Settings - - - -
    -

    -
    - Clean-Autofill Settings -

    -

    Configure your email domain for automatic email generation

    - -
    -
    - -
    - @ - -
    -

    Enter your email domain without the @ symbol

    - -
    -
    How it works:
    -
    - When you visit example.com, the extension will generate: - example.com@yourdomain.com -
    -
    - When you visit github.com, the extension will generate: - github.com@yourdomain.com -
    -
    -
    - -
    - - -
    - - -
    - -
    -
    Current Email Preview
    -
    - No domain set -
    -
    -
    - - - - - diff --git a/src/options.test.ts b/src/options.test.ts deleted file mode 100644 index 547d7af..0000000 --- a/src/options.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'; - -// Load utils first -beforeAll(async () => { - await import('./utils.js'); -}); - -// Mock chrome API -const mockStorage: Record = {}; -const mockChrome = { - storage: { - sync: { - get: mock(async (keys: string[]) => { - const result: Record = {}; - for (const key of keys) { - if (mockStorage[key] !== undefined) { - result[key] = mockStorage[key]; - } - } - return result; - }), - set: mock(async (items: Record) => { - Object.assign(mockStorage, items); - }), - remove: mock(async (keys: string[]) => { - for (const key of keys) { - delete mockStorage[key]; - } - }), - }, - }, - tabs: { - query: mock(async () => [{ url: 'https://example.com/page' }]), - }, -}; - -(globalThis as Record).chrome = mockChrome; - -// Extract testable logic from options.ts - -// Domain validation regex from options.ts -const domainRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z]{2,})+$/; - -function isValidDomain(domain: string): boolean { - return domainRegex.test(domain); -} - -function cleanDomain(domain: string): string { - return domain.trim().replace(/^@/, ''); -} - -function generateExampleEmail(siteDomain: string, userDomain: string): string { - return `${siteDomain}@${userDomain}`; -} - -describe('domain validation', () => { - describe('valid domains', () => { - test('accepts simple domain', () => { - expect(isValidDomain('example.com')).toBe(true); - }); - - test('accepts domain with subdomain', () => { - expect(isValidDomain('mail.example.com')).toBe(true); - }); - - test('accepts domain with multiple subdomains', () => { - // Multiple subdomains work correctly - expect(isValidDomain('sub.mail.example.com')).toBe(true); - }); - - test('accepts short domain names', () => { - expect(isValidDomain('mg.de')).toBe(true); - }); - - test('accepts single char subdomain', () => { - expect(isValidDomain('a.example.com')).toBe(true); - }); - - test('accepts domain with hyphens', () => { - expect(isValidDomain('my-domain.com')).toBe(true); - }); - - test('accepts .co.uk TLD', () => { - expect(isValidDomain('example.co.uk')).toBe(true); - }); - - test('accepts longer TLDs', () => { - expect(isValidDomain('example.technology')).toBe(true); - }); - }); - - describe('invalid domains', () => { - test('rejects empty string', () => { - expect(isValidDomain('')).toBe(false); - }); - - test('rejects domain without TLD', () => { - expect(isValidDomain('localhost')).toBe(false); - }); - - test('rejects domain starting with hyphen', () => { - expect(isValidDomain('-example.com')).toBe(false); - }); - - test('rejects domain ending with hyphen', () => { - expect(isValidDomain('example-.com')).toBe(false); - }); - - test('rejects domain with spaces', () => { - expect(isValidDomain('example .com')).toBe(false); - }); - - test('rejects domain with underscore', () => { - expect(isValidDomain('example_domain.com')).toBe(false); - }); - - test('rejects single letter TLD', () => { - expect(isValidDomain('example.c')).toBe(false); - }); - - test('rejects IP address', () => { - expect(isValidDomain('192.168.1.1')).toBe(false); - }); - - test('rejects domain with protocol', () => { - expect(isValidDomain('https://example.com')).toBe(false); - }); - - test('rejects domain with path', () => { - expect(isValidDomain('example.com/path')).toBe(false); - }); - }); -}); - -describe('cleanDomain', () => { - test('removes leading @ symbol', () => { - expect(cleanDomain('@example.com')).toBe('example.com'); - }); - - test('trims whitespace', () => { - expect(cleanDomain(' example.com ')).toBe('example.com'); - }); - - test('handles both whitespace and @', () => { - expect(cleanDomain(' @example.com ')).toBe('example.com'); - }); - - test('leaves valid domain unchanged', () => { - expect(cleanDomain('example.com')).toBe('example.com'); - }); - - test('only removes first @ symbol', () => { - expect(cleanDomain('@user@example.com')).toBe('user@example.com'); - }); -}); - -describe('generateExampleEmail', () => { - test('generates correct email format', () => { - expect(generateExampleEmail('google.com', 'mydomain.com')).toBe('google.com@mydomain.com'); - }); - - test('works with subdomain user domain', () => { - expect(generateExampleEmail('github.com', 'mail.mydomain.com')).toBe( - 'github.com@mail.mydomain.com', - ); - }); - - test('works with short domains', () => { - expect(generateExampleEmail('x.com', 'mg.de')).toBe('x.com@mg.de'); - }); -}); - -describe('chrome storage mock', () => { - beforeEach(() => { - // Clear mock storage - for (const key of Object.keys(mockStorage)) { - delete mockStorage[key]; - } - }); - - test('can set and get values', async () => { - await mockChrome.storage.sync.set({ emailDomain: 'test.com' }); - const result = await mockChrome.storage.sync.get(['emailDomain']); - expect(result.emailDomain).toBe('test.com'); - }); - - test('returns empty object for missing keys', async () => { - const result = await mockChrome.storage.sync.get(['nonexistent']); - expect(result).toEqual({}); - }); - - test('can remove values', async () => { - await mockChrome.storage.sync.set({ emailDomain: 'test.com' }); - await mockChrome.storage.sync.remove(['emailDomain']); - const result = await mockChrome.storage.sync.get(['emailDomain']); - expect(result).toEqual({}); - }); -}); - -describe('status message types', () => { - // Test the status message class logic - function getStatusClass(type: 'success' | 'error'): string { - return `status ${type}`; - } - - test('returns correct class for success', () => { - expect(getStatusClass('success')).toBe('status success'); - }); - - test('returns correct class for error', () => { - expect(getStatusClass('error')).toBe('status error'); - }); -}); diff --git a/src/options.ts b/src/options.ts deleted file mode 100644 index 5b4e44f..0000000 --- a/src/options.ts +++ /dev/null @@ -1,190 +0,0 @@ -// Access shared utilities (loaded via script tag in options.html) -import type { CleanAutofillUtils } from './types'; - -const { extractMainDomain, debounce } = - (globalThis as { CleanAutofillUtils?: CleanAutofillUtils }).CleanAutofillUtils || {}; - -document.addEventListener('DOMContentLoaded', async () => { - const form = document.getElementById('settingsForm'); - const emailDomainInput = document.getElementById('emailDomain'); - const statusDiv = document.getElementById('status'); - const clearButton = document.getElementById('clearButton'); - const previewBox = document.getElementById('previewBox'); - const exampleEmail = document.getElementById('exampleEmail'); - const exampleEmail2 = document.getElementById('exampleEmail2'); - - // Verify all required DOM elements exist - if ( - !form || - !emailDomainInput || - !statusDiv || - !clearButton || - !previewBox || - !exampleEmail || - !exampleEmail2 - ) { - console.error('Required DOM elements not found'); - return; - } - - // Type-safe references after null check - const formEl = form as HTMLFormElement; - const emailInput = emailDomainInput as HTMLInputElement; - const statusEl = statusDiv as HTMLDivElement; - const clearBtn = clearButton as HTMLButtonElement; - const previewEl = previewBox as HTMLDivElement; - const example1 = exampleEmail as HTMLSpanElement; - const example2 = exampleEmail2 as HTMLSpanElement; - - /** - * Load saved settings from Chrome sync storage and update the UI. - */ - async function loadSettings(): Promise { - try { - const result = await chrome.storage.sync.get(['emailDomain']); - if (result.emailDomain) { - emailInput.value = result.emailDomain as string; - await updatePreview(); - updateExamples(); - } - } catch (error) { - console.error('Failed to load settings:', error); - showStatus('Failed to load settings', 'error'); - } - } - - /** - * Validate and save settings to Chrome sync storage. - * @param e - The form submit event - */ - async function saveSettings(e: Event): Promise { - e.preventDefault(); - - const domain = emailInput.value.trim(); - - // Validate domain - if (!domain) { - showStatus('Please enter a domain', 'error'); - return; - } - - // Remove @ if user included it - const cleanDomain = domain.replace(/^@/, ''); - - // Improved domain validation - allows single-char labels - const domainRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z]{2,})+$/; - if (!domainRegex.test(cleanDomain)) { - showStatus('Please enter a valid domain (e.g., example.com)', 'error'); - return; - } - - try { - await chrome.storage.sync.set({ emailDomain: cleanDomain }); - emailInput.value = cleanDomain; - showStatus('Settings saved successfully!', 'success'); - await updatePreview(); - updateExamples(); - } catch (error) { - showStatus( - `Error saving settings: ${error instanceof Error ? error.message : 'Unknown error'}`, - 'error', - ); - } - } - - /** - * Clear saved settings from Chrome sync storage after user confirmation. - */ - async function clearSettings(): Promise { - if (confirm('Are you sure you want to clear your email domain?')) { - try { - await chrome.storage.sync.remove(['emailDomain']); - emailInput.value = ''; - showStatus('Settings cleared', 'success'); - await updatePreview(); - updateExamples(); - } catch (error) { - showStatus( - `Error clearing settings: ${error instanceof Error ? error.message : 'Unknown error'}`, - 'error', - ); - } - } - } - - /** - * Display a status message to the user that auto-hides after 3 seconds. - * @param message - The message to display - * @param type - The message type ('success' or 'error') - */ - function showStatus(message: string, type: 'success' | 'error'): void { - statusEl.textContent = message; - statusEl.className = `status ${type}`; - - // Hide status after 3 seconds - setTimeout(() => { - statusEl.className = 'status'; - }, 3000); - } - - /** - * Update the email preview based on the current tab's domain and user's configured domain. - */ - async function updatePreview(): Promise { - const domain = emailInput.value.trim(); - if (!domain) { - previewEl.textContent = 'No domain set'; - return; - } - - try { - const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); - if (tabs[0]?.url) { - try { - const url = new URL(tabs[0].url); - const currentDomain = extractMainDomain - ? extractMainDomain(url.hostname) - : url.hostname.replace(/^www\./, ''); - previewEl.textContent = `${currentDomain}@${domain}`; - } catch { - previewEl.textContent = `example.com@${domain}`; - } - } else { - previewEl.textContent = `example.com@${domain}`; - } - } catch { - previewEl.textContent = `example.com@${domain}`; - } - } - - /** - * Update the example email displays with the current domain setting. - */ - function updateExamples(): void { - const domain = emailInput.value.trim() || 'yourdomain.com'; - // Show examples with main domains only (no subdomains) - example1.textContent = `google.com@${domain}`; - example2.textContent = `github.com@${domain}`; - } - - /** - * Debounced version of preview update to avoid excessive updates during typing. - */ - const debouncedUpdatePreview = debounce - ? debounce(async () => { - await updatePreview(); - updateExamples(); - }, 300) - : async () => { - await updatePreview(); - updateExamples(); - }; - - // Event listeners - formEl.addEventListener('submit', saveSettings); - clearBtn.addEventListener('click', clearSettings); - emailInput.addEventListener('input', debouncedUpdatePreview); - - // Initialize - await loadSettings(); -}); diff --git a/src/types/index.ts b/src/types/index.ts index c682153..e603df2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,49 @@ +import type { ProviderStatus } from '../email/providers.js'; + +export type EmailMode = 'catchAll' | 'plusAddressing'; + +export interface MxRecord { + exchange: string; + priority: number; +} + +export type DetectedProvider = + | 'google-workspace' + | 'microsoft-365' + | 'fastmail' + | 'protonmail' + | 'zoho' + | 'icloud' + | 'mimecast' + | 'barracuda'; + +export interface ProviderInfo { + name: string; + plusAddressingSupported: boolean; +} + +export interface MxLookupResult { + domain: string; + provider: DetectedProvider | null; + mxRecords: MxRecord[]; + status: ProviderStatus; + timestamp: number; + ttl: number; +} + +/** + * A single history entry representing one email generation event. + */ +export interface EmailHistoryEntry { + id: string; + email: string; + domain: string; + pageUrl: string; + pageTitle: string; + createdAt: string; + mode: EmailMode; +} + /** * Interface for shared utility functions exposed globally for use across extension contexts. */ @@ -26,6 +72,24 @@ export interface FillEmailResponse { error?: string; } +/** + * Message from popup requesting email generation and fill. + */ +export interface GenerateAndFillRequest { + action: 'generateAndFill'; +} + +/** + * Response from background to popup after generating and filling email. + */ +export interface GenerateAndFillResponse { + success: boolean; + email?: string; + message?: string; + error?: string; + needsConfig?: boolean; +} + declare global { // eslint-disable-next-line no-var var CleanAutofillUtils: CleanAutofillUtils; diff --git a/src/ui/history.test.ts b/src/ui/history.test.ts new file mode 100644 index 0000000..81648e3 --- /dev/null +++ b/src/ui/history.test.ts @@ -0,0 +1,178 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test'; + +import type { EmailHistoryEntry } from '../types'; + +// Mock chrome.storage.local +let store: Record = {}; +const mockChrome = { + storage: { + local: { + get: mock(async (key: string) => { + const val = store[key]; + return val !== undefined ? { [key]: val } : {}; + }), + set: mock(async (items: Record) => { + Object.assign(store, items); + }), + remove: mock(async (key: string) => { + delete store[key]; + }), + }, + }, +}; +(globalThis as Record).chrome = mockChrome; + +// Mock crypto.randomUUID +let uuidCounter = 0; +(globalThis as Record).crypto = { + randomUUID: () => `test-uuid-${++uuidCounter}`, +}; + +// Import after mocks are in place +const { addEntry, getHistory, deleteEntry, clearHistory } = await import('../ui/history.js'); + +function makeEntry( + overrides?: Partial>, +): Omit { + return { + email: 'amazon.com@mg.de', + domain: 'amazon.com', + pageUrl: 'https://amazon.com/signup', + pageTitle: 'Amazon - Sign Up', + createdAt: '2026-04-07T10:00:00.000Z', + mode: 'catchAll', + ...overrides, + }; +} + +describe('history module', () => { + beforeEach(() => { + store = {}; + uuidCounter = 0; + }); + + describe('addEntry', () => { + test('adds entry with generated id', async () => { + const entry = await addEntry(makeEntry()); + expect(entry.id).toBe('test-uuid-1'); + expect(entry.email).toBe('amazon.com@mg.de'); + }); + + test('prepends new entries (newest first)', async () => { + await addEntry(makeEntry({ domain: 'first.com' })); + await addEntry(makeEntry({ domain: 'second.com' })); + + const entries = store.emailHistory as EmailHistoryEntry[]; + expect(entries[0].domain).toBe('second.com'); + expect(entries[1].domain).toBe('first.com'); + }); + + test('enforces max limit of 10,000', async () => { + // Pre-fill with 10,000 entries + const existing = Array.from({ length: 10_000 }, (_, i) => ({ + id: `old-${i}`, + email: `site${i}.com@mg.de`, + domain: `site${i}.com`, + pageUrl: `https://site${i}.com`, + pageTitle: `Site ${i}`, + createdAt: '2026-01-01T00:00:00.000Z', + mode: 'catchAll' as const, + })); + store.emailHistory = existing; + + await addEntry(makeEntry({ domain: 'new.com' })); + + const entries = store.emailHistory as EmailHistoryEntry[]; + expect(entries.length).toBe(10_000); + expect(entries[0].domain).toBe('new.com'); + }); + }); + + describe('getHistory', () => { + test('returns all entries when no query', async () => { + await addEntry(makeEntry({ domain: 'a.com' })); + await addEntry(makeEntry({ domain: 'b.com' })); + + const entries = await getHistory(); + expect(entries.length).toBe(2); + }); + + test('filters by search term (domain)', async () => { + await addEntry(makeEntry({ domain: 'amazon.com', email: 'amazon.com@mg.de' })); + await addEntry(makeEntry({ domain: 'google.com', email: 'google.com@mg.de' })); + + const entries = await getHistory({ search: 'amazon' }); + expect(entries.length).toBe(1); + expect(entries[0].domain).toBe('amazon.com'); + }); + + test('filters by search term (email)', async () => { + await addEntry(makeEntry({ email: 'user+amazon@gmail.com', domain: 'amazon.com' })); + await addEntry(makeEntry({ email: 'user+netflix@gmail.com', domain: 'netflix.com' })); + + const entries = await getHistory({ search: 'netflix' }); + expect(entries.length).toBe(1); + expect(entries[0].email).toBe('user+netflix@gmail.com'); + }); + + test('search is case-insensitive', async () => { + await addEntry(makeEntry({ domain: 'Amazon.com' })); + + const entries = await getHistory({ search: 'AMAZON' }); + expect(entries.length).toBe(1); + }); + + test('supports limit and offset', async () => { + await addEntry(makeEntry({ domain: 'a.com' })); + await addEntry(makeEntry({ domain: 'b.com' })); + await addEntry(makeEntry({ domain: 'c.com' })); + + const page = await getHistory({ limit: 1, offset: 1 }); + expect(page.length).toBe(1); + expect(page[0].domain).toBe('b.com'); + }); + + test('returns empty array when no history', async () => { + const entries = await getHistory(); + expect(entries).toEqual([]); + }); + }); + + describe('deleteEntry', () => { + test('removes entry by id', async () => { + await addEntry(makeEntry({ domain: 'keep.com' })); + await addEntry(makeEntry({ domain: 'remove.com' })); + + const entries = store.emailHistory as EmailHistoryEntry[]; + const removeId = entries.find((e) => e.domain === 'remove.com')?.id; + + await deleteEntry(removeId); + + const remaining = store.emailHistory as EmailHistoryEntry[]; + expect(remaining.length).toBe(1); + expect(remaining[0].domain).toBe('keep.com'); + }); + + test('no-op if id not found', async () => { + await addEntry(makeEntry()); + + await deleteEntry('nonexistent'); + + const entries = store.emailHistory as EmailHistoryEntry[]; + expect(entries.length).toBe(1); + }); + }); + + describe('clearHistory', () => { + test('removes all history', async () => { + await addEntry(makeEntry()); + await addEntry(makeEntry()); + + await clearHistory(); + + expect(store.emailHistory).toBeUndefined(); + const entries = await getHistory(); + expect(entries).toEqual([]); + }); + }); +}); diff --git a/src/ui/history.ts b/src/ui/history.ts new file mode 100644 index 0000000..1a8b8c7 --- /dev/null +++ b/src/ui/history.ts @@ -0,0 +1,54 @@ +import type { EmailHistoryEntry } from '../types'; + +const STORAGE_KEY = 'emailHistory'; +const MAX_ENTRIES = 10_000; + +export async function addEntry(entry: Omit): Promise { + const fullEntry: EmailHistoryEntry = { + ...entry, + id: crypto.randomUUID(), + }; + + const { emailHistory = [] } = await chrome.storage.local.get(STORAGE_KEY); + const history = [fullEntry, ...(emailHistory as EmailHistoryEntry[])]; + + // Enforce max limit + if (history.length > MAX_ENTRIES) { + history.length = MAX_ENTRIES; + } + + await chrome.storage.local.set({ [STORAGE_KEY]: history }); + return fullEntry; +} + +export interface HistoryQuery { + search?: string; + limit?: number; + offset?: number; +} + +export async function getHistory(query?: HistoryQuery): Promise { + const { emailHistory = [] } = await chrome.storage.local.get(STORAGE_KEY); + let entries = emailHistory as EmailHistoryEntry[]; + + if (query?.search) { + const term = query.search.toLowerCase(); + entries = entries.filter( + (e) => e.domain.toLowerCase().includes(term) || e.email.toLowerCase().includes(term), + ); + } + + const offset = query?.offset ?? 0; + const limit = query?.limit ?? entries.length; + return entries.slice(offset, offset + limit); +} + +export async function deleteEntry(id: string): Promise { + const { emailHistory = [] } = await chrome.storage.local.get(STORAGE_KEY); + const history = (emailHistory as EmailHistoryEntry[]).filter((e) => e.id !== id); + await chrome.storage.local.set({ [STORAGE_KEY]: history }); +} + +export async function clearHistory(): Promise { + await chrome.storage.local.remove(STORAGE_KEY); +} diff --git a/src/ui/message-tokens.css b/src/ui/message-tokens.css new file mode 100644 index 0000000..ee1073b --- /dev/null +++ b/src/ui/message-tokens.css @@ -0,0 +1,9 @@ +:root { + --ui-warning-surface: #fffbeb; + --ui-warning-bg: #fef3c7; + --ui-warning-border: #ca8a04; + --ui-warning-text: #713f12; + --ui-error-bg: #ffebee; + --ui-error-border: #ef9a9a; + --ui-error-text: #c62828; +} diff --git a/src/ui/options-preview.test.ts b/src/ui/options-preview.test.ts new file mode 100644 index 0000000..fea7b84 --- /dev/null +++ b/src/ui/options-preview.test.ts @@ -0,0 +1,104 @@ +import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'; + +type PreviewStorageArea = { + get: ( + keys?: string | string[] | Record | null, + ) => Promise>; + set: (items: Record) => Promise; + remove: (keys: string | string[]) => Promise; +}; + +type PreviewChrome = { + storage?: { + sync?: PreviewStorageArea; + local?: PreviewStorageArea; + }; + identity?: { + getProfileUserInfo?: (_details?: { + accountStatus?: string; + }) => Promise<{ email: string; id: string }>; + }; +}; + +type PreviewApi = { + installPreviewChrome: () => void; +}; + +function getPreviewApi(): PreviewApi { + const api = (globalThis as typeof globalThis & { CleanAutofillPreview?: PreviewApi }) + .CleanAutofillPreview; + + if (!api) { + throw new Error('Preview API not initialized'); + } + + return api; +} + +beforeAll(async () => { + await import('./options-preview.js'); +}); + +describe('options preview shim', () => { + beforeEach(() => { + delete (globalThis as typeof globalThis & { chrome?: PreviewChrome }).chrome; + }); + + test('installs storage and identity shims when extension APIs are missing', async () => { + getPreviewApi().installPreviewChrome(); + + const chromeApi = (globalThis as typeof globalThis & { chrome?: PreviewChrome }).chrome; + if ( + !chromeApi?.storage?.sync || + !chromeApi.storage.local || + !chromeApi.identity?.getProfileUserInfo + ) { + throw new Error('Preview chrome API missing'); + } + + await chromeApi.storage.sync.set({ emailMode: 'catchAll' }); + await chromeApi.storage.local.set({ emailHistory: ['entry'] }); + + expect(await chromeApi.storage.sync.get(['emailMode'])).toEqual({ emailMode: 'catchAll' }); + expect(await chromeApi.storage.local.get('emailHistory')).toEqual({ emailHistory: ['entry'] }); + await expect(chromeApi.identity.getProfileUserInfo({ accountStatus: 'ANY' })).resolves.toEqual({ + email: '', + id: '', + }); + }); + + test('does not replace existing chrome APIs', async () => { + const syncGet = mock(async () => ({ emailMode: 'plusAddressing' })); + const profileGet = mock(async () => ({ email: 'user@example.com', id: '123' })); + + (globalThis as typeof globalThis & { chrome?: PreviewChrome }).chrome = { + storage: { + sync: { + get: syncGet, + set: mock(async () => {}), + remove: mock(async () => {}), + }, + local: createLocalArea(), + }, + identity: { + getProfileUserInfo: profileGet, + }, + }; + + getPreviewApi().installPreviewChrome(); + + const chromeApi = (globalThis as typeof globalThis & { chrome?: PreviewChrome }).chrome; + expect(chromeApi?.storage?.sync?.get).toBe(syncGet); + expect(chromeApi?.identity?.getProfileUserInfo).toBe(profileGet); + }); +}); + +function createLocalArea(): PreviewStorageArea { + return { + async get(): Promise> { + return {}; + }, + async set(): Promise {}, + async remove(): Promise {}, + }; +} diff --git a/src/ui/options-preview.ts b/src/ui/options-preview.ts new file mode 100644 index 0000000..75eff31 --- /dev/null +++ b/src/ui/options-preview.ts @@ -0,0 +1,106 @@ +type PreviewProfile = { + email: string; + id: string; +}; + +type StorageKeys = string | string[] | Record | null | undefined; + +type PreviewStorageArea = { + get: (keys?: StorageKeys) => Promise>; + set: (items: Record) => Promise; + remove: (keys: string | string[]) => Promise; +}; + +type PreviewChrome = { + storage?: { + sync?: PreviewStorageArea; + local?: PreviewStorageArea; + }; + identity?: { + getProfileUserInfo?: (_details?: { accountStatus?: string }) => Promise; + }; +}; + +type PreviewApi = { + installPreviewChrome: () => void; +}; + +const previewSyncStore: Record = {}; +const previewLocalStore: Record = {}; + +function createStorageArea(store: Record): PreviewStorageArea { + return { + async get(keys?: StorageKeys): Promise> { + if (keys == null) { + return { ...store }; + } + + if (typeof keys === 'string') { + return store[keys] !== undefined ? { [keys]: store[keys] } : {}; + } + + if (Array.isArray(keys)) { + const result: Record = {}; + for (const key of keys) { + if (store[key] !== undefined) { + result[key] = store[key]; + } + } + return result; + } + + const result = { ...keys }; + for (const key of Object.keys(keys)) { + if (store[key] !== undefined) { + result[key] = store[key]; + } + } + return result; + }, + + async set(items: Record): Promise { + Object.assign(store, items); + }, + + async remove(keys: string | string[]): Promise { + const keysToRemove = Array.isArray(keys) ? keys : [keys]; + for (const key of keysToRemove) { + delete store[key]; + } + }, + }; +} + +function installPreviewChrome(): void { + const globalScope = globalThis as unknown as { + chrome?: unknown; + CleanAutofillPreview?: PreviewApi; + }; + + const chromeApi = (globalScope.chrome as PreviewChrome | undefined) ?? {}; + + chromeApi.storage ??= {}; + chromeApi.storage.sync ??= createStorageArea(previewSyncStore); + chromeApi.storage.local ??= createStorageArea(previewLocalStore); + + chromeApi.identity ??= {}; + chromeApi.identity.getProfileUserInfo ??= async () => ({ email: '', id: '' }); + + globalScope.chrome = chromeApi; +} + +const previewApi: PreviewApi = { installPreviewChrome }; +(globalThis as typeof globalThis & { CleanAutofillPreview?: PreviewApi }).CleanAutofillPreview = + previewApi; + +const existingChrome = (globalThis as unknown as { chrome?: unknown }).chrome as + | PreviewChrome + | undefined; +const hasExtensionApis = + existingChrome?.storage?.sync != null && + existingChrome.storage.local != null && + existingChrome.identity?.getProfileUserInfo != null; + +if (!hasExtensionApis) { + installPreviewChrome(); +} diff --git a/src/ui/options.css b/src/ui/options.css new file mode 100644 index 0000000..cc2aa84 --- /dev/null +++ b/src/ui/options.css @@ -0,0 +1,995 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background-color: #f5f5f5; + min-height: 100vh; + display: flex; +} + +/* ── Sidebar ── */ +.sidebar { + width: 220px; + background-color: #fff; + border-right: 1px solid #e0e0e0; + padding: 24px 0; + position: fixed; + top: 0; + left: 0; + bottom: 0; + display: flex; + flex-direction: column; +} + +.sidebar-header { + display: flex; + align-items: center; + gap: 10px; + padding: 0 20px 24px; + border-bottom: 1px solid #e0e0e0; + margin-bottom: 8px; +} + +.sidebar-header .icon { + width: 28px; + height: 28px; + background-color: #4CAF50; + border-radius: 6px; + flex-shrink: 0; +} + +.sidebar-header span { + font-size: 16px; + font-weight: 600; + color: #333; +} + +.nav-item { + display: flex; + align-items: center; + padding: 10px 20px; + font-size: 14px; + font-weight: 500; + color: #555; + text-decoration: none; + cursor: pointer; + transition: background-color 0.15s, color 0.15s; + border-left: 3px solid transparent; +} + +.nav-item:hover { + background-color: #f5f5f5; + color: #333; +} + +.nav-item.active { + background-color: #e8f5e9; + color: #2e7d32; + border-left-color: #4CAF50; + font-weight: 600; +} + +/* ── Content Area ── */ +.content { + margin-left: 220px; + flex: 1; + padding: 32px 40px; + max-width: 780px; +} + +.page { + display: none; +} + +.page.active { + display: block; +} + +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 32px; +} + +.page-header-text { + flex: 1; +} + +.page-title { + font-size: 24px; + font-weight: 600; + color: #333; + margin-bottom: 8px; +} + +.page-subtitle { + color: #666; + font-size: 15px; + margin-bottom: 32px; +} + +.page-header .page-subtitle { + margin-bottom: 0; +} + +.save-state-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-width: 128px; + padding: 12px 24px; + border-radius: 8px; + font-size: 16px; + font-weight: 500; + line-height: 1; + cursor: default; + pointer-events: none; + user-select: none; + white-space: nowrap; + flex-shrink: 0; + align-self: flex-start; + border: 1px solid transparent; +} + +.save-state-button::before { + content: ''; + display: none; + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid currentColor; + border-right-color: transparent; +} + +.save-state-button.is-editing { + background-color: #f3f4f6; + border-color: #d1d5db; + color: #4b5563; +} + +.save-state-button.is-saving, +.save-state-button.is-saved { + background-color: #4CAF50; + color: white; +} + +.save-state-button.is-saving::before { + display: inline-block; + animation: save-state-spin 0.8s linear infinite; +} + +.save-state-button.is-error { + background-color: var(--ui-error-bg); + border-color: var(--ui-error-border); + color: var(--ui-error-text); +} + +@keyframes save-state-spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +/* ── Home Page ── */ +.how-it-works { + display: flex; + flex-direction: column; + gap: 20px; + margin-bottom: 40px; +} + +.step { + display: flex; + gap: 16px; + align-items: flex-start; +} + +.step-number { + width: 32px; + height: 32px; + background-color: #4CAF50; + color: white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 14px; + flex-shrink: 0; +} + +.step-text { + padding-top: 4px; +} + +.step-text strong { + display: block; + font-size: 15px; + color: #333; + margin-bottom: 2px; +} + +.step-text span { + font-size: 14px; + color: #666; +} + +/* ── Settings Page ── */ +.settings-section { + margin-bottom: 32px; +} + +.settings-section h2 { + font-size: 18px; + font-weight: 600; + color: #333; + margin: 0 0 16px 0; +} + +.detected-email { + font-size: 14px; + font-weight: 500; + color: #333; +} + +.detection-box.detected .detected-email { + color: #4CAF50; + cursor: pointer; +} + +.detection-box.detected .detected-email:hover { + text-decoration: underline; +} + +.form-group { + margin-bottom: 8px; +} + +label { + display: block; + font-weight: 500; + margin-bottom: 8px; + color: #333; + font-size: 15px; +} + +input[type="text"] { + width: 100%; + padding: 12px; + font-size: 16px; + border: 2px solid #e0e0e0; + border-radius: 8px; + outline: none; + transition: border-color 0.2s; + box-sizing: border-box; +} + +input[type="text"]:focus { + border-color: #4CAF50; +} + +.help-text { + font-size: 13px; + color: #888; + margin-top: 8px; +} + +/* ── Detection Boxes ── */ +.detection-box { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + padding: 10px 14px; + border-left: 3px solid #e0e0e0; + border-radius: 0 6px 6px 0; + background-color: #f8f9fa; + transition: border-color 0.2s, background-color 0.2s; +} + +.detection-box.detected { + border-left-color: #4CAF50; + background-color: #f1f8f1; +} + +.detection-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #999; +} + +.detection-box.detected .detection-label { + color: #66bb6a; +} + +.detection-content { + display: flex; + align-items: center; + gap: 8px; +} + +.detection-placeholder { + font-size: 13px; + color: #aaa; +} + +/* Provider Detection */ +.provider-logo { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.provider-detected { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 500; + color: #4CAF50; + cursor: pointer; +} + +.provider-detected:hover .provider-text { + text-decoration: underline; +} + +.provider-detected.loading { + color: #888; + font-weight: 400; + cursor: default; +} + +.provider-detected.loading:hover .provider-text { + text-decoration: none; +} + +/* Mode Table */ +.mode-table { + display: flex; + gap: 16px; +} + +.mode-column { + flex: 1; + border: 2px solid #e0e0e0; + border-radius: 12px; + cursor: pointer; + transition: all 0.2s; + overflow: hidden; + opacity: 0.55; +} + +.mode-column:hover { + opacity: 0.8; + border-color: #ccc; +} + +.mode-column.selected { + border-color: #4CAF50; + opacity: 1; + box-shadow: 0 2px 8px rgba(76, 175, 80, 0.15); +} + +.mode-column.disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.mode-column.disabled:hover { + opacity: 0.4; + border-color: #e0e0e0; +} + +.messages-area { + margin-bottom: 24px; +} + +.mode-feedback { + display: flex; + align-items: center; + padding: 10px 14px; + margin-top: 12px; + min-height: 43px; + border-left: 3px solid #e0e0e0; + border-radius: 0 6px 6px 0; + background-color: #f8f9fa; + font-size: 13px; + color: #999; + transition: border-color 0.2s, background-color 0.2s, color 0.2s; +} + +.mode-feedback.is-empty { + visibility: hidden; +} + +.mode-feedback.feedback-warning { + border-left-color: var(--ui-warning-border); + background-color: var(--ui-warning-bg); + color: var(--ui-warning-text); +} + +.mode-header { + font-size: 17px; + font-weight: 600; + text-align: center; + padding: 16px; + color: #333; + background-color: #fafafa; + border-bottom: 1px solid #e0e0e0; +} + +.mode-column.selected .mode-header { + background-color: #e8f5e9; + color: #2e7d32; +} + +.mode-row { + padding: 12px 16px; + border-top: 1px solid #f0f0f0; +} + +.mode-row:first-of-type { + border-top: none; +} + +.row-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #999; + margin-bottom: 4px; +} + +.row-value { + font-size: 14px; + color: #555; +} + +.row-value code { + font-family: monospace; + font-size: 13px; + background-color: #f0f0f0; + padding: 2px 6px; + border-radius: 4px; + word-break: break-all; +} + +.mode-column.selected .row-value code { + background-color: #e8f5e9; + color: #2e7d32; +} + +/* ── Requirement Indicators ── */ +.req-checks { + display: flex; + flex-direction: column; + gap: 6px; +} + +.req-check { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.req-label { + font-size: 13px; + color: #555; + flex-shrink: 0; +} + +.req-value-group { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.req-value { + font-size: 13px; + color: #333; + font-weight: 500; +} + +.req-indicator { + display: none; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + font-size: 11px; + font-weight: 700; + line-height: 1; + flex-shrink: 0; +} + +.req-indicator.req-supported, +.req-indicator.req-possible, +.req-indicator.req-incompatible { + display: inline-flex; +} + +.req-indicator.req-supported { + background-color: #e8f5e9; + color: #2e7d32; + border: 1.5px solid #4CAF50; +} + +.req-indicator.req-supported::before { + content: "\2713"; +} + +.req-indicator.req-possible { + background-color: #e3f2fd; + color: #1565c0; + border: 1.5px solid #42a5f5; +} + +.req-indicator.req-possible::before { + content: "~"; +} + +.req-indicator.req-incompatible { + background-color: var(--ui-error-bg); + color: var(--ui-error-text); + border: 1.5px solid var(--ui-error-border); +} + +.req-indicator.req-incompatible::before { + content: "\2717"; +} + +/* ── Info Icon ── */ +.req-info-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + border-radius: 50%; + font-size: 9px; + font-weight: 700; + font-style: italic; + line-height: 1; + background-color: #f0f0f0; + color: #333; + border: 1px solid #ccc; + cursor: pointer; + margin-left: 4px; + vertical-align: middle; +} + +.req-info-icon:hover { + background-color: #e0e0e0; +} + +/* ── Catch-All Instructions ── */ +.catch-all-instructions { + margin-top: 16px; + padding: 16px 20px; + background-color: #f8f9fa; + border-radius: 8px; + border: 1px solid #e0e0e0; +} + +.catch-all-instructions ol { + margin: 0; + padding-left: 20px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.catch-all-instructions ol li { + font-size: 13px; + color: #555; + line-height: 1.5; +} + +.catch-all-links { + margin-top: 12px; +} + +.catch-all-links a { + font-size: 13px; + color: #4CAF50; + text-decoration: none; + font-weight: 500; +} + +.catch-all-links a:hover { + text-decoration: underline; +} + +.catch-all-note { + margin-top: 10px; + font-size: 12px; + color: #888; + font-style: italic; +} + +.catch-all-note:empty { + display: none; +} + +.catch-all-instructions.warning { + background-color: var(--ui-warning-surface); +} + +.catch-all-instructions.warning .catch-all-note { + color: var(--ui-warning-text); + font-style: normal; + font-weight: 400; + background-color: var(--ui-warning-bg); + padding: 10px 14px; + border-radius: 6px; + border-left: 3px solid var(--ui-warning-border); + margin-top: 0; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* Examples */ +.examples-list { + display: flex; + flex-direction: column; + gap: 0; + border: 1px solid #e0e0e0; + border-radius: 8px; + overflow: hidden; +} + +.example-row { + display: flex; + align-items: center; + padding: 10px 48px; + border-bottom: 1px solid #f0f0f0; + gap: 12px; +} + +.example-row:last-child { + border-bottom: none; +} + +.example-row:nth-child(odd) { + background-color: #fafafa; +} + +.example-site { + font-size: 14px; + color: #666; + min-width: 120px; + font-weight: 500; +} + +.example-arrow { + color: #ccc; + font-size: 14px; + flex-shrink: 0; +} + +.example-email { + font-family: monospace; + font-size: 13px; + background-color: #e8f5e9; + padding: 3px 8px; + border-radius: 4px; + color: #2e7d32; + word-break: break-all; + margin-left: auto; +} + +/* Buttons */ + +.button-danger { + padding: 8px 16px; + font-size: 14px; + font-weight: 500; + background-color: #fff; + color: var(--ui-error-text); + border: 1px solid #e0e0e0; + border-radius: 6px; + cursor: pointer; + transition: background-color 0.15s; +} + +.button-danger:hover { + background-color: var(--ui-error-bg); + border-color: var(--ui-error-border); +} + +/* Status */ +.status { + margin-top: 16px; + padding: 12px; + border-radius: 6px; + font-size: 14px; + text-align: center; + display: none; +} + +.status.success { + background-color: #e8f5e9; + color: #2e7d32; + display: block; +} + +.status.error { + background-color: var(--ui-error-bg); + color: var(--ui-error-text); + display: block; +} + +/* ── History Page ── */ +.history-toolbar { + display: flex; + gap: 12px; + align-items: center; + margin-bottom: 20px; +} + +.history-search { + flex: 1; + padding: 10px 14px; + font-size: 14px; + border: 2px solid #e0e0e0; + border-radius: 8px; + outline: none; + transition: border-color 0.2s; +} + +.history-search:focus { + border-color: #4CAF50; +} + +.history-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} + +.history-table th { + text-align: left; + padding: 10px 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #999; + border-bottom: 2px solid #e0e0e0; +} + +.history-table td { + padding: 10px 12px; + border-bottom: 1px solid #f0f0f0; + color: #333; + vertical-align: middle; +} + +.history-table tr:hover td { + background-color: #fafafa; +} + +.history-table .col-domain { + font-weight: 500; +} + +.history-table .col-email { + font-family: monospace; + font-size: 13px; + color: #2e7d32; +} + +.history-table .col-date { + color: #888; + font-size: 13px; + white-space: nowrap; +} + +.history-table .col-actions { + text-align: right; + white-space: nowrap; +} + +.history-table .col-actions button { + background: none; + border: none; + cursor: pointer; + font-size: 13px; + padding: 4px 8px; + border-radius: 4px; + transition: background-color 0.15s; +} + +.btn-copy { + color: #4CAF50; +} + +.btn-copy:hover { + background-color: #e8f5e9; +} + +.btn-delete { + color: #999; +} + +.btn-delete:hover { + color: var(--ui-error-text); + background-color: var(--ui-error-bg); +} + +.history-empty { + text-align: center; + padding: 48px 20px; + color: #999; + font-size: 15px; +} + +.history-empty strong { + display: block; + font-size: 16px; + color: #666; + margin-bottom: 8px; +} + +/* ── Help Page ── */ +.help-intro { + font-size: 14px; + color: #666; + line-height: 1.6; + margin-bottom: 24px; +} + +.help-provider-card { + margin-bottom: 12px; + border-radius: 10px; + border: 1px solid #e0e0e0; + overflow: hidden; + transition: box-shadow 0.2s; +} + +.help-provider-card:hover { + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); +} + +.help-provider-card .catch-all-instructions { + margin-top: 0; + border: none; + border-top: 1px solid #e0e0e0; + border-radius: 0; + padding: 16px 20px 20px; +} + +.help-provider-header { + font-size: 14px; + font-weight: 600; + color: #333; + padding: 12px 20px; + background-color: #fff; + border: none; + border-radius: 0; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + user-select: none; +} + +.help-provider-header:hover { + background-color: #fafafa; +} + +.header-left { + display: flex; + align-items: center; + gap: 8px; +} + +.header-chevron { + display: inline-flex; + font-size: 12px; + color: #bbb; + transition: transform 0.2s; + flex-shrink: 0; +} + +.header-chevron::before { + content: "\25B8"; +} + +.help-provider-card:not(.collapsed) .header-chevron { + transform: rotate(90deg); +} + +.help-provider-header.detected { + background-color: #f1f8f1; + color: #2e7d32; +} + +.help-provider-card:has(.help-provider-header.detected) { + border-color: #a5d6a7; +} + +.help-provider-header .detected-badge { + font-size: 10px; + font-weight: 600; + color: #fff; + background-color: #4CAF50; + padding: 2px 8px; + border-radius: 10px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.help-provider-card.collapsed .catch-all-instructions { + display: none; +} + +.help-provider-card .catch-all-instructions ol { + counter-reset: step; + padding-left: 0; + list-style: none; +} + +.help-provider-card .catch-all-instructions ol li { + counter-increment: step; + padding-left: 28px; + position: relative; +} + +.help-provider-card .catch-all-instructions ol li::before { + content: counter(step); + position: absolute; + left: 0; + top: 1px; + width: 18px; + height: 18px; + border-radius: 50%; + background-color: #f0f0f0; + color: #666; + font-size: 11px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; +} + +.help-provider-card .catch-all-links { + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid #eee; +} + +.help-provider-card .catch-all-note { + margin-top: 8px; +} diff --git a/src/ui/options.html b/src/ui/options.html new file mode 100644 index 0000000..341bbf0 --- /dev/null +++ b/src/ui/options.html @@ -0,0 +1,317 @@ + + + + + Clean-Autofill + + + + + + + + +
    + + +
    +

    Clean-Autofill

    +

    Generate unique email addresses for every website you sign up on.

    + +

    How It Works

    +
    +
    +
    1
    +
    + Configure your email + Enter your email address or custom domain in Settings. +
    +
    +
    +
    2
    +
    + Click the extension icon + On any website, click the Clean-Autofill icon in your toolbar. +
    +
    +
    +
    3
    +
    + Email is generated and filled + A unique email based on the site's domain is created and filled into the email field. +
    +
    +
    + +
    +

    Examples

    +
    +
    + wikipedia.org + + +
    +
    + amazon.com + + +
    +
    + zalando.de + + +
    +
    + ui.com + + +
    +
    + cloudflare.com + + +
    +
    + claude.ai + + +
    +
    + netflix.com + + +
    +
    + github.com + + +
    +
    + spotify.com + + +
    +
    + linkedin.com + + +
    +
    + stripe.com + + +
    +
    + notion.so + + +
    +
    +
    +
    + + +
    + + +
    + +
    +

    1. Provide Email Address

    +
    +
    Chrome Profile
    +
    + Not detected +
    +
    +
    + +

    Enter a full email for Plus-Addressing Mode or just a domain for Catch-All Prefix Mode.

    +
    +
    + + +
    +

    2. Select Mode

    +
    +
    Email Provider Detection
    +
    + + + +
    +
    +
    +
    +
    Plus-Addressing
    +
    +
    Format
    +
    name+example.com@gmail.com
    +
    +
    +
    Requirements
    +
    +
    + Email Provider + + -- + + +
    +
    + Plus-Addressing + + -- + + +
    +
    +
    +
    +
    +
    Catch-All Prefix
    +
    +
    Format
    +
    example.com@yourdomain.com
    +
    +
    +
    Requirements
    +
    +
    + Custom Domain + + -- + + +
    +
    + Catch-All + + -- + + +
    +
    +
    +
    +
    + + + +
    + + +
    +

    3. Examples

    +
    +
    + wikipedia.org + + +
    +
    + amazon.com + + +
    +
    + zalando.de + + +
    +
    + ui.com + + +
    +
    +
    + +
    + +
    + +
    +
    + + +
    +

    History

    +

    Emails generated by Clean-Autofill

    + +
    + + +
    + +
    + + + + + + + + + + +
    DomainEmailDate
    +
    + + +
    + + +
    +

    Help

    +

    Setup guides and documentation

    + +
    +

    Setting Up Catch-All

    +

    Catch-All mode requires your custom domain to accept emails sent to any address. Below are instructions for each supported email provider.

    +
    +
    +
    + +
    + + + + + + diff --git a/src/ui/options.test.ts b/src/ui/options.test.ts new file mode 100644 index 0000000..6960a69 --- /dev/null +++ b/src/ui/options.test.ts @@ -0,0 +1,933 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'; +import { domainRegex, extractDomainFromEmail, getProviderStatus } from '../email/providers.js'; + +let createSettingsDraft: typeof import('./options.js').createSettingsDraft; +let areSettingsDraftsEqual: typeof import('./options.js').areSettingsDraftsEqual; +let getSaveIndicatorLabel: typeof import('./options.js').getSaveIndicatorLabel; + +// Load utils first +beforeAll(async () => { + await import('../email/utils.js'); + const optionsModule = await import('./options.js'); + createSettingsDraft = optionsModule.createSettingsDraft; + areSettingsDraftsEqual = optionsModule.areSettingsDraftsEqual; + getSaveIndicatorLabel = optionsModule.getSaveIndicatorLabel; +}); + +// Mock chrome API +const mockStorage: Record = {}; +const mockLocalStorage: Record = {}; +const mockChrome = { + storage: { + sync: { + get: mock(async (keys: string[]) => { + const result: Record = {}; + for (const key of keys) { + if (mockStorage[key] !== undefined) { + result[key] = mockStorage[key]; + } + } + return result; + }), + set: mock(async (items: Record) => { + Object.assign(mockStorage, items); + }), + remove: mock(async (keys: string[]) => { + for (const key of keys) { + delete mockStorage[key]; + } + }), + }, + local: { + get: mock(async (key: string) => { + const value = mockLocalStorage[key]; + return value !== undefined ? { [key]: value } : {}; + }), + set: mock(async (items: Record) => { + Object.assign(mockLocalStorage, items); + }), + remove: mock(async (key: string) => { + delete mockLocalStorage[key]; + }), + }, + }, + tabs: { + query: mock(async () => [{ url: 'https://example.com/page' }]), + }, + identity: { + getProfileUserInfo: mock(async (_details?: { accountStatus?: string }) => ({ + email: 'user@example.com', + id: '12345', + })), + }, +}; + +(globalThis as Record).chrome = mockChrome; + +const mockFetch = mock(async () => ({ + ok: true, + status: 200, + json: async () => ({ Status: 3 }), +})); + +(globalThis as Record).fetch = mockFetch; + +function setupOptionsDOM(): void { + document.body.innerHTML = ` + +
    +
    +
    + +
    + +
    + Not detected +
    +
    + + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + + +
    +
    + + + `; +} + +function getOptionsElements() { + return { + input: document.getElementById('emailInput') as HTMLInputElement, + saveState: document.getElementById('saveStateIndicator') as HTMLDivElement, + status: document.getElementById('status') as HTMLDivElement, + modeFeedback: document.getElementById('modeFeedback') as HTMLDivElement, + profileEmail: document.getElementById('chromeProfileEmail') as HTMLSpanElement, + colPlus: document.getElementById('colPlusAddressing') as HTMLDivElement, + colCatch: document.getElementById('colCatchAll') as HTMLDivElement, + radioPlus: document.getElementById('modePlusAddressing') as HTMLInputElement, + radioCatch: document.getElementById('modeCatchAll') as HTMLInputElement, + }; +} + +async function initOptionsPage(): Promise { + setupOptionsDOM(); + document.dispatchEvent(new Event('DOMContentLoaded')); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +async function waitForDebounce(ms = 350): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Test-only helpers + +function cleanDomain(domain: string): string { + return domain.trim().replace(/^@/, ''); +} + +function generateExampleEmail(siteDomain: string, userDomain: string): string { + return `${siteDomain}@${userDomain}`; +} + +function generatePlusAddressEmail(siteDomain: string, baseEmail: string): string | null { + const trimmed = baseEmail.trim(); + if (!trimmed) return null; + const atIndex = trimmed.lastIndexOf('@'); + if (atIndex <= 0 || atIndex === trimmed.length - 1) return null; + const localPart = trimmed.substring(0, atIndex); + const domain = trimmed.substring(atIndex + 1); + return `${localPart}+${siteDomain}@${domain}`; +} + +function isValidBaseEmail(email: string): boolean { + const trimmed = email.trim(); + if (!trimmed) return false; + const atIndex = trimmed.lastIndexOf('@'); + if (atIndex <= 0 || atIndex === trimmed.length - 1) return false; + const domain = trimmed.substring(atIndex + 1); + return domainRegex.test(domain); +} + +describe('cleanDomain', () => { + test('removes leading @ symbol', () => { + expect(cleanDomain('@example.com')).toBe('example.com'); + }); + + test('trims whitespace', () => { + expect(cleanDomain(' example.com ')).toBe('example.com'); + }); + + test('handles both whitespace and @', () => { + expect(cleanDomain(' @example.com ')).toBe('example.com'); + }); + + test('leaves valid domain unchanged', () => { + expect(cleanDomain('example.com')).toBe('example.com'); + }); + + test('only removes first @ symbol', () => { + expect(cleanDomain('@user@example.com')).toBe('user@example.com'); + }); +}); + +describe('generateExampleEmail (catch-all)', () => { + test('generates correct email format', () => { + expect(generateExampleEmail('google.com', 'mydomain.com')).toBe('google.com@mydomain.com'); + }); + + test('works with subdomain user domain', () => { + expect(generateExampleEmail('github.com', 'mail.mydomain.com')).toBe( + 'github.com@mail.mydomain.com', + ); + }); + + test('works with short domains', () => { + expect(generateExampleEmail('x.com', 'mg.de')).toBe('x.com@mg.de'); + }); +}); + +describe('generatePlusAddressEmail', () => { + test('generates correct plus-addressed email', () => { + expect(generatePlusAddressEmail('zalando.de', 'name@gmail.com')).toBe( + 'name+zalando.de@gmail.com', + ); + }); + + test('works with company email', () => { + expect(generatePlusAddressEmail('salesforce.com', 'employee@company.com')).toBe( + 'employee+salesforce.com@company.com', + ); + }); + + test('handles local part with dots', () => { + expect(generatePlusAddressEmail('amazon.com', 'first.last@gmail.com')).toBe( + 'first.last+amazon.com@gmail.com', + ); + }); + + test('works with all 7 example sites', () => { + const sites = [ + 'wikipedia.org', + 'amazon.com', + 'zalando.de', + 'ui.com', + 'cloudflare.com', + 'claude.ai', + 'netflix.com', + ]; + for (const site of sites) { + const result = generatePlusAddressEmail(site, 'name@gmail.com'); + expect(result).toBe(`name+${site}@gmail.com`); + } + }); + + test('returns null for empty base email', () => { + expect(generatePlusAddressEmail('example.com', '')).toBeNull(); + }); + + test('returns null for base email without @', () => { + expect(generatePlusAddressEmail('example.com', 'invalid-email')).toBeNull(); + }); + + test('returns null for base email starting with @', () => { + expect(generatePlusAddressEmail('example.com', '@gmail.com')).toBeNull(); + }); + + test('returns null for base email ending with @', () => { + expect(generatePlusAddressEmail('example.com', 'name@')).toBeNull(); + }); + + test('trims whitespace', () => { + expect(generatePlusAddressEmail('example.com', ' name@gmail.com ')).toBe( + 'name+example.com@gmail.com', + ); + }); +}); + +describe('isValidBaseEmail', () => { + test('accepts valid email', () => { + expect(isValidBaseEmail('user@example.com')).toBe(true); + }); + + test('accepts email with dots in local part', () => { + expect(isValidBaseEmail('first.last@example.com')).toBe(true); + }); + + test('accepts email with plus in local part', () => { + expect(isValidBaseEmail('user+tag@example.com')).toBe(true); + }); + + test('rejects empty string', () => { + expect(isValidBaseEmail('')).toBe(false); + }); + + test('rejects email without @', () => { + expect(isValidBaseEmail('invalid-email')).toBe(false); + }); + + test('rejects email with invalid domain', () => { + expect(isValidBaseEmail('user@localhost')).toBe(false); + }); + + test('rejects email with no local part', () => { + expect(isValidBaseEmail('@example.com')).toBe(false); + }); + + test('rejects email ending with @', () => { + expect(isValidBaseEmail('user@')).toBe(false); + }); +}); + +describe('chrome storage mock', () => { + beforeEach(() => { + for (const key of Object.keys(mockStorage)) { + delete mockStorage[key]; + } + }); + + test('can set and get emailDomain', async () => { + await mockChrome.storage.sync.set({ emailDomain: 'test.com' }); + const result = await mockChrome.storage.sync.get(['emailDomain']); + expect(result.emailDomain).toBe('test.com'); + }); + + test('can set and get emailMode', async () => { + await mockChrome.storage.sync.set({ emailMode: 'plusAddressing' }); + const result = await mockChrome.storage.sync.get(['emailMode']); + expect(result.emailMode).toBe('plusAddressing'); + }); + + test('can set and get baseEmail', async () => { + await mockChrome.storage.sync.set({ baseEmail: 'name@gmail.com' }); + const result = await mockChrome.storage.sync.get(['baseEmail']); + expect(result.baseEmail).toBe('name@gmail.com'); + }); + + test('can get all three keys at once', async () => { + await mockChrome.storage.sync.set({ + emailMode: 'plusAddressing', + baseEmail: 'name@gmail.com', + emailDomain: 'old.com', + }); + const result = await mockChrome.storage.sync.get(['emailDomain', 'emailMode', 'baseEmail']); + expect(result.emailMode).toBe('plusAddressing'); + expect(result.baseEmail).toBe('name@gmail.com'); + expect(result.emailDomain).toBe('old.com'); + }); + + test('returns empty object for missing keys', async () => { + const result = await mockChrome.storage.sync.get(['nonexistent']); + expect(result).toEqual({}); + }); + + test('can remove all settings keys', async () => { + await mockChrome.storage.sync.set({ + emailMode: 'plusAddressing', + baseEmail: 'name@gmail.com', + emailDomain: 'old.com', + }); + await mockChrome.storage.sync.remove(['emailDomain', 'emailMode', 'baseEmail']); + const result = await mockChrome.storage.sync.get(['emailDomain', 'emailMode', 'baseEmail']); + expect(result).toEqual({}); + }); +}); + +describe('chrome profile import', () => { + beforeEach(() => { + mockChrome.identity.getProfileUserInfo = mock(async () => ({ + email: 'user@example.com', + id: '12345', + })); + }); + + test('extracts domain from chrome profile email', async () => { + const userInfo = await mockChrome.identity.getProfileUserInfo({ accountStatus: 'ANY' }); + const domain = extractDomainFromEmail(userInfo.email); + expect(domain).toBe('example.com'); + }); + + test('extracts full email for plus addressing mode', async () => { + const userInfo = await mockChrome.identity.getProfileUserInfo({ accountStatus: 'ANY' }); + expect(userInfo.email).toBe('user@example.com'); + // In plus addressing mode, the full email is used directly + const plusEmail = generatePlusAddressEmail('zalando.de', userInfo.email); + expect(plusEmail).toBe('user+zalando.de@example.com'); + }); + + test('handles empty email (not signed in)', async () => { + mockChrome.identity.getProfileUserInfo = mock(async () => ({ + email: '', + id: '', + })); + const userInfo = await mockChrome.identity.getProfileUserInfo({ accountStatus: 'ANY' }); + const domain = extractDomainFromEmail(userInfo.email); + expect(domain).toBeNull(); + }); + + test('handles API error gracefully', async () => { + mockChrome.identity.getProfileUserInfo = mock(async () => { + throw new Error('API unavailable'); + }); + await expect(mockChrome.identity.getProfileUserInfo({ accountStatus: 'ANY' })).rejects.toThrow( + 'API unavailable', + ); + }); +}); + +describe('domain-only provider detection', () => { + function cleanDomainInput(value: string): string { + return value.replace(/^@/, '').toLowerCase(); + } + + test('domainRegex accepts valid domain-only input', () => { + expect(domainRegex.test('manuelgruber.com')).toBe(true); + expect(domainRegex.test('gmail.com')).toBe(true); + expect(domainRegex.test('my-company.co.uk')).toBe(true); + }); + + test('domainRegex rejects incomplete domains', () => { + expect(domainRegex.test('gmai')).toBe(false); + expect(domainRegex.test('hello')).toBe(false); + expect(domainRegex.test('')).toBe(false); + }); + + test('cleanDomainInput strips leading @', () => { + expect(cleanDomainInput('@gmail.com')).toBe('gmail.com'); + expect(cleanDomainInput('gmail.com')).toBe('gmail.com'); + expect(cleanDomainInput('@MyDomain.COM')).toBe('mydomain.com'); + }); + + test('getProviderStatus works with bare domains', () => { + expect(getProviderStatus('gmail.com')).toBe('plus-supported'); + expect(getProviderStatus('yahoo.com')).toBe('plus-unsupported'); + expect(getProviderStatus('manuelgruber.com')).toBe('custom'); + }); + + test('getProviderStatus is case-insensitive', () => { + expect(getProviderStatus('Gmail.com')).toBe('plus-supported'); + expect(getProviderStatus('YAHOO.COM')).toBe('plus-unsupported'); + }); +}); + +describe('auto-save defaults on options page load', () => { + beforeEach(() => { + // Reset storage + for (const key of Object.keys(mockStorage)) { + delete mockStorage[key]; + } + // Reset identity mock + mockChrome.identity.getProfileUserInfo = mock(async () => ({ + email: 'user@example.com', + id: '12345', + })); + }); + + test('auto-saves when no settings exist and profile email is available', async () => { + const profileEmail = 'user@gmail.com'; + // Simulate loadSettings logic: no saved settings + profile email available + const result = await mockChrome.storage.sync.get(['emailDomain', 'emailMode', 'baseEmail']); + const hasSavedSettings = result.emailMode || result.emailDomain || result.baseEmail; + + if (!hasSavedSettings && profileEmail) { + await mockChrome.storage.sync.set({ emailMode: 'plusAddressing', baseEmail: profileEmail }); + } + + expect(mockStorage.emailMode).toBe('plusAddressing'); + expect(mockStorage.baseEmail).toBe('user@gmail.com'); + }); + + test('does not auto-save when settings already exist', async () => { + mockStorage.emailMode = 'catchAll'; + mockStorage.emailDomain = 'mg.de'; + + const profileEmail = 'user@gmail.com'; + const result = await mockChrome.storage.sync.get(['emailDomain', 'emailMode', 'baseEmail']); + const hasSavedSettings = result.emailMode || result.emailDomain || result.baseEmail; + + if (!hasSavedSettings && profileEmail) { + await mockChrome.storage.sync.set({ emailMode: 'plusAddressing', baseEmail: profileEmail }); + } + + expect(mockStorage.emailMode).toBe('catchAll'); + expect(mockStorage.emailDomain).toBe('mg.de'); + }); + + test('does not auto-save when no profile email is available', async () => { + const profileEmail: string | null = null; + const result = await mockChrome.storage.sync.get(['emailDomain', 'emailMode', 'baseEmail']); + const hasSavedSettings = result.emailMode || result.emailDomain || result.baseEmail; + + if (!hasSavedSettings && profileEmail) { + await mockChrome.storage.sync.set({ emailMode: 'plusAddressing', baseEmail: profileEmail }); + } + + expect(mockStorage.emailMode).toBeUndefined(); + expect(mockStorage.baseEmail).toBeUndefined(); + }); +}); + +describe('status message types', () => { + test('returns the correct label for each header save state', () => { + expect(getSaveIndicatorLabel('editing')).toBe('Editing…'); + expect(getSaveIndicatorLabel('saving')).toBe('Saving…'); + expect(getSaveIndicatorLabel('saved')).toBe('Saved'); + expect(getSaveIndicatorLabel('error')).toBe('Save failed'); + }); +}); + +describe('settings draft helpers', () => { + test('creates a plus-addressing draft from a valid email', () => { + expect(createSettingsDraft('name@gmail.com', 'plusAddressing')).toEqual({ + mode: 'plusAddressing', + canonicalInputValue: 'name@gmail.com', + storagePayload: { + emailMode: 'plusAddressing', + emailDomain: 'gmail.com', + baseEmail: 'name@gmail.com', + }, + }); + }); + + test('creates a catch-all draft from a bare domain', () => { + expect(createSettingsDraft('example.com', 'catchAll')).toEqual({ + mode: 'catchAll', + canonicalInputValue: 'example.com', + storagePayload: { + emailMode: 'catchAll', + emailDomain: 'example.com', + }, + }); + }); + + test('creates a catch-all draft from a leading-at domain', () => { + expect(createSettingsDraft('@Example.COM', 'catchAll')).toEqual({ + mode: 'catchAll', + canonicalInputValue: 'example.com', + storagePayload: { + emailMode: 'catchAll', + emailDomain: 'example.com', + }, + }); + }); + + test('creates a catch-all draft from a full email without collapsing the input value', () => { + expect(createSettingsDraft('User@Example.com', 'catchAll')).toEqual({ + mode: 'catchAll', + canonicalInputValue: 'User@Example.com', + storagePayload: { + emailMode: 'catchAll', + emailDomain: 'example.com', + baseEmail: 'User@Example.com', + }, + }); + }); + + test('returns null for incomplete or invalid input', () => { + expect(createSettingsDraft('name@', 'plusAddressing')).toBeNull(); + expect(createSettingsDraft('exam', 'catchAll')).toBeNull(); + expect(createSettingsDraft('', 'catchAll')).toBeNull(); + }); + + test('compares drafts by payload and canonical input', () => { + const left = createSettingsDraft('name@gmail.com', 'plusAddressing'); + const right = createSettingsDraft('name@gmail.com', 'plusAddressing'); + const different = createSettingsDraft('other@gmail.com', 'plusAddressing'); + + expect(areSettingsDraftsEqual(left, right)).toBe(true); + expect(areSettingsDraftsEqual(left, different)).toBe(false); + expect(areSettingsDraftsEqual(left, null)).toBe(false); + }); +}); + +describe('options page integration', () => { + beforeEach(() => { + for (const key of Object.keys(mockStorage)) { + delete mockStorage[key]; + } + for (const key of Object.keys(mockLocalStorage)) { + delete mockLocalStorage[key]; + } + mockChrome.storage.sync.get.mockClear(); + mockChrome.storage.sync.set.mockClear(); + mockChrome.storage.sync.remove.mockClear(); + mockChrome.storage.local.get.mockClear(); + mockChrome.storage.local.set.mockClear(); + mockChrome.storage.local.remove.mockClear(); + mockChrome.identity.getProfileUserInfo = mock(async () => ({ + email: 'user@gmail.com', + id: '12345', + })); + mockFetch.mockClear(); + }); + + afterEach(async () => { + await waitForDebounce(); + document.body.innerHTML = ''; + }); + + test('shows full email on load when catch-all mode and baseEmail are both saved', async () => { + mockStorage.emailMode = 'catchAll'; + mockStorage.emailDomain = 'mg.de'; + mockStorage.baseEmail = 'user@gmail.com'; + + await initOptionsPage(); + const { input, colCatch, colPlus, saveState, status } = getOptionsElements(); + + expect(input.value).toBe('user@gmail.com'); + expect(colCatch.classList.contains('disabled')).toBe(true); + expect(colCatch.classList.contains('selected')).toBe(false); + expect(colPlus.classList.contains('selected')).toBe(true); + expect(saveState.textContent).toBe('Saved'); + expect(saveState.dataset.state).toBe('saved'); + expect(status.textContent).toBe(''); + expect(status.classList.contains('success')).toBe(false); + expect(status.classList.contains('error')).toBe(false); + }); + + test('imports Chrome profile email and auto-selects plus addressing for known providers', async () => { + mockStorage.emailMode = 'catchAll'; + mockStorage.emailDomain = 'gmail.com'; + + await initOptionsPage(); + const { input, profileEmail, colPlus, colCatch, saveState, status } = getOptionsElements(); + + profileEmail.click(); + await waitForDebounce(20); + + expect(input.value).toBe('user@gmail.com'); + expect(colCatch.classList.contains('disabled')).toBe(true); + expect(colPlus.classList.contains('selected')).toBe(true); + expect(mockStorage.emailMode).toBe('plusAddressing'); + expect(mockStorage.emailDomain).toBe('gmail.com'); + expect(mockStorage.baseEmail).toBe('user@gmail.com'); + expect(saveState.textContent).toBe('Saved'); + expect(status.textContent).toBe('Email imported'); + }); + + test('imports Chrome profile email and preserves plus mode when already selected', async () => { + mockStorage.emailMode = 'plusAddressing'; + mockStorage.emailDomain = 'yahoo.com'; + mockStorage.baseEmail = 'old@yahoo.com'; + + await initOptionsPage(); + const { input, profileEmail, colPlus } = getOptionsElements(); + + profileEmail.click(); + await waitForDebounce(20); + + expect(input.value).toBe('user@gmail.com'); + expect(colPlus.classList.contains('selected')).toBe(true); + expect(mockStorage.emailMode).toBe('plusAddressing'); + expect(mockStorage.emailDomain).toBe('gmail.com'); + expect(mockStorage.baseEmail).toBe('user@gmail.com'); + }); + + test('disables plus immediately for domain-only input and prevents reselection', async () => { + mockStorage.emailMode = 'plusAddressing'; + mockStorage.emailDomain = 'mycorp.com'; + mockStorage.baseEmail = 'user@mycorp.com'; + + await initOptionsPage(); + const { input, colPlus, colCatch, radioPlus, radioCatch } = getOptionsElements(); + + expect(colPlus.classList.contains('selected')).toBe(true); + + input.value = 'mycorp.com'; + input.dispatchEvent(new Event('input', { bubbles: true })); + + expect(colPlus.classList.contains('disabled')).toBe(true); + expect(colCatch.classList.contains('disabled')).toBe(false); + expect(radioPlus.checked).toBe(false); + expect(radioCatch.checked).toBe(true); + expect(colCatch.classList.contains('selected')).toBe(true); + + colPlus.click(); + + expect(radioPlus.checked).toBe(false); + expect(radioCatch.checked).toBe(true); + + await waitForDebounce(); + + expect(mockStorage.emailMode).toBe('catchAll'); + expect(mockStorage.emailDomain).toBe('mycorp.com'); + expect(mockStorage.baseEmail).toBeUndefined(); + }); + + test('saving a full email while catch-all stays selected preserves the full field value', async () => { + mockStorage.emailMode = 'catchAll'; + mockStorage.emailDomain = 'mycorp.com'; + + await initOptionsPage(); + const { input, colCatch } = getOptionsElements(); + + input.value = 'worker@mycorp.com'; + input.dispatchEvent(new Event('input', { bubbles: true })); + await waitForDebounce(); + + expect(colCatch.classList.contains('selected')).toBe(true); + expect(input.value).toBe('worker@mycorp.com'); + expect(mockStorage.emailMode).toBe('catchAll'); + expect(mockStorage.emailDomain).toBe('mycorp.com'); + expect(mockStorage.baseEmail).toBe('worker@mycorp.com'); + }); + + test('defaults to plus addressing for first-time supported full-email input', async () => { + mockChrome.identity.getProfileUserInfo = mock(async () => ({ + email: '', + id: '', + })); + + await initOptionsPage(); + const { input, colPlus, colCatch } = getOptionsElements(); + + input.value = 'worker@gmail.com'; + input.dispatchEvent(new Event('input', { bubbles: true })); + await waitForDebounce(); + + expect(colPlus.classList.contains('selected')).toBe(true); + expect(colCatch.classList.contains('selected')).toBe(false); + expect(mockStorage.emailMode).toBe('plusAddressing'); + expect(mockStorage.emailDomain).toBe('gmail.com'); + expect(mockStorage.baseEmail).toBe('worker@gmail.com'); + }); + + test('saving domain-only input clears baseEmail and reloads as domain-only', async () => { + mockStorage.emailMode = 'plusAddressing'; + mockStorage.emailDomain = 'mycorp.com'; + mockStorage.baseEmail = 'user@mycorp.com'; + + await initOptionsPage(); + let elements = getOptionsElements(); + + elements.input.value = 'mycorp.com'; + elements.input.dispatchEvent(new Event('input', { bubbles: true })); + await waitForDebounce(); + + expect(mockStorage.emailMode).toBe('catchAll'); + expect(mockStorage.emailDomain).toBe('mycorp.com'); + expect(mockStorage.baseEmail).toBeUndefined(); + + await initOptionsPage(); + elements = getOptionsElements(); + + expect(elements.input.value).toBe('mycorp.com'); + expect(elements.colPlus.classList.contains('disabled')).toBe(true); + expect(elements.colCatch.classList.contains('selected')).toBe(true); + }); + + test('disables both modes for berlin.com emails and does not overwrite saved settings', async () => { + mockStorage.emailMode = 'plusAddressing'; + mockStorage.emailDomain = 'gmail.com'; + mockStorage.baseEmail = 'user@gmail.com'; + + await initOptionsPage(); + const { input, colPlus, colCatch, radioPlus, radioCatch, modeFeedback, saveState } = + getOptionsElements(); + + expect(modeFeedback.classList.contains('is-empty')).toBe(false); + expect(modeFeedback.classList.contains('feedback-warning')).toBe(false); + expect(modeFeedback.textContent).toBe('Catch-All mode requires a custom domain you own.'); + + input.value = 'abc@berlin.com'; + input.dispatchEvent(new Event('input', { bubbles: true })); + + expect(colPlus.classList.contains('disabled')).toBe(true); + expect(colCatch.classList.contains('disabled')).toBe(true); + expect(radioPlus.checked).toBe(false); + expect(radioCatch.checked).toBe(false); + expect(modeFeedback.textContent).toBe( + 'This provider does not support Plus-Addressing. Catch-All mode requires a custom domain.', + ); + expect(modeFeedback.classList.contains('feedback-warning')).toBe(true); + expect(modeFeedback.classList.contains('is-empty')).toBe(false); + expect(modeFeedback.getAttribute('aria-hidden')).toBe('false'); + expect(saveState.textContent).toBe('Editing…'); + + await waitForDebounce(); + + expect(mockStorage.emailMode).toBe('plusAddressing'); + expect(mockStorage.emailDomain).toBe('gmail.com'); + expect(mockStorage.baseEmail).toBe('user@gmail.com'); + }); + + test('applies the same both-disabled rule to yahoo.com emails', async () => { + mockStorage.emailMode = 'catchAll'; + mockStorage.emailDomain = 'gmail.com'; + mockStorage.baseEmail = 'user@gmail.com'; + + await initOptionsPage(); + const { input, colPlus, colCatch, radioPlus, radioCatch, modeFeedback } = getOptionsElements(); + + input.value = 'abc@yahoo.com'; + input.dispatchEvent(new Event('input', { bubbles: true })); + + expect(colPlus.classList.contains('disabled')).toBe(true); + expect(colCatch.classList.contains('disabled')).toBe(true); + expect(radioPlus.checked).toBe(false); + expect(radioCatch.checked).toBe(false); + expect(modeFeedback.textContent).toBe( + 'This provider does not support Plus-Addressing. Catch-All mode requires a custom domain.', + ); + }); + + test('supported public-provider emails recover to the previous mode and clear the warning slot', async () => { + mockStorage.emailMode = 'plusAddressing'; + mockStorage.emailDomain = 'gmail.com'; + mockStorage.baseEmail = 'user@gmail.com'; + + await initOptionsPage(); + const { input, colPlus, colCatch, radioPlus, radioCatch, modeFeedback } = getOptionsElements(); + + input.value = 'abc@berlin.com'; + input.dispatchEvent(new Event('input', { bubbles: true })); + + expect(radioPlus.checked).toBe(false); + expect(radioCatch.checked).toBe(false); + + input.value = 'abc@gmail.com'; + input.dispatchEvent(new Event('input', { bubbles: true })); + + expect(colPlus.classList.contains('disabled')).toBe(false); + expect(colCatch.classList.contains('disabled')).toBe(true); + expect(radioPlus.checked).toBe(true); + expect(radioCatch.checked).toBe(false); + expect(modeFeedback.classList.contains('feedback-warning')).toBe(false); + expect(modeFeedback.classList.contains('is-empty')).toBe(false); + expect(modeFeedback.textContent).toBe('Catch-All mode requires a custom domain you own.'); + }); + + test('full email with plus-supported provider disables catch-all column', async () => { + await initOptionsPage(); + const { input, colPlus, colCatch, radioPlus, radioCatch } = getOptionsElements(); + + input.value = 'user@gmail.com'; + input.dispatchEvent(new Event('input', { bubbles: true })); + + expect(colPlus.classList.contains('disabled')).toBe(false); + expect(colCatch.classList.contains('disabled')).toBe(true); + expect(radioPlus.checked).toBe(true); + expect(radioCatch.checked).toBe(false); + }); + + test('custom-domain full emails do not disable both modes', async () => { + mockStorage.emailMode = 'catchAll'; + mockStorage.emailDomain = 'mycorp.com'; + mockStorage.baseEmail = 'user@mycorp.com'; + + await initOptionsPage(); + const { input, colPlus, colCatch, radioCatch, modeFeedback } = getOptionsElements(); + + input.value = 'abc@mydomain.com'; + input.dispatchEvent(new Event('input', { bubbles: true })); + + expect(colPlus.classList.contains('disabled')).toBe(false); + expect(colCatch.classList.contains('disabled')).toBe(false); + expect(radioCatch.checked).toBe(true); + expect(modeFeedback.classList.contains('feedback-warning')).toBe(false); + }); + + test('shows editing immediately for invalid input and does not save it', async () => { + mockStorage.emailMode = 'plusAddressing'; + mockStorage.emailDomain = 'gmail.com'; + mockStorage.baseEmail = 'user@gmail.com'; + + await initOptionsPage(); + const { input, saveState } = getOptionsElements(); + + input.value = 'name@'; + input.dispatchEvent(new Event('input', { bubbles: true })); + + expect(saveState.textContent).toBe('Editing…'); + expect(saveState.dataset.state).toBe('editing'); + + await waitForDebounce(); + + expect(mockStorage.emailMode).toBe('plusAddressing'); + expect(mockStorage.baseEmail).toBe('user@gmail.com'); + }); + + test('transitions from editing to saved for valid input', async () => { + mockStorage.emailMode = 'catchAll'; + mockStorage.emailDomain = 'old.com'; + + await initOptionsPage(); + const { input, saveState } = getOptionsElements(); + + input.value = 'new.com'; + input.dispatchEvent(new Event('input', { bubbles: true })); + + expect(saveState.textContent).toBe('Editing…'); + expect(saveState.dataset.state).toBe('editing'); + + await waitForDebounce(); + + expect(saveState.textContent).toBe('Saved'); + expect(saveState.dataset.state).toBe('saved'); + expect(mockStorage.emailDomain).toBe('new.com'); + }); + + test('auto-configures first-run users and ends with a saved header state', async () => { + await initOptionsPage(); + const { input, saveState, status } = getOptionsElements(); + + expect(input.value).toBe('user@gmail.com'); + expect(mockStorage.emailMode).toBe('plusAddressing'); + expect(mockStorage.baseEmail).toBe('user@gmail.com'); + expect(saveState.textContent).toBe('Saved'); + expect(status.textContent).toBe('Settings auto-configured from your Chrome profile'); + }); + + test('shows save failure in the header and recovers on the next successful save', async () => { + mockStorage.emailMode = 'catchAll'; + mockStorage.emailDomain = 'start.com'; + + const failingSet = mock(async (_items: Record) => { + throw new Error('sync unavailable'); + }); + mockChrome.storage.sync.set = failingSet; + + await initOptionsPage(); + let elements = getOptionsElements(); + + elements.input.value = 'broken.com'; + elements.input.dispatchEvent(new Event('input', { bubbles: true })); + await waitForDebounce(); + + expect(elements.saveState.textContent).toBe('Save failed'); + expect(elements.saveState.dataset.state).toBe('error'); + expect(elements.status.textContent).toContain('sync unavailable'); + + mockChrome.storage.sync.set = mock(async (items: Record) => { + Object.assign(mockStorage, items); + }); + + elements.input.value = 'fixed.com'; + elements.input.dispatchEvent(new Event('input', { bubbles: true })); + await waitForDebounce(); + + elements = getOptionsElements(); + expect(elements.saveState.textContent).toBe('Saved'); + expect(mockStorage.emailDomain).toBe('fixed.com'); + }); +}); diff --git a/src/ui/options.ts b/src/ui/options.ts new file mode 100644 index 0000000..65052ca --- /dev/null +++ b/src/ui/options.ts @@ -0,0 +1,1328 @@ +import { getAllCatchAllInstructions } from '../email/catch-all-instructions.js'; +import { getProviderInfo } from '../email/mx-lookup.js'; +import type { ProviderStatus } from '../email/providers.js'; +import { + domainRegex, + extractDomainFromEmail, + extractLocalPart, + getProviderStatus, + getProviderStatusWithMx, +} from '../email/providers.js'; +import type { + CleanAutofillUtils, + DetectedProvider, + EmailHistoryEntry, + EmailMode, + MxLookupResult, +} from '../types'; + +const { debounce } = + (globalThis as { CleanAutofillUtils?: CleanAutofillUtils }).CleanAutofillUtils || {}; + +export type SaveIndicatorState = 'editing' | 'saving' | 'saved' | 'error'; + +export type SettingsDraft = { + mode: EmailMode; + storagePayload: Record; + canonicalInputValue: string; +}; + +type SelectedMode = EmailMode | null; + +const SAVE_INDICATOR_LABELS: Record = { + editing: 'Editing…', + saving: 'Saving…', + saved: 'Saved', + error: 'Save failed', +}; + +const FEEDBACK_MESSAGES = { + unsupportedProvider: + 'This provider does not support Plus-Addressing. Catch-All mode requires a custom domain.', + catchAllRequiresCustomDomain: 'Catch-All mode requires a custom domain you own.', + unsupportedPlusAddressing: 'This email provider does not support Plus-Addressing.', + possiblyUnsupportedPlusAddressing: 'This email provider likely does not support Plus-Addressing.', + enterEmailOrDomain: 'Enter your email or domain above.', + enterValidEmailOrDomain: 'Enter a valid email or domain.', + plusRequiresFullEmail: 'Plus-Addressing requires a full email address.', +} as const; + +export function getSaveIndicatorLabel(state: SaveIndicatorState): string { + return SAVE_INDICATOR_LABELS[state]; +} + +export function createSettingsDraft(value: string, mode: EmailMode): SettingsDraft | null { + const trimmedValue = value.trim(); + if (!trimmedValue) return null; + + const localPart = extractLocalPart(trimmedValue); + const domain = extractDomainFromEmail(trimmedValue)?.toLowerCase() ?? null; + const normalizedDomain = trimmedValue.replace(/^@/, '').toLowerCase(); + const isFullEmail = + trimmedValue.includes('@') && localPart != null && domain != null && domainRegex.test(domain); + + if (mode === 'plusAddressing') { + if (!isFullEmail || !domain) return null; + return { + mode, + canonicalInputValue: trimmedValue, + storagePayload: { + emailMode: mode, + emailDomain: domain, + baseEmail: trimmedValue, + }, + }; + } + + if (isFullEmail && domain) { + return { + mode, + canonicalInputValue: trimmedValue, + storagePayload: { + emailMode: mode, + emailDomain: domain, + baseEmail: trimmedValue, + }, + }; + } + + if (!domainRegex.test(normalizedDomain)) return null; + + return { + mode, + canonicalInputValue: normalizedDomain, + storagePayload: { + emailMode: mode, + emailDomain: normalizedDomain, + }, + }; +} + +export function areSettingsDraftsEqual(a: SettingsDraft | null, b: SettingsDraft | null): boolean { + if (a === b) return true; + if (!a || !b) return false; + if (a.mode !== b.mode || a.canonicalInputValue !== b.canonicalInputValue) return false; + + const aEntries = Object.entries(a.storagePayload).sort(([left], [right]) => + left.localeCompare(right), + ); + const bEntries = Object.entries(b.storagePayload).sort(([left], [right]) => + left.localeCompare(right), + ); + + if (aEntries.length !== bEntries.length) return false; + + for (let i = 0; i < aEntries.length; i++) { + if (aEntries[i][0] !== bEntries[i][0] || aEntries[i][1] !== bEntries[i][1]) { + return false; + } + } + + return true; +} + +document.addEventListener('DOMContentLoaded', async () => { + // ── Sidebar Navigation ── + const navItems = document.querySelectorAll('.nav-item[data-page]'); + const pages = document.querySelectorAll('.page'); + + function switchPage(pageId: string): void { + navItems.forEach((nav) => { + nav.classList.toggle('active', nav.dataset.page === pageId); + }); + pages.forEach((page) => { + page.classList.toggle('active', page.id === `page-${pageId}`); + }); + if (pageId === 'history') { + loadHistory(); + } + if (pageId === 'help') { + renderHelpPage(); + } + } + + navItems.forEach((nav) => { + nav.addEventListener('click', () => { + const pageId = nav.dataset.page; + if (pageId) switchPage(pageId); + }); + }); + + // ── Settings Page Elements ── + const form = document.getElementById('settingsForm'); + const emailInput = document.getElementById('emailInput'); + const statusDiv = document.getElementById('status'); + const saveStateIndicator = document.getElementById('saveStateIndicator'); + const chromeProfileEmail = document.getElementById('chromeProfileEmail'); + const colPlusAddressing = document.getElementById('colPlusAddressing'); + const colCatchAll = document.getElementById('colCatchAll'); + const modePlusAddressing = document.getElementById('modePlusAddressing'); + const modeCatchAll = document.getElementById('modeCatchAll'); + const plusFormat = document.getElementById('plusFormat'); + const catchAllFormat = document.getElementById('catchAllFormat'); + const modeFeedback = document.getElementById('modeFeedback'); + const providerDetected = document.getElementById('providerDetected'); + const providerText = document.getElementById('providerText'); + const providerPlaceholder = document.getElementById('providerPlaceholder'); + const providerLogo = document.getElementById('providerLogo'); + const plusProviderIndicator = document.getElementById('plusProviderIndicator'); + const plusSupportIndicator = document.getElementById('plusSupportIndicator'); + const catchAllDomainIndicator = document.getElementById('catchAllDomainIndicator'); + const catchAllEnabledIndicator = document.getElementById('catchAllEnabledIndicator'); + const plusProviderValue = document.getElementById('plusProviderValue'); + const plusSupportValue = document.getElementById('plusSupportValue'); + const catchAllDomainValue = document.getElementById('catchAllDomainValue'); + const catchAllEnabledValue = document.getElementById('catchAllEnabledValue'); + const detectionChromeProfile = document.getElementById('detectionChromeProfile'); + const detectionProvider = document.getElementById('detectionProvider'); + const catchAllInfoIcon = document.getElementById('catchAllInfoIcon'); + const helpProvidersContainer = document.getElementById('helpProvidersContainer'); + + if ( + !form || + !emailInput || + !statusDiv || + !saveStateIndicator || + !chromeProfileEmail || + !colPlusAddressing || + !colCatchAll || + !modePlusAddressing || + !modeCatchAll || + !plusFormat || + !catchAllFormat || + !modeFeedback || + !providerDetected || + !providerText || + !providerPlaceholder || + !providerLogo || + !plusProviderIndicator || + !plusSupportIndicator || + !catchAllDomainIndicator || + !catchAllEnabledIndicator || + !plusProviderValue || + !plusSupportValue || + !catchAllDomainValue || + !catchAllEnabledValue || + !detectionChromeProfile || + !detectionProvider || + !catchAllInfoIcon || + !helpProvidersContainer + ) { + console.error('Required DOM elements not found'); + return; + } + + const formEl = form as HTMLFormElement; + const input = emailInput as HTMLInputElement; + const statusEl = statusDiv as HTMLDivElement; + const saveStateEl = saveStateIndicator as HTMLDivElement; + const profileEmailEl = chromeProfileEmail as HTMLSpanElement; + const colPlus = colPlusAddressing as HTMLDivElement; + const colCatch = colCatchAll as HTMLDivElement; + const radioPlus = modePlusAddressing as HTMLInputElement; + const radioCatch = modeCatchAll as HTMLInputElement; + const plusFormatEl = plusFormat as HTMLElement; + const catchAllFormatEl = catchAllFormat as HTMLElement; + const modeFeedbackEl = modeFeedback as HTMLDivElement; + const providerDetectedEl = providerDetected as HTMLDivElement; + const providerTextEl = providerText as HTMLSpanElement; + const providerPlaceholderEl = providerPlaceholder as HTMLSpanElement; + const providerLogoEl = providerLogo as HTMLSpanElement; + const plusProviderEl = plusProviderIndicator as HTMLSpanElement; + const plusSupportEl = plusSupportIndicator as HTMLSpanElement; + const catchAllDomainEl = catchAllDomainIndicator as HTMLSpanElement; + const catchAllEnabledEl = catchAllEnabledIndicator as HTMLSpanElement; + const plusProviderValueEl = plusProviderValue as HTMLSpanElement; + const plusSupportValueEl = plusSupportValue as HTMLSpanElement; + const catchAllDomainValueEl = catchAllDomainValue as HTMLSpanElement; + const catchAllEnabledValueEl = catchAllEnabledValue as HTMLSpanElement; + const chromeDetectionBoxEl = detectionChromeProfile as HTMLDivElement; + const providerDetectionBoxEl = detectionProvider as HTMLDivElement; + const catchAllInfoIconEl = catchAllInfoIcon as HTMLSpanElement; + const helpContainerEl = helpProvidersContainer as HTMLDivElement; + + let currentLookupDomain: string | null = null; + let currentDetectedProvider: DetectedProvider | null = null; + let isLoading = true; + let lastSavedDraft: SettingsDraft | null = null; + let pendingDraft: SettingsDraft | null = null; + let saveDelayTimer: ReturnType | null = null; + let activeSavePromise: Promise | null = null; + let statusTimer: ReturnType | null = null; + let preferredMode: EmailMode = 'plusAddressing'; + + const exampleEls = document.querySelectorAll('.example-email[data-site]'); + + interface InputState { + trimmedValue: string; + normalizedDomain: string; + localPart: string | null; + domain: string | null; + isFullEmail: boolean; + plusAllowed: boolean; + catchAllAllowed: boolean; + } + + // ── Provider Logos (local assets from src/icons/providers/) ── + const PROVIDER_LOGO_FILES: Record = { + gmail: 'icons/providers/gmail.png', + 'google-workspace': 'icons/providers/google-workspace.png', + outlook: 'icons/providers/outlook.png', + protonmail: 'icons/providers/protonmail.png', + fastmail: 'icons/providers/fastmail.png', + zoho: 'icons/providers/zoho.png', + icloud: 'icons/providers/icloud.png', + yahoo: 'icons/providers/yahoo.png', + gmx: 'icons/providers/gmx.png', + webde: 'icons/providers/webde.png', + tutanota: 'icons/providers/tutanota.png', + 'mailbox-org': 'icons/providers/mailbox-org.png', + yandex: 'icons/providers/yandex.png', + mailru: 'icons/providers/mailru.png', + 't-online': 'icons/providers/t-online.png', + hey: 'icons/providers/hey.png', + qq: 'icons/providers/qq.png', + netease: 'icons/providers/netease.png', + libero: 'icons/providers/libero.png', + laposte: 'icons/providers/laposte.png', + rediffmail: 'icons/providers/rediffmail.png', + mailcom: 'icons/providers/mailcom.png', + }; + + const DOMAIN_TO_PROVIDER: Record = { + 'gmail.com': 'gmail', + 'googlemail.com': 'gmail', + 'outlook.com': 'outlook', + 'hotmail.com': 'outlook', + 'live.com': 'outlook', + 'msn.com': 'outlook', + 'protonmail.com': 'protonmail', + 'proton.me': 'protonmail', + 'pm.me': 'protonmail', + 'protonmail.ch': 'protonmail', + 'fastmail.com': 'fastmail', + 'fastmail.fm': 'fastmail', + 'zoho.com': 'zoho', + 'icloud.com': 'icloud', + 'me.com': 'icloud', + 'mac.com': 'icloud', + 'yahoo.com': 'yahoo', + 'ymail.com': 'yahoo', + 'rocketmail.com': 'yahoo', + 'gmx.com': 'gmx', + 'gmx.de': 'gmx', + 'gmx.net': 'gmx', + 'tuta.com': 'tutanota', + 'tutanota.com': 'tutanota', + 'web.de': 'webde', + 't-online.de': 't-online', + 'mailbox.org': 'mailbox-org', + 'yandex.com': 'yandex', + 'yandex.ru': 'yandex', + 'ya.ru': 'yandex', + 'mail.ru': 'mailru', + 'inbox.ru': 'mailru', + 'list.ru': 'mailru', + 'bk.ru': 'mailru', + 'hey.com': 'hey', + 'qq.com': 'qq', + 'foxmail.com': 'qq', + '163.com': 'netease', + '126.com': 'netease', + 'yeah.net': 'netease', + 'libero.it': 'libero', + 'laposte.net': 'laposte', + 'rediffmail.com': 'rediffmail', + 'rediff.com': 'rediffmail', + 'mail.com': 'mailcom', + 'email.com': 'mailcom', + }; + + const DETECTED_PROVIDER_TO_LOGO: Record = { + 'google-workspace': 'google-workspace', + 'microsoft-365': 'outlook', + fastmail: 'fastmail', + protonmail: 'protonmail', + zoho: 'zoho', + icloud: 'icloud', + }; + + const DOMAIN_TO_FRIENDLY_NAME: Record = { + 'gmail.com': 'Gmail', + 'googlemail.com': 'Gmail', + 'outlook.com': 'Outlook', + 'hotmail.com': 'Outlook', + 'live.com': 'Outlook', + 'msn.com': 'Outlook', + 'protonmail.com': 'Proton Mail', + 'proton.me': 'Proton Mail', + 'pm.me': 'Proton Mail', + 'protonmail.ch': 'Proton Mail', + 'fastmail.com': 'Fastmail', + 'fastmail.fm': 'Fastmail', + 'zoho.com': 'Zoho Mail', + 'icloud.com': 'iCloud Mail', + 'me.com': 'iCloud Mail', + 'mac.com': 'iCloud Mail', + 'yahoo.com': 'Yahoo Mail', + 'ymail.com': 'Yahoo Mail', + 'rocketmail.com': 'Yahoo Mail', + 'gmx.com': 'GMX', + 'gmx.de': 'GMX', + 'gmx.net': 'GMX', + 'web.de': 'web.de', + 't-online.de': 'T-Online', + 'tuta.com': 'Tuta', + 'tutanota.com': 'Tuta', + 'mailbox.org': 'Mailbox.org', + 'yandex.com': 'Yandex Mail', + 'yandex.ru': 'Yandex Mail', + 'ya.ru': 'Yandex Mail', + 'mail.ru': 'Mail.ru', + 'inbox.ru': 'Mail.ru', + 'list.ru': 'Mail.ru', + 'bk.ru': 'Mail.ru', + 'hey.com': 'Hey', + 'qq.com': 'QQ Mail', + 'foxmail.com': 'QQ Mail', + '163.com': 'NetEase', + '126.com': 'NetEase', + 'yeah.net': 'NetEase', + 'libero.it': 'Libero', + 'laposte.net': 'La Poste', + 'rediffmail.com': 'Rediffmail', + 'rediff.com': 'Rediffmail', + 'mail.com': 'mail.com', + 'email.com': 'mail.com', + }; + + // ── Settings Logic ── + + function getMode(): SelectedMode { + if (radioPlus.checked) return 'plusAddressing'; + if (radioCatch.checked) return 'catchAll'; + return null; + } + + function getDisplayMode(): EmailMode { + return getMode() ?? preferredMode; + } + + function applyModeSelection(mode: SelectedMode): void { + radioPlus.checked = mode === 'plusAddressing'; + radioCatch.checked = mode === 'catchAll'; + colPlus.classList.toggle('selected', mode === 'plusAddressing'); + colCatch.classList.toggle('selected', mode === 'catchAll'); + } + + function clearModeSelection(): void { + applyModeSelection(null); + } + + function restorePreferredModeSelection(): void { + if (getMode()) return; + + if (preferredMode === 'plusAddressing' && !colPlus.classList.contains('disabled')) { + applyModeSelection('plusAddressing'); + return; + } + + if (preferredMode === 'catchAll' && !colCatch.classList.contains('disabled')) { + applyModeSelection('catchAll'); + return; + } + + if (!colPlus.classList.contains('disabled')) { + applyModeSelection('plusAddressing'); + return; + } + + if (!colCatch.classList.contains('disabled')) { + applyModeSelection('catchAll'); + } + } + + function getDisabledModesForFullEmail( + domain: string, + finalStatus: ProviderStatus, + ): { plus: boolean; catchAll: boolean } { + const isKnownProvider = getProviderStatus(domain) !== 'custom'; + return { + plus: isKnownProvider && finalStatus === 'plus-unsupported', + catchAll: isKnownProvider, + }; + } + + function getCurrentDraft(): SettingsDraft | null { + const mode = getMode(); + if (!mode) return null; + return createSettingsDraft(input.value, mode); + } + + function clearStatus(): void { + if (statusTimer) { + clearTimeout(statusTimer); + statusTimer = null; + } + statusEl.textContent = ''; + statusEl.className = 'status'; + } + + function clearInlineError(): void { + if (statusEl.classList.contains('error')) { + clearStatus(); + } + } + + function setSaveIndicator(state: SaveIndicatorState): void { + saveStateEl.hidden = false; + saveStateEl.dataset.state = state; + saveStateEl.className = `save-state-button is-${state}`; + saveStateEl.textContent = getSaveIndicatorLabel(state); + } + + function syncSaveIndicatorFromDraft(): void { + if (isLoading) return; + + const currentDraft = getCurrentDraft(); + if ( + !activeSavePromise && + pendingDraft == null && + areSettingsDraftsEqual(currentDraft, lastSavedDraft) + ) { + setSaveIndicator('saved'); + return; + } + + setSaveIndicator('editing'); + } + + function clearScheduledSave(): void { + if (saveDelayTimer) { + clearTimeout(saveDelayTimer); + saveDelayTimer = null; + } + } + + function getInputState(value = input.value): InputState { + const trimmedValue = value.trim(); + const localPart = extractLocalPart(trimmedValue); + const domain = extractDomainFromEmail(trimmedValue)?.toLowerCase() ?? null; + const normalizedDomain = trimmedValue.replace(/^@/, '').toLowerCase(); + const isFullEmail = trimmedValue.includes('@') && domain != null && domainRegex.test(domain); + + return { + trimmedValue, + normalizedDomain, + localPart, + domain, + isFullEmail, + plusAllowed: isFullEmail, + catchAllAllowed: isFullEmail || domainRegex.test(normalizedDomain), + }; + } + + function setMode(mode: EmailMode, options: { persist?: boolean } = {}): void { + const { persist = true } = options; + // Don't allow selecting a disabled mode + const col = mode === 'plusAddressing' ? colPlus : colCatch; + if (col.classList.contains('disabled')) return; + + preferredMode = mode; + applyModeSelection(mode); + updateFormatDisplay(); + updateExamples(); + if (persist) { + syncSaveIndicatorFromDraft(); + void requestSave({ immediate: true }); + } + } + + function setColumnDisabled(col: HTMLDivElement, disabled: boolean): void { + col.classList.toggle('disabled', disabled); + col.setAttribute('aria-disabled', disabled ? 'true' : 'false'); + } + + function setFeedback(state: 'info' | 'warning' | 'clear', message: string): void { + modeFeedbackEl.className = 'mode-feedback'; + modeFeedbackEl.textContent = ''; + if (state === 'clear') { + modeFeedbackEl.classList.add('is-empty'); + modeFeedbackEl.setAttribute('aria-hidden', 'true'); + return; + } + modeFeedbackEl.setAttribute('aria-hidden', 'false'); + if (state === 'warning') { + modeFeedbackEl.classList.add('feedback-warning'); + } + modeFeedbackEl.textContent = message; + } + + function setIndicator( + el: HTMLSpanElement, + state: 'supported' | 'possible' | 'incompatible' | null, + ): void { + el.className = 'req-indicator'; + if (state) el.classList.add(`req-${state}`); + } + + function updateRequirementIndicators( + syncStatus: ProviderStatus, + mxProviderFound: boolean, + finalStatus: ProviderStatus, + providerName: string | null, + ): void { + const isCustomDomain = syncStatus === 'custom'; + const providerDetected = !isCustomDomain || mxProviderFound; + + // Plus-Addressing: Email Provider + setIndicator(plusProviderEl, providerDetected ? 'supported' : 'incompatible'); + plusProviderValueEl.textContent = providerDetected + ? (providerName ?? 'Detected') + : 'Not Detected'; + + // Plus-Addressing: Plus-Addressing Supported + if (finalStatus === 'plus-supported') { + setIndicator(plusSupportEl, 'supported'); + plusSupportValueEl.textContent = 'Supported'; + } else if (finalStatus === 'plus-unsupported') { + setIndicator(plusSupportEl, 'incompatible'); + plusSupportValueEl.textContent = 'Not Supported'; + } else { + setIndicator(plusSupportEl, 'possible'); + plusSupportValueEl.textContent = 'Possible'; + } + + // Catch-All: Custom Domain + if (!isCustomDomain) { + setIndicator(catchAllDomainEl, 'incompatible'); + catchAllDomainValueEl.textContent = 'No'; + } else if (mxProviderFound) { + setIndicator(catchAllDomainEl, 'supported'); + catchAllDomainValueEl.textContent = 'Yes'; + } else { + setIndicator(catchAllDomainEl, 'possible'); + catchAllDomainValueEl.textContent = 'Possible'; + } + + // Catch-All: Catch-All Enabled + if (isCustomDomain) { + setIndicator(catchAllEnabledEl, 'possible'); + catchAllEnabledValueEl.textContent = 'Possible'; + showCatchAllInfoIcon(); + } else { + setIndicator(catchAllEnabledEl, 'incompatible'); + catchAllEnabledValueEl.textContent = 'Not Available'; + hideCatchAllInfoIcon(); + } + } + + function resetRequirementIndicators(): void { + setIndicator(plusProviderEl, null); + setIndicator(plusSupportEl, null); + setIndicator(catchAllDomainEl, null); + setIndicator(catchAllEnabledEl, null); + plusProviderValueEl.textContent = '--'; + plusSupportValueEl.textContent = '--'; + catchAllDomainValueEl.textContent = '--'; + catchAllEnabledValueEl.textContent = '--'; + hideCatchAllInfoIcon(); + } + + function showCatchAllInfoIcon(): void { + catchAllInfoIconEl.style.display = 'inline-flex'; + } + + function hideCatchAllInfoIcon(): void { + catchAllInfoIconEl.style.display = 'none'; + } + + function renderHelpPage(): void { + helpContainerEl.innerHTML = ''; + const allInstructions = getAllCatchAllInstructions(); + + for (const { key, instructions } of allInstructions) { + const isDetected = currentDetectedProvider === key; + const isWarning = key === 'icloud'; + const collapsed = !isDetected; + + const card = document.createElement('div'); + card.className = `help-provider-card${collapsed ? ' collapsed' : ''}`; + + const header = document.createElement('div'); + header.className = `help-provider-header${isDetected ? ' detected' : ''}`; + header.innerHTML = `${escapeHtml(instructions.providerName)}${isDetected ? 'Detected' : ''}`; + header.addEventListener('click', () => { + card.classList.toggle('collapsed'); + }); + + const body = document.createElement('div'); + body.className = `catch-all-instructions${isWarning ? ' warning' : ''}`; + + const ol = document.createElement('ol'); + for (const step of instructions.steps) { + const li = document.createElement('li'); + li.textContent = step; + ol.appendChild(li); + } + body.appendChild(ol); + + if (instructions.adminUrl) { + const linksDiv = document.createElement('div'); + linksDiv.className = 'catch-all-links'; + const a = document.createElement('a'); + a.href = instructions.adminUrl; + a.target = '_blank'; + a.rel = 'noopener noreferrer'; + a.textContent = `Open ${instructions.providerName} Admin`; + linksDiv.appendChild(a); + body.appendChild(linksDiv); + } + + if (instructions.notes) { + const note = document.createElement('p'); + note.className = 'catch-all-note'; + note.textContent = instructions.notes; + body.appendChild(note); + } + + card.appendChild(header); + card.appendChild(body); + helpContainerEl.appendChild(card); + } + } + + function showProviderPlaceholder(): void { + providerPlaceholderEl.style.display = ''; + } + + function hideProviderPlaceholder(): void { + providerPlaceholderEl.style.display = 'none'; + } + + function applyProviderStatus( + domain: string, + status: ProviderStatus, + mxResult: MxLookupResult | null, + ): void { + // Provider detection display + const syncStatus = getProviderStatus(domain); + let providerName: string | null = null; + if (mxResult?.provider) { + const info = getProviderInfo(mxResult.provider); + const logoKey = DETECTED_PROVIDER_TO_LOGO[mxResult.provider] ?? null; + providerName = info.name; + showProviderDetection(info.name, logoKey); + } else if (syncStatus !== 'custom') { + const friendlyName = DOMAIN_TO_FRIENDLY_NAME[domain] ?? domain; + const logoKey = DOMAIN_TO_PROVIDER[domain] ?? null; + providerName = friendlyName; + showProviderDetection(friendlyName, logoKey); + } else if (mxResult) { + hideProviderDetection(); + } + + // Track detected provider for catch-all instructions + currentDetectedProvider = mxResult?.provider ?? null; + + // Requirement indicators + updateRequirementIndicators(syncStatus, mxResult?.provider != null, status, providerName); + + const disabled = getDisabledModesForFullEmail(domain, status); + setColumnDisabled(colPlus, disabled.plus); + setColumnDisabled(colCatch, disabled.catchAll); + if ( + (disabled.plus && getMode() === 'plusAddressing') || + (disabled.catchAll && getMode() === 'catchAll') + ) { + clearModeSelection(); + } + restorePreferredModeSelection(); + + if (disabled.plus && disabled.catchAll) { + setFeedback('warning', FEEDBACK_MESSAGES.unsupportedProvider); + return; + } + + // Feedback bar + if (status === 'plus-unsupported') { + setFeedback( + 'warning', + syncStatus !== 'custom' + ? FEEDBACK_MESSAGES.unsupportedPlusAddressing + : FEEDBACK_MESSAGES.possiblyUnsupportedPlusAddressing, + ); + } else if (disabled.catchAll) { + setFeedback('info', FEEDBACK_MESSAGES.catchAllRequiresCustomDomain); + } else { + setFeedback('clear', ''); + } + } + + function showProviderLogo(logoKey: string | null): void { + const file = logoKey ? PROVIDER_LOGO_FILES[logoKey] : null; + if (file) { + providerLogoEl.innerHTML = ``; + providerLogoEl.style.display = 'inline-flex'; + } else { + providerLogoEl.innerHTML = ''; + providerLogoEl.style.display = 'none'; + } + } + + function showProviderLoading(): void { + hideProviderPlaceholder(); + showProviderLogo(null); + providerDetectedEl.style.display = 'flex'; + providerDetectedEl.className = 'provider-detected loading'; + providerTextEl.textContent = 'Checking email provider...'; + } + + function showProviderDetection(providerName: string, logoKey: string | null): void { + hideProviderPlaceholder(); + showProviderLogo(logoKey); + providerDetectedEl.style.display = 'flex'; + providerDetectionBoxEl.classList.add('detected'); + providerDetectedEl.className = 'provider-detected'; + providerTextEl.textContent = providerName; + } + + function hideProviderDetection(): void { + providerDetectedEl.style.display = 'none'; + showProviderLogo(null); + providerDetectionBoxEl.classList.remove('detected'); + showProviderPlaceholder(); + currentLookupDomain = null; + } + + function applyImmediateInputState(state: InputState): void { + hideProviderDetection(); + resetRequirementIndicators(); + + if (state.plusAllowed) { + const syncStatus = getProviderStatus(state.domain as string); + const disabled = getDisabledModesForFullEmail(state.domain as string, syncStatus); + setColumnDisabled(colPlus, disabled.plus); + setColumnDisabled(colCatch, disabled.catchAll); + if ( + (disabled.plus && getMode() === 'plusAddressing') || + (disabled.catchAll && getMode() === 'catchAll') + ) { + clearModeSelection(); + } + restorePreferredModeSelection(); + if (disabled.plus && disabled.catchAll) { + setFeedback('warning', FEEDBACK_MESSAGES.unsupportedProvider); + } else if (disabled.catchAll) { + setFeedback('info', FEEDBACK_MESSAGES.catchAllRequiresCustomDomain); + } else { + setFeedback('clear', ''); + } + } else if (!state.trimmedValue) { + setColumnDisabled(colPlus, true); + setColumnDisabled(colCatch, true); + setFeedback('info', FEEDBACK_MESSAGES.enterEmailOrDomain); + } else if (!state.catchAllAllowed) { + setColumnDisabled(colPlus, true); + setColumnDisabled(colCatch, true); + setFeedback('info', FEEDBACK_MESSAGES.enterValidEmailOrDomain); + } else { + const isKnownProvider = getProviderStatus(state.normalizedDomain) !== 'custom'; + setColumnDisabled(colPlus, true); + setColumnDisabled(colCatch, isKnownProvider); + if (isKnownProvider) { + clearModeSelection(); + setFeedback('info', FEEDBACK_MESSAGES.catchAllRequiresCustomDomain); + } else { + setFeedback('info', FEEDBACK_MESSAGES.plusRequiresFullEmail); + if (getMode() === 'plusAddressing') { + setMode('catchAll', { persist: false }); + } else { + restorePreferredModeSelection(); + } + } + } + + updateFormatDisplay(state); + updateExamples(state); + } + + function updateModeAvailability(state = getInputState()): void { + hideProviderDetection(); + resetRequirementIndicators(); + + if (!state.trimmedValue) { + return; + } + + if (!state.isFullEmail) { + // Domain-only input: Plus-Addressing stays disabled, but provider detection still runs + const cleanValue = state.normalizedDomain; + + if (!state.catchAllAllowed) { + return; + } + + // Synchronous provider detection + const syncStatus = getProviderStatus(cleanValue); + const isCustomDomain = syncStatus === 'custom'; + let providerName: string | null = null; + + if (!isCustomDomain) { + const friendlyName = DOMAIN_TO_FRIENDLY_NAME[cleanValue] ?? cleanValue; + const logoKey = DOMAIN_TO_PROVIDER[cleanValue] ?? null; + providerName = friendlyName; + showProviderDetection(friendlyName, logoKey); + } + + // Plus-Addressing indicators (informational, column stays disabled) + setIndicator(plusProviderEl, !isCustomDomain ? 'supported' : null); + plusProviderValueEl.textContent = !isCustomDomain ? (providerName ?? 'Detected') : '--'; + if (syncStatus === 'plus-supported') { + setIndicator(plusSupportEl, 'supported'); + plusSupportValueEl.textContent = 'Supported'; + } else if (syncStatus === 'plus-unsupported') { + setIndicator(plusSupportEl, 'incompatible'); + plusSupportValueEl.textContent = 'Not Supported'; + } else { + setIndicator(plusSupportEl, null); + plusSupportValueEl.textContent = '--'; + } + + // Catch-All indicators + column state + if (!isCustomDomain) { + setColumnDisabled(colCatch, true); + if (getMode() === 'catchAll') { + clearModeSelection(); + } + setIndicator(catchAllDomainEl, 'incompatible'); + catchAllDomainValueEl.textContent = 'No'; + setIndicator(catchAllEnabledEl, 'incompatible'); + catchAllEnabledValueEl.textContent = 'Not Available'; + hideCatchAllInfoIcon(); + } else { + setIndicator(catchAllDomainEl, 'supported'); + catchAllDomainValueEl.textContent = 'Yes'; + setIndicator(catchAllEnabledEl, 'possible'); + catchAllEnabledValueEl.textContent = 'Possible'; + showCatchAllInfoIcon(); + + // Async MX lookup for custom domains + currentLookupDomain = cleanValue; + showProviderLoading(); + getProviderStatusWithMx(cleanValue) + .then(({ status: mxStatus, mxResult }) => { + if (currentLookupDomain !== cleanValue) return; + + if (mxResult?.provider) { + const info = getProviderInfo(mxResult.provider); + const logoKey = DETECTED_PROVIDER_TO_LOGO[mxResult.provider] ?? null; + showProviderDetection(info.name, logoKey); + + setIndicator(plusProviderEl, 'supported'); + plusProviderValueEl.textContent = info.name; + setIndicator(catchAllDomainEl, 'supported'); + catchAllDomainValueEl.textContent = 'Yes'; + } else if (mxResult) { + hideProviderDetection(); + setIndicator(plusProviderEl, 'incompatible'); + plusProviderValueEl.textContent = 'Not Detected'; + setIndicator(catchAllDomainEl, 'possible'); + catchAllDomainValueEl.textContent = 'Possible'; + } + + if (mxStatus === 'plus-supported') { + setIndicator(plusSupportEl, 'supported'); + plusSupportValueEl.textContent = 'Supported'; + } else if (mxStatus === 'plus-unsupported') { + setIndicator(plusSupportEl, 'incompatible'); + plusSupportValueEl.textContent = 'Not Supported'; + } else { + setIndicator(plusSupportEl, 'possible'); + plusSupportValueEl.textContent = 'Possible'; + } + + currentDetectedProvider = mxResult?.provider ?? null; + showCatchAllInfoIcon(); + }) + .catch(() => { + if (currentLookupDomain === cleanValue) { + hideProviderDetection(); + } + }); + } + return; + } + + // Synchronous check first + const status = getProviderStatus(state.domain as string); + applyProviderStatus(state.domain as string, status, null); + + // If custom domain, try MX lookup + if (status === 'custom') { + currentLookupDomain = state.domain as string; + showProviderLoading(); + getProviderStatusWithMx(state.domain as string) + .then(({ status: mxStatus, mxResult }) => { + if (currentLookupDomain === state.domain) { + applyProviderStatus(state.domain as string, mxStatus, mxResult); + } + }) + .catch(() => { + if (currentLookupDomain === state.domain) { + hideProviderDetection(); + } + }); + } + } + + function updateFormatDisplay(state = getInputState()): void { + const mode = getDisplayMode(); + + if (mode === 'plusAddressing') { + plusFormatEl.textContent = `${state.localPart || 'name'}+example.com@${state.domain || 'gmail.com'}`; + } else { + catchAllFormatEl.textContent = `example.com@${state.domain || state.trimmedValue || 'yourdomain.com'}`; + } + } + + function updateExamples(state = getInputState()): void { + const mode = getDisplayMode(); + + if (mode === 'plusAddressing') { + for (let i = 0; i < exampleEls.length; i++) { + const site = exampleEls[i].dataset.site; + if (site) + exampleEls[i].textContent = + `${state.localPart || 'name'}+${site}@${state.domain || 'gmail.com'}`; + } + } else { + for (let i = 0; i < exampleEls.length; i++) { + const site = exampleEls[i].dataset.site; + if (site) { + exampleEls[i].textContent = + `${site}@${state.domain || state.trimmedValue || 'yourdomain.com'}`; + } + } + } + } + + async function loadSettings(profileEmail: string | null): Promise { + try { + const result = await chrome.storage.sync.get(['emailDomain', 'emailMode', 'baseEmail']); + const hasSavedSettings = result.emailMode || result.emailDomain || result.baseEmail; + + if (hasSavedSettings) { + const mode: EmailMode = (result.emailMode as EmailMode) ?? 'catchAll'; + preferredMode = mode; + if (result.baseEmail) { + input.value = result.baseEmail as string; + } else if (result.emailDomain) { + input.value = result.emailDomain as string; + } + const state = getInputState(); + applyImmediateInputState(state); + updateModeAvailability(state); + setMode(mode, { persist: false }); + lastSavedDraft = createSettingsDraft(input.value, mode); + setSaveIndicator(lastSavedDraft ? 'saved' : 'editing'); + } else if (profileEmail) { + // No saved settings, auto-configure with Chrome profile email + preferredMode = 'plusAddressing'; + input.value = profileEmail; + const state = getInputState(); + applyImmediateInputState(state); + updateModeAvailability(state); + setMode('plusAddressing', { persist: false }); + await requestSave({ immediate: true }); + showStatus('Settings auto-configured from your Chrome profile', 'success'); + } else { + preferredMode = 'plusAddressing'; + const state = getInputState(); + applyImmediateInputState(state); + updateModeAvailability(state); + lastSavedDraft = null; + setSaveIndicator('editing'); + } + } catch (error) { + console.error('Failed to load settings:', error); + setSaveIndicator('error'); + showStatus('Failed to load settings', 'error'); + } + } + + async function persistDraft(draft: SettingsDraft): Promise { + await chrome.storage.sync.set(draft.storagePayload); + + if (!('baseEmail' in draft.storagePayload)) { + await chrome.storage.sync.remove(['baseEmail']); + } + + if (input.value !== draft.canonicalInputValue) { + input.value = draft.canonicalInputValue; + const normalizedState = getInputState(); + applyImmediateInputState(normalizedState); + updateModeAvailability(normalizedState); + } + } + + async function flushPendingSave(): Promise { + if (activeSavePromise) return activeSavePromise; + + activeSavePromise = (async () => { + while (pendingDraft) { + const draftToSave = pendingDraft; + pendingDraft = null; + setSaveIndicator('saving'); + + try { + await persistDraft(draftToSave); + lastSavedDraft = draftToSave; + clearInlineError(); + + const currentDraft = getCurrentDraft(); + if (pendingDraft == null && areSettingsDraftsEqual(currentDraft, draftToSave)) { + setSaveIndicator('saved'); + } else { + setSaveIndicator('editing'); + } + } catch (error) { + setSaveIndicator('error'); + showStatus( + `Error saving settings: ${error instanceof Error ? error.message : 'Unknown error'}`, + 'error', + ); + + if (pendingDraft == null) { + break; + } + } + } + })().finally(() => { + activeSavePromise = null; + if (pendingDraft) { + void flushPendingSave(); + } + }); + + return activeSavePromise; + } + + function requestSave(options: { immediate: boolean }): Promise | void { + const { immediate } = options; + const draft = getCurrentDraft(); + + if (!draft) { + pendingDraft = null; + clearScheduledSave(); + if (!activeSavePromise) { + setSaveIndicator('editing'); + } + return; + } + + if (!activeSavePromise && areSettingsDraftsEqual(draft, lastSavedDraft)) { + pendingDraft = null; + clearScheduledSave(); + setSaveIndicator('saved'); + return; + } + + pendingDraft = draft; + + if (immediate) { + clearScheduledSave(); + return flushPendingSave(); + } + + clearScheduledSave(); + saveDelayTimer = setTimeout(() => { + saveDelayTimer = null; + void flushPendingSave(); + }, 300); + } + + async function loadChromeProfileEmail(): Promise { + try { + const userInfo = await chrome.identity.getProfileUserInfo({ accountStatus: 'ANY' }); + if (userInfo.email) { + profileEmailEl.textContent = userInfo.email; + chromeDetectionBoxEl.classList.add('detected'); + return userInfo.email; + } + } catch { + // Silently fail + } + return null; + } + + async function importChromeEmail(): Promise { + const email = profileEmailEl.textContent; + if (!email || email === 'Not detected') return; + input.value = email; + const state = getInputState(); + applyImmediateInputState(state); + updateModeAvailability(state); + showStatus('Email imported', 'success'); + await requestSave({ immediate: true }); + } + + function selectRecommendedMode(): void { + if (!colPlus.classList.contains('disabled')) { + setMode('plusAddressing'); + } else if (!colCatch.classList.contains('disabled')) { + setMode('catchAll'); + } + } + + function showStatus(message: string, type: 'success' | 'error'): void { + if (statusTimer) { + clearTimeout(statusTimer); + } + statusEl.textContent = message; + statusEl.className = `status ${type}`; + + statusTimer = setTimeout(() => { + statusTimer = null; + clearStatus(); + }, 3000); + } + + const debouncedUpdate = debounce + ? debounce(() => { + const state = getInputState(); + updateModeAvailability(state); + }, 300) + : () => { + const state = getInputState(); + updateModeAvailability(state); + }; + + // Settings event listeners + formEl.addEventListener('submit', (e) => e.preventDefault()); + profileEmailEl.addEventListener('click', () => { + void importChromeEmail(); + }); + providerDetectedEl.addEventListener('click', selectRecommendedMode); + input.addEventListener('input', () => { + const state = getInputState(); + applyImmediateInputState(state); + syncSaveIndicatorFromDraft(); + debouncedUpdate(); + void requestSave({ immediate: false }); + }); + + colPlus.addEventListener('click', () => setMode('plusAddressing')); + colCatch.addEventListener('click', () => setMode('catchAll')); + catchAllInfoIconEl.addEventListener('click', (e) => { + e.stopPropagation(); + switchPage('help'); + }); + + // ── History Page ── + const historyBody = document.getElementById('historyBody') as HTMLTableSectionElement; + const historyTable = document.getElementById('historyTable') as HTMLTableElement; + const historyEmpty = document.getElementById('historyEmpty') as HTMLDivElement; + const historySearch = document.getElementById('historySearch') as HTMLInputElement; + const clearHistoryButton = document.getElementById('clearHistoryButton') as HTMLButtonElement; + + function formatDate(iso: string): string { + const d = new Date(iso); + return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); + } + + function renderHistory(entries: EmailHistoryEntry[]): void { + historyBody.innerHTML = ''; + + if (entries.length === 0) { + historyTable.style.display = 'none'; + historyEmpty.style.display = 'block'; + return; + } + + historyTable.style.display = ''; + historyEmpty.style.display = 'none'; + + for (const entry of entries) { + const tr = document.createElement('tr'); + tr.innerHTML = ` + ${escapeHtml(entry.domain)} + ${escapeHtml(entry.email)} + ${formatDate(entry.createdAt)} + + + + + `; + historyBody.appendChild(tr); + } + } + + function escapeHtml(str: string): string { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + } + + function escapeAttr(str: string): string { + return str.replace(/&/g, '&').replace(/"/g, '"'); + } + + async function loadHistory(): Promise { + const { emailHistory = [] } = await chrome.storage.local.get('emailHistory'); + let entries = emailHistory as EmailHistoryEntry[]; + + const searchTerm = historySearch.value.trim().toLowerCase(); + if (searchTerm) { + entries = entries.filter( + (e) => + e.domain.toLowerCase().includes(searchTerm) || e.email.toLowerCase().includes(searchTerm), + ); + } + + renderHistory(entries); + } + + historyBody.addEventListener('click', async (e) => { + const target = e.target as HTMLElement; + + if (target.classList.contains('btn-copy')) { + const email = target.dataset.email; + if (email) { + await navigator.clipboard.writeText(email); + target.textContent = 'Copied!'; + setTimeout(() => { + target.textContent = 'Copy'; + }, 1500); + } + } + + if (target.classList.contains('btn-delete')) { + const id = target.dataset.id; + if (id) { + const { emailHistory = [] } = await chrome.storage.local.get('emailHistory'); + const updated = (emailHistory as EmailHistoryEntry[]).filter((e) => e.id !== id); + await chrome.storage.local.set({ emailHistory: updated }); + await loadHistory(); + } + } + }); + + clearHistoryButton.addEventListener('click', async () => { + if (confirm('Are you sure you want to clear all history?')) { + await chrome.storage.local.remove('emailHistory'); + await loadHistory(); + } + }); + + const debouncedHistorySearch = debounce + ? debounce(() => loadHistory(), 300) + : () => loadHistory(); + + historySearch.addEventListener('input', debouncedHistorySearch); + + // ── Initialize ── + const profileEmail = await loadChromeProfileEmail(); + await loadSettings(profileEmail); + isLoading = false; +}); diff --git a/src/ui/popup.html b/src/ui/popup.html new file mode 100644 index 0000000..fce1f90 --- /dev/null +++ b/src/ui/popup.html @@ -0,0 +1,154 @@ + + + + + + Clean Autofill + + + + +
    + +

    Clean Autofill

    +
    + +
    Generating email...
    + +
    + +
    +
    + +
    + +
    + Please configure your email first. +
    + + + + diff --git a/src/ui/popup.test.ts b/src/ui/popup.test.ts new file mode 100644 index 0000000..902cd31 --- /dev/null +++ b/src/ui/popup.test.ts @@ -0,0 +1,267 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'; + +import type { GenerateAndFillResponse } from '../types'; + +// Mock chrome API — must be set up before dynamic import of popup.ts +let mockResponse: GenerateAndFillResponse | undefined; + +const mockChrome = { + runtime: { + sendMessage: mock( + ( + _request: Record, + callback: (response: GenerateAndFillResponse) => void, + ) => { + callback(mockResponse as GenerateAndFillResponse); + }, + ), + openOptionsPage: mock(() => {}), + lastError: null as { message: string } | null, + }, +}; + +(globalThis as Record).chrome = mockChrome; + +// Mock navigator.clipboard +let clipboardContent = ''; +const mockClipboard = { + writeText: mock(async (text: string) => { + clipboardContent = text; + }), +}; +Object.defineProperty(navigator, 'clipboard', { value: mockClipboard, writable: true }); + +// Mock window.close +window.close = mock(() => {}); + +function setupPopupDOM(): void { + document.body.innerHTML = ` +
    Generating email...
    + + + + `; +} + +function getElements() { + return { + loading: document.getElementById('loading') as HTMLDivElement, + result: document.getElementById('result') as HTMLDivElement, + emailDisplay: document.getElementById('emailDisplay') as HTMLSpanElement, + copyButton: document.getElementById('copyButton') as HTMLButtonElement, + statusMessage: document.getElementById('statusMessage') as HTMLDivElement, + errorDiv: document.getElementById('error') as HTMLDivElement, + configPrompt: document.getElementById('configPrompt') as HTMLDivElement, + configLink: document.getElementById('configLink') as HTMLAnchorElement, + }; +} + +// Dynamic import so mocks are in place before popup.ts module-level init() runs +let init: () => void; +beforeAll(async () => { + setupPopupDOM(); + mockResponse = { success: true, email: 'setup@test.com' }; + const mod = await import('./popup.js'); + init = mod.init; +}); + +beforeEach(() => { + mockResponse = undefined; + clipboardContent = ''; + mockChrome.runtime.lastError = null; + mockChrome.runtime.sendMessage.mockClear(); + mockChrome.runtime.openOptionsPage.mockClear(); + mockClipboard.writeText.mockClear(); + (window.close as ReturnType).mockClear(); + setupPopupDOM(); +}); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('popup message protocol', () => { + test('sends generateAndFill action on load', () => { + mockResponse = { success: true, email: 'test@test.com' }; + init(); + + expect(mockChrome.runtime.sendMessage).toHaveBeenCalledTimes(1); + const call = mockChrome.runtime.sendMessage.mock.calls[0]; + expect(call[0]).toEqual({ action: 'generateAndFill' }); + }); +}); + +describe('popup UI states', () => { + test('shows email on successful response', () => { + mockResponse = { + success: true, + email: 'example.com@mydomain.com', + message: 'Email filled successfully', + }; + init(); + + const els = getElements(); + expect(els.loading.style.display).toBe('none'); + expect(els.result.style.display).toBe('block'); + expect(els.emailDisplay.textContent).toBe('example.com@mydomain.com'); + expect(els.statusMessage.textContent).toBe('Email filled successfully'); + }); + + test('shows config prompt when needsConfig is true', () => { + mockResponse = { success: false, needsConfig: true }; + init(); + + const els = getElements(); + expect(els.loading.style.display).toBe('none'); + expect(els.configPrompt.style.display).toBe('block'); + expect(els.result.style.display).toBe('none'); + }); + + test('shows error message on failure', () => { + mockResponse = { + success: false, + error: "Email addresses can't be generated on browser pages.", + }; + init(); + + const els = getElements(); + expect(els.loading.style.display).toBe('none'); + expect(els.errorDiv.style.display).toBe('block'); + expect(els.errorDiv.textContent).toBe("Email addresses can't be generated on browser pages."); + expect(els.result.style.display).toBe('none'); + }); + + test('shows email even when fill fails', () => { + mockResponse = { + success: true, + email: 'example.com@mydomain.com', + message: 'Email generated (no field found to fill)', + }; + init(); + + const els = getElements(); + expect(els.result.style.display).toBe('block'); + expect(els.emailDisplay.textContent).toBe('example.com@mydomain.com'); + expect(els.statusMessage.textContent).toBe('Email generated (no field found to fill)'); + }); + + test('shows error when lastError is set', () => { + mockChrome.runtime.lastError = { message: 'Extension context invalidated' }; + mockResponse = { success: true, email: 'test@test.com' }; + init(); + + const els = getElements(); + expect(els.loading.style.display).toBe('none'); + expect(els.errorDiv.style.display).toBe('block'); + expect(els.errorDiv.textContent).toBe('Unable to generate an email. Please try again.'); + }); + + test('shows error when response is undefined', () => { + mockResponse = undefined; + init(); + + const els = getElements(); + expect(els.loading.style.display).toBe('none'); + expect(els.errorDiv.style.display).toBe('block'); + expect(els.errorDiv.textContent).toBe('No response from the extension. Please try again.'); + }); + + test('shows default error when response has no error message', () => { + mockResponse = { success: false }; + init(); + + const els = getElements(); + expect(els.errorDiv.textContent).toBe('Failed to generate an email.'); + }); +}); + +describe('copy button', () => { + test('copies email to clipboard on click', async () => { + mockResponse = { success: true, email: 'example.com@mydomain.com' }; + init(); + + const els = getElements(); + els.copyButton.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockClipboard.writeText).toHaveBeenCalledWith('example.com@mydomain.com'); + expect(clipboardContent).toBe('example.com@mydomain.com'); + expect(els.copyButton.textContent).toBe('Copied!'); + expect(els.copyButton.classList.contains('copied')).toBe(true); + }); + + test('shows a red error message when copy fails', async () => { + mockClipboard.writeText.mockImplementationOnce(async () => { + throw new Error('Clipboard denied'); + }); + mockResponse = { success: true, email: 'example.com@mydomain.com' }; + init(); + + const els = getElements(); + els.copyButton.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(els.errorDiv.style.display).toBe('block'); + expect(els.errorDiv.textContent).toBe('Failed to copy.'); + }); +}); + +describe('config link', () => { + test('opens options page and closes popup on click', () => { + mockResponse = { success: false, needsConfig: true }; + init(); + + const els = getElements(); + els.configLink.click(); + + expect(mockChrome.runtime.openOptionsPage).toHaveBeenCalledTimes(1); + expect(window.close).toHaveBeenCalledTimes(1); + }); +}); + +describe('GenerateAndFillResponse shape', () => { + test('success response has required fields', () => { + const response: GenerateAndFillResponse = { + success: true, + email: 'test.com@domain.com', + }; + expect(response.success).toBe(true); + expect(response.email).toBe('test.com@domain.com'); + }); + + test('needsConfig response', () => { + const response: GenerateAndFillResponse = { + success: false, + needsConfig: true, + }; + expect(response.success).toBe(false); + expect(response.needsConfig).toBe(true); + expect(response.email).toBeUndefined(); + }); + + test('error response', () => { + const response: GenerateAndFillResponse = { + success: false, + error: 'Something went wrong', + }; + expect(response.success).toBe(false); + expect(response.error).toBe('Something went wrong'); + }); + + test('success with fill failure still includes email', () => { + const response: GenerateAndFillResponse = { + success: true, + email: 'github.com@mg.de', + message: 'Email generated (please refresh the page to autofill)', + }; + expect(response.success).toBe(true); + expect(response.email).toBe('github.com@mg.de'); + expect(response.message).toContain('refresh'); + }); +}); diff --git a/src/ui/popup.ts b/src/ui/popup.ts new file mode 100644 index 0000000..4c7dc6f --- /dev/null +++ b/src/ui/popup.ts @@ -0,0 +1,97 @@ +import type { GenerateAndFillResponse } from '../types'; + +const POPUP_MESSAGES = { + unableToGenerate: 'Unable to generate an email. Please try again.', + noResponse: 'No response from the extension. Please try again.', + failedToGenerate: 'Failed to generate an email.', + failedToCopy: 'Failed to copy.', +} as const; + +export function init(): void { + const loading = document.getElementById('loading') as HTMLDivElement; + const result = document.getElementById('result') as HTMLDivElement; + const emailDisplay = document.getElementById('emailDisplay') as HTMLSpanElement; + const copyButton = document.getElementById('copyButton') as HTMLButtonElement; + const statusMessage = document.getElementById('statusMessage') as HTMLDivElement; + const errorDiv = document.getElementById('error') as HTMLDivElement; + const configPrompt = document.getElementById('configPrompt') as HTMLDivElement; + const configLink = document.getElementById('configLink') as HTMLAnchorElement; + + let generatedEmail = ''; + + // Request email generation and fill immediately on popup open + chrome.runtime.sendMessage({ action: 'generateAndFill' }, (response: GenerateAndFillResponse) => { + loading.style.display = 'none'; + showMessage('clear'); + + if (chrome.runtime.lastError) { + showMessage('error', POPUP_MESSAGES.unableToGenerate); + return; + } + + if (!response) { + showMessage('error', POPUP_MESSAGES.noResponse); + return; + } + + if (response.needsConfig) { + configPrompt.style.display = 'block'; + return; + } + + if (!response.success || !response.email) { + showMessage('error', response.error ?? POPUP_MESSAGES.failedToGenerate); + return; + } + + generatedEmail = response.email; + emailDisplay.textContent = generatedEmail; + result.style.display = 'block'; + + if (response.message) { + showMessage('status', response.message); + } + }); + + copyButton.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(generatedEmail); + copyButton.textContent = 'Copied!'; + copyButton.classList.add('copied'); + setTimeout(() => { + copyButton.textContent = 'Copy'; + copyButton.classList.remove('copied'); + }, 1500); + } catch { + showMessage('error', POPUP_MESSAGES.failedToCopy); + } + }); + + configLink.addEventListener('click', (e) => { + e.preventDefault(); + chrome.runtime.openOptionsPage(); + window.close(); + }); + + function showMessage(type: 'status' | 'error' | 'clear', message = ''): void { + if (type === 'status') { + statusMessage.textContent = message; + errorDiv.textContent = ''; + errorDiv.style.display = 'none'; + return; + } + + if (type === 'error') { + statusMessage.textContent = ''; + errorDiv.textContent = message; + errorDiv.style.display = 'block'; + return; + } + + statusMessage.textContent = ''; + errorDiv.textContent = ''; + errorDiv.style.display = 'none'; + } +} + +init(); diff --git a/toolkit/Conductor/run.sh b/toolkit/Conductor/run.sh index aa82ed7..35f7a99 100755 --- a/toolkit/Conductor/run.sh +++ b/toolkit/Conductor/run.sh @@ -1,6 +1,8 @@ #!/bin/bash # Conductor run script - builds the Chrome Extension +export PATH="$HOME/.bun/bin:$PATH" + # Check if dependencies are installed if [ ! -d "node_modules" ]; then echo "❌ Dependencies not installed. Run ./toolkit/Conductor/setup.sh first." diff --git a/toolkit/Conductor/setup.sh b/toolkit/Conductor/setup.sh index 76b63c2..e490174 100755 --- a/toolkit/Conductor/setup.sh +++ b/toolkit/Conductor/setup.sh @@ -2,6 +2,7 @@ # Conductor setup script - installs dependencies and builds the extension set -e +export PATH="$HOME/.bun/bin:$PATH" echo "📦 Installing dependencies..." bun install diff --git a/toolkit/bun/bunfig.toml b/toolkit/bun/bunfig.toml index a9a828f..9761365 100644 --- a/toolkit/bun/bunfig.toml +++ b/toolkit/bun/bunfig.toml @@ -1,3 +1,3 @@ [test] # Enable DOM support for content script tests -preload = ["../../src/test-setup.ts"] +preload = ["./toolkit/test/test-setup.ts"] diff --git a/toolkit/husky/pre-commit b/toolkit/husky/pre-commit index 07b3fd4..e1ba510 100755 --- a/toolkit/husky/pre-commit +++ b/toolkit/husky/pre-commit @@ -1,5 +1,7 @@ #!/bin/sh +export PATH="$HOME/.bun/bin:$PATH" + # Run checks before commit echo "🔍 Running pre-commit checks..." diff --git a/toolkit/scripts/build.js b/toolkit/scripts/build.js index c1bad8f..10ea080 100644 --- a/toolkit/scripts/build.js +++ b/toolkit/scripts/build.js @@ -12,10 +12,16 @@ console.log('🔨 Building Clean-Autofill Chrome Extension...\n'); // Check TypeScript source files first const requiredSourceFiles = [ - 'src/background.ts', - 'src/content.ts', - 'src/options.ts', - 'src/utils.ts', + 'src/extension/background.ts', + 'src/extension/autofill.ts', + 'src/ui/history.ts', + 'src/email/utils.ts', + 'src/ui/options.ts', + 'src/ui/options-preview.ts', + 'src/ui/popup.ts', + 'src/email/mx-lookup.ts', + 'src/email/provider-domains.ts', + 'src/email/providers.ts', 'src/types/index.ts', ]; @@ -59,13 +65,13 @@ try { console.log('\n📦 Bundling utils.js with dependencies...'); try { // ESM bundle for background.js (service worker) - execSync('npx esbuild dist/utils.js --bundle --outfile=dist/utils.esm.js --format=esm --platform=browser --minify', { cwd: ROOT, stdio: 'inherit' }); + execSync('npx esbuild dist/email/utils.js --bundle --outfile=dist/email/utils.esm.js --format=esm --platform=browser --minify', { cwd: ROOT, stdio: 'inherit' }); // IIFE bundle for content scripts (sets globalThis.CleanAutofillUtils) - execSync('npx esbuild dist/utils.js --bundle --outfile=dist/utils-content.js --format=iife --global-name=CleanAutofillUtils --platform=browser --minify', { cwd: ROOT, stdio: 'inherit' }); + execSync('npx esbuild dist/email/utils.js --bundle --outfile=dist/email/utils-content.js --format=iife --global-name=CleanAutofillUtils --platform=browser --minify', { cwd: ROOT, stdio: 'inherit' }); // Replace utils.js with ESM version for background.js imports - fs.renameSync(path.join(DIST, 'utils.esm.js'), path.join(DIST, 'utils.js')); - console.log(' ✅ utils.js (ESM for background.js)'); - console.log(' ✅ utils-content.js (IIFE for content scripts)'); + fs.renameSync(path.join(DIST, 'email', 'utils.esm.js'), path.join(DIST, 'email', 'utils.js')); + console.log(' ✅ email/utils.js (ESM for background.js)'); + console.log(' ✅ email/utils-content.js (IIFE for content scripts)'); } catch (error) { console.error(' ❌ Bundling failed:', error.message); process.exit(1); @@ -79,7 +85,7 @@ if (usesESModules) { console.log('\n🔧 ES modules enabled - processing scripts...'); // Strip exports from content script files (they use globalThis pattern) - const contentScriptFiles = ['content.js', 'options.js']; + const contentScriptFiles = ['extension/autofill.js', 'ui/options.js', 'ui/popup.js']; for (const file of contentScriptFiles) { const filePath = path.join(DIST, file); if (fs.existsSync(filePath)) { @@ -93,11 +99,11 @@ if (usesESModules) { console.log(` ✅ ${file} (stripped exports)`); } } - console.log(` ✅ background.js (ES module preserved)`); + console.log(` ✅ extension/background.js (ES module preserved)`); } else { // Strip ES module exports for classic script compatibility console.log('\n🔧 Stripping ES module exports for Chrome compatibility...'); - const jsFiles = ['background.js', 'content.js', 'utils.js', 'options.js']; + const jsFiles = ['extension/background.js', 'extension/autofill.js', 'email/utils.js', 'ui/options.js']; for (const file of jsFiles) { const filePath = path.join(DIST, file); if (fs.existsSync(filePath)) { @@ -120,9 +126,17 @@ console.log('\n📁 Copying static assets...'); fs.copyFileSync(path.join(ROOT, 'manifest.json'), path.join(DIST, 'manifest.json')); console.log(' ✅ manifest.json'); -// Copy options.html -fs.copyFileSync(path.join(SRC, 'options.html'), path.join(DIST, 'options.html')); -console.log(' ✅ options.html'); +// Copy UI HTML files +const uiDir = path.join(DIST, 'ui'); +fs.mkdirSync(uiDir, { recursive: true }); +fs.copyFileSync(path.join(SRC, 'ui', 'options.html'), path.join(uiDir, 'options.html')); +console.log(' ✅ ui/options.html'); +fs.copyFileSync(path.join(SRC, 'ui', 'popup.html'), path.join(uiDir, 'popup.html')); +console.log(' ✅ ui/popup.html'); +fs.copyFileSync(path.join(SRC, 'ui', 'message-tokens.css'), path.join(uiDir, 'message-tokens.css')); +console.log(' ✅ ui/message-tokens.css'); +fs.copyFileSync(path.join(SRC, 'ui', 'options.css'), path.join(uiDir, 'options.css')); +console.log(' ✅ ui/options.css'); // Copy icons const iconsDir = path.join(DIST, 'icons'); @@ -133,16 +147,37 @@ icons.forEach(icon => { console.log(` ✅ icons/${icon}`); }); +// Copy provider icons +const providerIconsSrc = path.join(SRC, 'icons', 'providers'); +const providerIconsDist = path.join(iconsDir, 'providers'); +if (fs.existsSync(providerIconsSrc)) { + fs.mkdirSync(providerIconsDist, { recursive: true }); + const providerIcons = fs.readdirSync(providerIconsSrc).filter(f => f.endsWith('.png')); + providerIcons.forEach(icon => { + fs.copyFileSync(path.join(providerIconsSrc, icon), path.join(providerIconsDist, icon)); + console.log(` ✅ icons/providers/${icon}`); + }); +} + // Verify compiled output console.log('\n📋 Verifying compiled files:'); const requiredCompiledFiles = [ - 'background.js', - 'content.js', - 'options.js', - 'utils.js', - 'utils-content.js', + 'extension/background.js', + 'extension/autofill.js', + 'ui/history.js', + 'email/utils.js', + 'email/utils-content.js', + 'email/mx-lookup.js', + 'email/provider-domains.js', + 'email/providers.js', 'manifest.json', - 'options.html', + 'ui/options.js', + 'ui/options-preview.js', + 'ui/options.html', + 'ui/message-tokens.css', + 'ui/options.css', + 'ui/popup.js', + 'ui/popup.html', 'icons/icon16.png', 'icons/icon32.png', 'icons/icon48.png', diff --git a/toolkit/scripts/validate.js b/toolkit/scripts/validate.js index f44f064..89c3f7f 100644 --- a/toolkit/scripts/validate.js +++ b/toolkit/scripts/validate.js @@ -73,10 +73,11 @@ try { // Check file sizes (compiled files in dist/) console.log('\n📏 Checking file sizes:'); const files = [ - { path: 'dist/background.js', maxSize: 1024 * 200 }, // 200KB (bundled) - { path: 'dist/content.js', maxSize: 1024 * 200 }, // 200KB (bundled) - { path: 'dist/options.js', maxSize: 1024 * 100 }, // 100KB - { path: 'src/options.html', maxSize: 1024 * 50 }, // 50KB + { path: 'dist/extension/background.js', maxSize: 1024 * 200 }, // 200KB (bundled) + { path: 'dist/extension/autofill.js', maxSize: 1024 * 200 }, // 200KB (bundled) + { path: 'dist/ui/options.js', maxSize: 1024 * 100 }, // 100KB + { path: 'dist/ui/popup.js', maxSize: 1024 * 50 }, // 50KB + { path: 'src/ui/options.html', maxSize: 1024 * 50 }, // 50KB ]; files.forEach(({ path: filePath, maxSize }) => { @@ -115,7 +116,7 @@ iconSizes.forEach(size => { console.log('\n🔎 Checking for common issues:'); // Check for console.log in production code (check TypeScript source files) -const tsFiles = ['src/background.ts', 'src/content.ts', 'src/options.ts']; +const tsFiles = ['src/extension/background.ts', 'src/extension/autofill.ts', 'src/ui/options.ts', 'src/ui/popup.ts']; tsFiles.forEach(file => { const filePath = path.join(__dirname, '../..', file); if (fs.existsSync(filePath)) { diff --git a/src/test-setup.ts b/toolkit/test/test-setup.ts similarity index 100% rename from src/test-setup.ts rename to toolkit/test/test-setup.ts