diff --git a/entrypoints/background.ts b/entrypoints/background.ts index 74da5d5..83fdd06 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -1,3 +1,11 @@ +import { getHostnameFromUrl, normalizeDomain } from "../src/utils/domain"; +import { detectCategory } from "../src/utils/categoryDetector"; +import { + buildPlusAlias, + generateAliasSuggestions, +} from "../src/utils/aliasGenerator"; +import { loadAliasData, touchAlias } from "../src/utils/storage"; + export default defineBackground(() => { console.log("Gmail Alias Toolkit background started"); @@ -22,7 +30,7 @@ export default defineBackground(() => { (key) => key.startsWith("gmail_alias_recent_") || key.startsWith("alias_stats_") || - key === "email_accounts" + key === "email_accounts", ); if (shouldUpdateBadge) { await updateBadge(); @@ -38,6 +46,34 @@ export default defineBackground(() => { contexts: ["editable"], }); + browser.contextMenus.create({ + id: "insert-suggested-alias", + parentId: "gmail-alias-parent", + title: "Insert suggested alias", + contexts: ["editable"], + }); + + browser.contextMenus.create({ + id: "copy-suggested-alias", + parentId: "gmail-alias-parent", + title: "Copy suggested alias", + contexts: ["editable"], + }); + + browser.contextMenus.create({ + id: "use-previous-alias", + parentId: "gmail-alias-parent", + title: "Use previous alias for this site", + contexts: ["editable"], + }); + + browser.contextMenus.create({ + id: "generate-random-alias", + parentId: "gmail-alias-parent", + title: "Generate random alias", + contexts: ["editable"], + }); + // Random email submenu browser.contextMenus.create({ id: "fill-random-email", @@ -122,7 +158,7 @@ export default defineBackground(() => { if (result.email_accounts && Array.isArray(result.email_accounts)) { const activeAccount = result.email_accounts.find( - (acc: any) => acc.isActive + (acc: any) => acc.isActive, ); if (activeAccount) { baseEmail = activeAccount.email; @@ -133,8 +169,20 @@ export default defineBackground(() => { const [username, domain] = baseEmail.split("@"); let emailToFill = ""; + const siteAliasMenuIds = new Set([ + "insert-suggested-alias", + "copy-suggested-alias", + "use-previous-alias", + "generate-random-alias", + ]); - if (info.menuItemId === "fill-random-email") { + if (siteAliasMenuIds.has(String(info.menuItemId))) { + emailToFill = await resolveWebsiteAlias( + String(info.menuItemId), + tab.url, + baseEmail, + ); + } else if (info.menuItemId === "fill-random-email") { // Generate random email const format = result.app_settings?.randomFormat || "private-mail"; let randomTag = ""; @@ -144,14 +192,14 @@ export default defineBackground(() => { const chars = "abcdefghijklmnopqrstuvwxyz"; randomTag = Array.from( { length: 8 }, - () => chars[Math.floor(Math.random() * chars.length)] + () => chars[Math.floor(Math.random() * chars.length)], ).join(""); break; case "alphanumeric": const alphanum = "abcdefghijklmnopqrstuvwxyz0123456789"; randomTag = Array.from( { length: 10 }, - () => alphanum[Math.floor(Math.random() * alphanum.length)] + () => alphanum[Math.floor(Math.random() * alphanum.length)], ).join(""); break; case "words": @@ -199,14 +247,62 @@ export default defineBackground(() => { // Save to history and statistics await saveToHistory(emailToFill, result.app_settings?.maxHistory || 20); - // Send message to content script to fill the input - browser.tabs.sendMessage(tab.id, { - action: "fillEmail", - email: emailToFill, - }); + if (info.menuItemId === "copy-suggested-alias") { + await navigator.clipboard.writeText(emailToFill).catch(() => undefined); + } else { + // Send message to content script to fill the input + const response = await browser.tabs + .sendMessage(tab.id, { + action: "autofillAlias", + email: emailToFill, + }) + .catch(() => ({ ok: false })); + if (!response?.ok) + await navigator.clipboard + .writeText(emailToFill) + .catch(() => undefined); + } + + const hostname = tab.url ? getHostnameFromUrl(tab.url) : null; + if (hostname) await touchAlias(hostname); } }); + async function resolveWebsiteAlias( + menuItemId: string, + tabUrl: string | undefined, + baseEmail: string, + ): Promise { + const hostname = tabUrl ? getHostnameFromUrl(tabUrl) : null; + const existingAliases = await loadAliasData().catch(() => null); + + if ( + hostname && + existingAliases?.siteAliases[hostname] && + menuItemId !== "generate-random-alias" + ) { + return existingAliases.siteAliases[hostname].alias; + } + + if (!hostname) return ""; + + const keyword = normalizeDomain(hostname); + if (menuItemId === "generate-random-alias") { + return buildPlusAlias( + baseEmail, + `${keyword}-${Math.random().toString(36).slice(2, 6)}`, + ); + } + + return ( + generateAliasSuggestions({ + baseEmail, + domainKeyword: keyword, + category: detectCategory(keyword, hostname), + })[0]?.alias || "" + ); + } + // Helper function to update badge async function updateBadge() { try { @@ -232,7 +328,7 @@ export default defineBackground(() => { Array.isArray(accountResult.email_accounts) ) { const activeAccount = accountResult.email_accounts.find( - (acc: any) => acc.isActive + (acc: any) => acc.isActive, ); if (activeAccount) { activeEmail = activeAccount.email; @@ -244,7 +340,7 @@ export default defineBackground(() => { // Get history for active account const historyKey = getAccountStorageKey( activeEmail, - "gmail_alias_recent" + "gmail_alias_recent", ); const statsKey = getAccountStorageKey(activeEmail, "alias_stats"); const result = await browser.storage.local.get([historyKey, statsKey]); @@ -265,16 +361,16 @@ export default defineBackground(() => { const today = new Date( now.getFullYear(), now.getMonth(), - now.getDate() + now.getDate(), ).getTime(); count = recentAliases.filter((a: any) => a.timestamp >= today).length; break; case "week": const weekAgo = new Date( - now.getTime() - 7 * 24 * 60 * 60 * 1000 + now.getTime() - 7 * 24 * 60 * 60 * 1000, ).getTime(); count = recentAliases.filter( - (a: any) => a.timestamp >= weekAgo + (a: any) => a.timestamp >= weekAgo, ).length; break; } @@ -312,7 +408,7 @@ export default defineBackground(() => { Array.isArray(accountResult.email_accounts) ) { const activeAccount = accountResult.email_accounts.find( - (acc: any) => acc.isActive + (acc: any) => acc.isActive, ); if (activeAccount) { activeEmail = activeAccount.email; diff --git a/entrypoints/content.ts b/entrypoints/content.ts index 9f8704f..4fbefdc 100644 --- a/entrypoints/content.ts +++ b/entrypoints/content.ts @@ -1,45 +1,30 @@ +import { autofillEmail } from "../src/utils/autofill"; + export default defineContentScript({ matches: [""], main() { - // Listen for messages from background script + // Listen for messages from background script and popup. browser.runtime.onMessage.addListener((message) => { - if (message.action === "fillEmail" && message.email) { - // Get the active element (the input field that was right-clicked) + if ( + (message.action === "fillEmail" || + message.action === "autofillAlias") && + message.email + ) { + const ok = autofillEmail(message.email); const activeElement = document.activeElement; - if ( - activeElement && - (activeElement.tagName === "INPUT" || - activeElement.tagName === "TEXTAREA" || - activeElement.isContentEditable) - ) { - if ( - activeElement instanceof HTMLInputElement || - activeElement instanceof HTMLTextAreaElement - ) { - // Fill input or textarea - activeElement.value = message.email; - - // Trigger input event for frameworks like React/Vue - activeElement.dispatchEvent(new Event("input", { bubbles: true })); - activeElement.dispatchEvent(new Event("change", { bubbles: true })); - } else if (activeElement.isContentEditable) { - // Fill contentEditable element - activeElement.textContent = message.email; - - // Trigger input event - activeElement.dispatchEvent(new Event("input", { bubbles: true })); - } - - // Flash effect to show it was filled - const originalBg = (activeElement as HTMLElement).style - .backgroundColor; - (activeElement as HTMLElement).style.backgroundColor = "#d1fae5"; + if (ok && activeElement instanceof HTMLElement) { + const originalBg = activeElement.style.backgroundColor; + activeElement.style.backgroundColor = "#d1fae5"; setTimeout(() => { - (activeElement as HTMLElement).style.backgroundColor = originalBg; + activeElement.style.backgroundColor = originalBg; }, 500); } + + return Promise.resolve({ ok }); } + + return undefined; }); }, }); diff --git a/entrypoints/popup/App.css b/entrypoints/popup/App.css index e15d344..eca77be 100644 --- a/entrypoints/popup/App.css +++ b/entrypoints/popup/App.css @@ -13,3 +13,21 @@ .animate-fade-in { animation: fade-in 0.2s ease-out; } + +:root { + --alias-bg: #ffffff; + --alias-text: #111827; + --alias-muted: #6b7280; + --alias-border: #e5e7eb; + --alias-card: #f9fafb; + --alias-primary: #2563eb; +} + +[data-theme="dark"] { + --alias-bg: #111827; + --alias-text: #f9fafb; + --alias-muted: #9ca3af; + --alias-border: #374151; + --alias-card: #1f2937; + --alias-primary: #60a5fa; +} diff --git a/entrypoints/popup/App.tsx b/entrypoints/popup/App.tsx index a123fa5..6b623e1 100644 --- a/entrypoints/popup/App.tsx +++ b/entrypoints/popup/App.tsx @@ -1,9 +1,10 @@ -import { useState, useEffect } from 'react'; -import './App.css'; -import Settings from './components/Settings'; -import Statistics from './components/Statistics'; -import GmailTricks from './components/GmailTricks'; -import WelcomeScreen from './components/WelcomeScreen'; +import { useState, useEffect } from "react"; +import "./App.css"; +import Settings from "./components/Settings"; +import Statistics from "./components/Statistics"; +import GmailTricks from "./components/GmailTricks"; +import WelcomeScreen from "./components/WelcomeScreen"; +import SiteAliasManager from "./components/SiteAliasManager"; interface Alias { email: string; @@ -21,7 +22,7 @@ interface AppSettings { maxHistory: number; tags?: Record; total?: number; - randomFormat?: 'private-mail' | 'alphanumeric' | 'words' | 'timestamp'; + randomFormat?: "private-mail" | "alphanumeric" | "words" | "timestamp"; } interface StorageResult { @@ -35,117 +36,163 @@ interface StorageResult { }; } -const STORAGE_KEY = 'gmail_alias_recent'; +const STORAGE_KEY = "gmail_alias_recent"; // Helper to get account-specific storage key const getAccountStorageKey = (email: string, suffix: string) => { - const sanitized = email.replace(/[^a-zA-Z0-9]/g, '_'); + const sanitized = email.replace(/[^a-zA-Z0-9]/g, "_"); return `${suffix}_${sanitized}`; }; function App() { - const [baseEmail, setBaseEmail] = useState('your.email@gmail.com'); - const [customTag, setCustomTag] = useState(''); + const [baseEmail, setBaseEmail] = useState("your.email@gmail.com"); + const [customTag, setCustomTag] = useState(""); const [recentAliases, setRecentAliases] = useState([]); const [copiedEmail, setCopiedEmail] = useState(null); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [maxRecent, setMaxRecent] = useState(20); const [customPresets, setCustomPresets] = useState([]); - const [searchQuery, setSearchQuery] = useState(''); - const [filterTag, setFilterTag] = useState('all'); - const [sortBy, setSortBy] = useState<'recent' | 'alphabetical'>('recent'); - const [viewMode, setViewMode] = useState<'all' | 'favorites'>('all'); + const [searchQuery, setSearchQuery] = useState(""); + const [filterTag, setFilterTag] = useState("all"); + const [sortBy, setSortBy] = useState<"recent" | "alphabetical">("recent"); + const [viewMode, setViewMode] = useState<"all" | "favorites">("all"); const [currentPage, setCurrentPage] = useState(1); const [itemsPerPage, setItemsPerPage] = useState(10); - const [randomFormat, setRandomFormat] = useState<'private-mail' | 'alphanumeric' | 'words' | 'timestamp'>('private-mail'); - const [lastGeneratedRandom, setLastGeneratedRandom] = useState(''); + const [randomFormat, setRandomFormat] = useState< + "private-mail" | "alphanumeric" | "words" | "timestamp" + >("private-mail"); + const [lastGeneratedRandom, setLastGeneratedRandom] = useState(""); const [generatedRandomList, setGeneratedRandomList] = useState([]); const [randomEmailCount, setRandomEmailCount] = useState(10); const [showRandomSettings, setShowRandomSettings] = useState(false); - const [activeGeneratorTab, setActiveGeneratorTab] = useState<'random' | 'tags' | 'tricks'>('random'); + const [activeGeneratorTab, setActiveGeneratorTab] = useState< + "random" | "tags" | "tricks" + >("random"); const [emailAccounts, setEmailAccounts] = useState([]); const [hasEmailAccounts, setHasEmailAccounts] = useState(true); const [showAddAccount, setShowAddAccount] = useState(false); - const [newAccountEmail, setNewAccountEmail] = useState(''); - const [newAccountLabel, setNewAccountLabel] = useState(''); - const [addAccountError, setAddAccountError] = useState(''); + const [newAccountEmail, setNewAccountEmail] = useState(""); + const [newAccountLabel, setNewAccountLabel] = useState(""); + const [addAccountError, setAddAccountError] = useState(""); const [favorites, setFavorites] = useState([]); // Load recent aliases, base email, and settings from storage useEffect(() => { - browser.storage.local.get(['base_email', 'app_settings', 'email_accounts', 'gmail_alias_recent', 'alias_stats', 'favorites']).then(async (result: StorageResult) => { - let activeEmail = 'your.email@gmail.com'; - let needsMigration = false; - - // Load active email from email_accounts or fall back to base_email - if (result.email_accounts && Array.isArray(result.email_accounts)) { - const activeAccount = result.email_accounts.find((acc: any) => acc.isActive); - if (activeAccount) { - activeEmail = activeAccount.email; + browser.storage.local + .get([ + "base_email", + "app_settings", + "email_accounts", + "gmail_alias_recent", + "alias_stats", + "favorites", + ]) + .then(async (result: StorageResult) => { + let activeEmail = "your.email@gmail.com"; + let needsMigration = false; + + // Load active email from email_accounts or fall back to base_email + if (result.email_accounts && Array.isArray(result.email_accounts)) { + const activeAccount = result.email_accounts.find( + (acc: any) => acc.isActive, + ); + if (activeAccount) { + activeEmail = activeAccount.email; + setBaseEmail(activeEmail); + } + } else if (result.base_email) { + activeEmail = result.base_email; setBaseEmail(activeEmail); + // Check if we need to migrate from old format + needsMigration = true; } - } else if (result.base_email) { - activeEmail = result.base_email; - setBaseEmail(activeEmail); - // Check if we need to migrate from old format - needsMigration = true; - } - - // Migrate old data format to new account-specific format if needed - if (needsMigration && (result.gmail_alias_recent || result.alias_stats || result.favorites)) { - const historyKey = getAccountStorageKey(activeEmail, 'gmail_alias_recent'); - const statsKey = getAccountStorageKey(activeEmail, 'alias_stats'); - const favoritesKey = getAccountStorageKey(activeEmail, 'favorites'); - - // Only migrate if account-specific data doesn't exist yet - const accountData = await browser.storage.local.get([historyKey, statsKey, favoritesKey]); - - if (!accountData[historyKey] && !accountData[statsKey] && !accountData[favoritesKey]) { - await browser.storage.local.set({ - [historyKey]: result.gmail_alias_recent || [], - [statsKey]: result.alias_stats || { total: 0, tags: {} }, - [favoritesKey]: result.favorites || [], - }); - console.log('Migrated old data to account-specific storage for:', activeEmail); + + // Migrate old data format to new account-specific format if needed + if ( + needsMigration && + (result.gmail_alias_recent || result.alias_stats || result.favorites) + ) { + const historyKey = getAccountStorageKey( + activeEmail, + "gmail_alias_recent", + ); + const statsKey = getAccountStorageKey(activeEmail, "alias_stats"); + const favoritesKey = getAccountStorageKey(activeEmail, "favorites"); + + // Only migrate if account-specific data doesn't exist yet + const accountData = await browser.storage.local.get([ + historyKey, + statsKey, + favoritesKey, + ]); + + if ( + !accountData[historyKey] && + !accountData[statsKey] && + !accountData[favoritesKey] + ) { + await browser.storage.local.set({ + [historyKey]: result.gmail_alias_recent || [], + [statsKey]: result.alias_stats || { total: 0, tags: {} }, + [favoritesKey]: result.favorites || [], + }); + console.log( + "Migrated old data to account-specific storage for:", + activeEmail, + ); + } } - } - - // Load account-specific history - const historyKey = getAccountStorageKey(activeEmail, 'gmail_alias_recent'); - const favoritesKey = getAccountStorageKey(activeEmail, 'favorites'); - const historyResult = await browser.storage.local.get([historyKey, favoritesKey]); - if (historyResult[historyKey] && Array.isArray(historyResult[historyKey])) { - setRecentAliases(historyResult[historyKey] as Alias[]); - } else { - setRecentAliases([]); - } - - // Load favorites - if (historyResult[favoritesKey] && Array.isArray(historyResult[favoritesKey])) { - const favEmails = historyResult[favoritesKey].map((f: any) => f.email); - setFavorites(favEmails); - } else { - setFavorites([]); - } - - if (result.app_settings) { - setMaxRecent(result.app_settings.maxHistory || 20); - setCustomPresets(result.app_settings.customPresets || []); - setRandomFormat(result.app_settings.randomFormat || 'private-mail'); - } - - // Load email accounts list - if (result.email_accounts && Array.isArray(result.email_accounts)) { - setEmailAccounts(result.email_accounts); - setHasEmailAccounts(result.email_accounts.length > 0); - } else if (result.base_email) { - // Legacy: has base_email but no email_accounts - setHasEmailAccounts(true); - } else { - // First time user - setHasEmailAccounts(false); - } - }); + + // Load account-specific history + const historyKey = getAccountStorageKey( + activeEmail, + "gmail_alias_recent", + ); + const favoritesKey = getAccountStorageKey(activeEmail, "favorites"); + const historyResult = await browser.storage.local.get([ + historyKey, + favoritesKey, + ]); + if ( + historyResult[historyKey] && + Array.isArray(historyResult[historyKey]) + ) { + setRecentAliases(historyResult[historyKey] as Alias[]); + } else { + setRecentAliases([]); + } + + // Load favorites + if ( + historyResult[favoritesKey] && + Array.isArray(historyResult[favoritesKey]) + ) { + const favEmails = historyResult[favoritesKey].map( + (f: any) => f.email, + ); + setFavorites(favEmails); + } else { + setFavorites([]); + } + + if (result.app_settings) { + setMaxRecent(result.app_settings.maxHistory || 20); + setCustomPresets(result.app_settings.customPresets || []); + setRandomFormat(result.app_settings.randomFormat || "private-mail"); + } + + // Load email accounts list + if (result.email_accounts && Array.isArray(result.email_accounts)) { + setEmailAccounts(result.email_accounts); + setHasEmailAccounts(result.email_accounts.length > 0); + } else if (result.base_email) { + // Legacy: has base_email but no email_accounts + setHasEmailAccounts(true); + } else { + // First time user + setHasEmailAccounts(false); + } + }); }, []); // Listen for settings changes @@ -156,7 +203,7 @@ function App() { if (newSettings) { setMaxRecent(newSettings.maxHistory || 20); setCustomPresets(newSettings.customPresets || []); - setRandomFormat(newSettings.randomFormat || 'private-mail'); + setRandomFormat(newSettings.randomFormat || "private-mail"); } } if (changes.email_accounts) { @@ -169,18 +216,32 @@ function App() { if (activeAccount && activeAccount.email !== baseEmail) { setBaseEmail(activeAccount.email); // Load history for new account - const historyKey = getAccountStorageKey(activeAccount.email, 'gmail_alias_recent'); + const historyKey = getAccountStorageKey( + activeAccount.email, + "gmail_alias_recent", + ); const historyResult = await browser.storage.local.get(historyKey); - if (historyResult[historyKey] && Array.isArray(historyResult[historyKey])) { + if ( + historyResult[historyKey] && + Array.isArray(historyResult[historyKey]) + ) { setRecentAliases(historyResult[historyKey] as Alias[]); } else { setRecentAliases([]); } // Load favorites for new account - const favoritesKey = getAccountStorageKey(activeAccount.email, 'favorites'); + const favoritesKey = getAccountStorageKey( + activeAccount.email, + "favorites", + ); const favResult = await browser.storage.local.get(favoritesKey); - if (favResult[favoritesKey] && Array.isArray(favResult[favoritesKey])) { - const favEmails = favResult[favoritesKey].map((f: any) => f.email); + if ( + favResult[favoritesKey] && + Array.isArray(favResult[favoritesKey]) + ) { + const favEmails = favResult[favoritesKey].map( + (f: any) => f.email, + ); setFavorites(favEmails); } else { setFavorites([]); @@ -188,9 +249,9 @@ function App() { } } } - + // Listen for favorites changes - const favoritesKey = getAccountStorageKey(baseEmail, 'favorites'); + const favoritesKey = getAccountStorageKey(baseEmail, "favorites"); if (changes[favoritesKey]) { const newFavorites = changes[favoritesKey].newValue; if (newFavorites && Array.isArray(newFavorites)) { @@ -215,18 +276,18 @@ function App() { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Ctrl/Cmd + K to open settings - if ((e.ctrlKey || e.metaKey) && e.key === 'k') { + if ((e.ctrlKey || e.metaKey) && e.key === "k") { e.preventDefault(); setIsSettingsOpen(true); } // Escape to close settings - if (e.key === 'Escape' && isSettingsOpen) { + if (e.key === "Escape" && isSettingsOpen) { setIsSettingsOpen(false); } }; - globalThis.addEventListener('keydown', handleKeyDown); - return () => globalThis.removeEventListener('keydown', handleKeyDown); + globalThis.addEventListener("keydown", handleKeyDown); + return () => globalThis.removeEventListener("keydown", handleKeyDown); }, [isSettingsOpen]); const saveRecentAlias = (email: string) => { @@ -235,15 +296,15 @@ function App() { timestamp: Date.now(), }; - const updated = [newAlias, ...recentAliases.filter((a) => a.email !== email)].slice( - 0, - maxRecent - ); + const updated = [ + newAlias, + ...recentAliases.filter((a) => a.email !== email), + ].slice(0, maxRecent); setRecentAliases(updated); - + // Save with account-specific key - const historyKey = getAccountStorageKey(baseEmail, 'gmail_alias_recent'); + const historyKey = getAccountStorageKey(baseEmail, "gmail_alias_recent"); browser.storage.local.set({ [historyKey]: updated }); // Update statistics @@ -252,7 +313,7 @@ function App() { const updateStats = async (email: string) => { // Use account-specific stats key - const statsKey = getAccountStorageKey(baseEmail, 'alias_stats'); + const statsKey = getAccountStorageKey(baseEmail, "alias_stats"); const result: StorageResult = await browser.storage.local.get(statsKey); const stats = result[statsKey] || { total: 0, tags: {} }; @@ -271,17 +332,17 @@ function App() { const clearHistory = () => { setRecentAliases([]); - const historyKey = getAccountStorageKey(baseEmail, 'gmail_alias_recent'); + const historyKey = getAccountStorageKey(baseEmail, "gmail_alias_recent"); browser.storage.local.set({ [historyKey]: [] }); }; const toggleFavorite = async (email: string) => { - const favoritesKey = getAccountStorageKey(baseEmail, 'favorites'); + const favoritesKey = getAccountStorageKey(baseEmail, "favorites"); const result = await browser.storage.local.get(favoritesKey); const currentFavs = result[favoritesKey] || []; - + const exists = currentFavs.find((f: any) => f.email === email); - + let updated; if (exists) { // Remove from favorites @@ -295,44 +356,89 @@ function App() { }; updated = [...currentFavs, newFav]; } - + await browser.storage.local.set({ [favoritesKey]: updated }); - + // Update local state const favEmails = updated.map((f: any) => f.email); setFavorites(favEmails); }; - const generateRandomString = (format: 'private-mail' | 'alphanumeric' | 'words' | 'timestamp', index: number = 0): string => { - if (format === 'private-mail') { + const generateRandomString = ( + format: "private-mail" | "alphanumeric" | "words" | "timestamp", + index: number = 0, + ): string => { + if (format === "private-mail") { // Generate format like: private-mail-q2ga - const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; const length = 4; - let randomStr = ''; + let randomStr = ""; for (let i = 0; i < length; i++) { randomStr += chars.charAt(Math.floor(Math.random() * chars.length)); } return `private-mail-${randomStr}`; } - - if (format === 'timestamp') { + + if (format === "timestamp") { // Add index to ensure uniqueness when generating multiple return (Date.now() + index).toString(36); } - - if (format === 'words') { - const adjectives = ['happy', 'sunny', 'calm', 'bright', 'swift', 'brave', 'cool', 'smart', 'quick', 'zen', 'wild', 'free', 'bold', 'wise', 'pure', 'kind', 'fair', 'true', 'rare', 'fine']; - const nouns = ['fox', 'bird', 'bear', 'wolf', 'deer', 'lion', 'hawk', 'eagle', 'tiger', 'panda', 'seal', 'otter', 'raven', 'crane', 'swan', 'lynx', 'coral', 'pearl', 'jade', 'ruby']; + + if (format === "words") { + const adjectives = [ + "happy", + "sunny", + "calm", + "bright", + "swift", + "brave", + "cool", + "smart", + "quick", + "zen", + "wild", + "free", + "bold", + "wise", + "pure", + "kind", + "fair", + "true", + "rare", + "fine", + ]; + const nouns = [ + "fox", + "bird", + "bear", + "wolf", + "deer", + "lion", + "hawk", + "eagle", + "tiger", + "panda", + "seal", + "otter", + "raven", + "crane", + "swan", + "lynx", + "coral", + "pearl", + "jade", + "ruby", + ]; const adj = adjectives[Math.floor(Math.random() * adjectives.length)]; const noun = nouns[Math.floor(Math.random() * nouns.length)]; const num = Math.floor(Math.random() * 999); return `${adj}-${noun}-${num}`; } - + // alphanumeric - const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; const length = 8; - let result = ''; + let result = ""; for (let i = 0; i < length; i++) { result += chars.charAt(Math.floor(Math.random() * chars.length)); } @@ -342,11 +448,11 @@ function App() { const generateRandomAlias = () => { // Clear previous results first setGeneratedRandomList([]); - setLastGeneratedRandom(''); - + setLastGeneratedRandom(""); + const aliases: string[] = []; const timestamp = Date.now(); - + for (let i = 0; i < randomEmailCount; i++) { const randomTag = generateRandomString(randomFormat, i + timestamp); const alias = generateAlias(randomTag); @@ -354,7 +460,7 @@ function App() { aliases.push(alias); } } - + // Use setTimeout to ensure state update triggers re-render setTimeout(() => { if (aliases.length > 0) { @@ -372,7 +478,7 @@ function App() { }; const generateAlias = (tag: string) => { - const [username, domain] = baseEmail.split('@'); + const [username, domain] = baseEmail.split("@"); if (!username || !domain) return null; return `${username}+${tag}@${domain}`; }; @@ -384,7 +490,7 @@ function App() { saveRecentAlias(email); setTimeout(() => setCopiedEmail(null), 2000); } catch (err) { - console.error('Failed to copy:', err); + console.error("Failed to copy:", err); } }; @@ -400,62 +506,67 @@ function App() { const alias = generateAlias(customTag.trim()); if (alias) { copyToClipboard(alias); - setCustomTag(''); + setCustomTag(""); } }; const handleKeyPress = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { + if (e.key === "Enter") { handleCustomGenerate(); } }; const handleAddAccount = async () => { - setAddAccountError(''); - + setAddAccountError(""); + if (!newAccountEmail.trim()) { - setAddAccountError('Email is required'); + setAddAccountError("Email is required"); return; } - - if (!newAccountEmail.includes('@')) { - setAddAccountError('Please enter a valid email address'); + + if (!newAccountEmail.includes("@")) { + setAddAccountError("Please enter a valid email address"); return; } - + // Check if email already exists - const emailExists = emailAccounts.some(acc => acc.email.toLowerCase() === newAccountEmail.trim().toLowerCase()); + const emailExists = emailAccounts.some( + (acc) => acc.email.toLowerCase() === newAccountEmail.trim().toLowerCase(), + ); if (emailExists) { - setAddAccountError('This email address is already added!'); + setAddAccountError("This email address is already added!"); return; } - + const newAccount = { id: Date.now().toString(), email: newAccountEmail.trim(), - label: newAccountLabel.trim() || 'Account ' + (emailAccounts.length + 1), + label: newAccountLabel.trim() || "Account " + (emailAccounts.length + 1), isActive: false, // Don't auto-switch to new account }; - + const updatedAccounts = [...emailAccounts, newAccount]; await browser.storage.local.set({ email_accounts: updatedAccounts }); - + // Initialize empty storage for new account - const historyKey = getAccountStorageKey(newAccount.email, 'gmail_alias_recent'); - const statsKey = getAccountStorageKey(newAccount.email, 'alias_stats'); - const favoritesKey = getAccountStorageKey(newAccount.email, 'favorites'); - + const historyKey = getAccountStorageKey( + newAccount.email, + "gmail_alias_recent", + ); + const statsKey = getAccountStorageKey(newAccount.email, "alias_stats"); + const favoritesKey = getAccountStorageKey(newAccount.email, "favorites"); + await browser.storage.local.set({ [historyKey]: [], [statsKey]: { total: 0, tags: {} }, [favoritesKey]: [], }); - - setNewAccountEmail(''); - setNewAccountLabel(''); - setAddAccountError(''); + + setNewAccountEmail(""); + setNewAccountLabel(""); + setAddAccountError(""); setShowAddAccount(false); - + // Show success message briefly const accountLabel = newAccount.label; setCopiedEmail(`โœ“ ${accountLabel} added!`); @@ -466,7 +577,7 @@ function App() {
{/* Show Welcome Screen for first-time users */} {!hasEmailAccounts ? ( - { setBaseEmail(email); setHasEmailAccounts(true); @@ -480,527 +591,565 @@ function App() {

Gmail Alias Toolkit

-

Generate aliases with plus addressing

+

+ Generate aliases with plus addressing +

- {/* Main Content */} -
- {/* Base Email Selector - Dropdown */} -
- -
-
- -
- - - -
-
- -
- - {/* Quick Add Account Form */} - {showAddAccount && ( -
-
- { - setNewAccountEmail(e.target.value); - setAddAccountError(''); - }} - onKeyDown={(e) => { - if (e.key === 'Tab' && newAccountEmail && !newAccountEmail.includes('@')) { - e.preventDefault(); - setNewAccountEmail(newAccountEmail + '@gmail.com'); - } - }} - placeholder="your.email" - className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" - autoFocus - /> - {newAccountEmail && !newAccountEmail.includes('@') && ( -
- @gmail.com -
- )} -
- {addAccountError && ( -
-

{addAccountError}

-
- )} -

- ๐Ÿ’ก Press Tab to add @gmail.com -

- setNewAccountLabel(e.target.value)} - placeholder="Label (optional, e.g., Work, Personal)" - className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" - /> + {/* Main Content */} +
+ {/* Base Email Selector - Dropdown */} +
+
- - -
-
- )} - - {baseEmail && !baseEmail.includes('@gmail.com') && baseEmail.includes('@') && ( -

- โš  This doesn't look like a Gmail address. Plus addressing works best with Gmail. -

- )} -
- - {/* Unified Email Alias Generator - RoboForm Style */} -
- {/* Header */} -
-
- - - -

Email Alias Generator

-
-
- - {/* Main Tabs */} -
- - - -
- - {/* Tab Content */} -
- {/* Random Tab */} - {activeGeneratorTab === 'random' && ( -
- {/* Format Selector */} -
- +
+
+ + + +
+ +
- {/* Number of Emails */} -
- + {/* Quick Add Account Form */} + {showAddAccount && ( +
+
+ { + setNewAccountEmail(e.target.value); + setAddAccountError(""); + }} + onKeyDown={(e) => { + if ( + e.key === "Tab" && + newAccountEmail && + !newAccountEmail.includes("@") + ) { + e.preventDefault(); + setNewAccountEmail(newAccountEmail + "@gmail.com"); + } + }} + placeholder="your.email" + className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + autoFocus + /> + {newAccountEmail && !newAccountEmail.includes("@") && ( +
+ @gmail.com +
+ )} +
+ {addAccountError && ( +
+

{addAccountError}

+
+ )} +

+ ๐Ÿ’ก Press{" "} + + Tab + {" "} + to add @gmail.com +

setRandomEmailCount(Math.max(1, parseInt(e.target.value) || 10))} - className="w-20 px-3 py-1.5 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-purple-500" + type="text" + value={newAccountLabel} + onChange={(e) => setNewAccountLabel(e.target.value)} + placeholder="Label (optional, e.g., Work, Personal)" + className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" /> +
+ + +
+ )} - {/* Generate Button */} + {baseEmail && + !baseEmail.includes("@gmail.com") && + baseEmail.includes("@") && ( +

+ โš  This doesn't look like a Gmail address. Plus addressing + works best with Gmail. +

+ )} +
+ + + + {/* Unified Email Alias Generator - RoboForm Style */} +
+ {/* Header */} +
+
+ + + +

Email Alias Generator

+
+
+ + {/* Main Tabs */} +
+ + +
- {/* Generated Emails List */} - {generatedRandomList.length > 0 && ( -
-
-
- Generated Aliases - {generatedRandomList.length} total -
+ {/* Tab Content */} +
+ {/* Random Tab */} + {activeGeneratorTab === "random" && ( +
+ {/* Format Selector */} +
+ +
-
- {generatedRandomList.map((email, index) => ( -
+ + + setRandomEmailCount( + Math.max(1, parseInt(e.target.value) || 10), + ) + } + className="w-20 px-3 py-1.5 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-purple-500" + /> +
+ + {/* Generate Button */} + + + {/* Generated Emails List */} + {generatedRandomList.length > 0 && ( +
+
+
+ + Generated Aliases + + + {generatedRandomList.length} total +
-
- ))} +
+ {generatedRandomList.map((email, index) => ( +
+
+ {email} +
+ +
+ ))} +
+
+ )} + +
+ {randomFormat === "private-mail" + ? "Format: private-mail-xxxx" + : randomFormat === "alphanumeric" + ? "8 random characters" + : randomFormat === "words" + ? "2 random words" + : "Unix timestamp"}
)} -
- {randomFormat === 'private-mail' ? 'Format: private-mail-xxxx' : randomFormat === 'alphanumeric' ? '8 random characters' : randomFormat === 'words' ? '2 random words' : 'Unix timestamp'} -
-
- )} + {/* Custom Tags Tab */} + {activeGeneratorTab === "tags" && ( +
+
+ setCustomTag(e.target.value)} + onKeyDown={handleKeyPress} + className="flex-1 px-3 py-2.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="Enter tag (e.g., shopping, work)" + /> + +
- {/* Custom Tags Tab */} - {activeGeneratorTab === 'tags' && ( -
-
- setCustomTag(e.target.value)} - onKeyDown={handleKeyPress} - className="flex-1 px-3 py-2.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" - placeholder="Enter tag (e.g., shopping, work)" - /> - -
+ {/* Custom Presets - Quick Access */} + {customPresets.length > 0 && ( +
+
+ Your Presets +
+
+ {customPresets.map((preset) => ( + + ))} +
+
+ )} - {/* Custom Presets - Quick Access */} - {customPresets.length > 0 && ( -
-
Your Presets
-
- {customPresets.map((preset) => ( - - ))} +
+ Example: {baseEmail.split("@")[0]}+ + your-tag@{baseEmail.split("@")[1]}
)} -
- Example: {baseEmail.split('@')[0]}+your-tag@{baseEmail.split('@')[1]} -
-
- )} - - {/* Gmail Tricks Tab */} - {activeGeneratorTab === 'tricks' && ( -
- + {/* Gmail Tricks Tab */} + {activeGeneratorTab === "tricks" && ( +
+ +
+ )}
- )} -
-
- - {/* Recent Aliases */} - {(recentAliases.length > 0 || favorites.length > 0) && ( -
-
-

- {viewMode === 'all' ? 'Recent Aliases' : 'Favorites'} -

- - {viewMode === 'all' ? `${recentAliases.length} total` : `${favorites.length} starred`} -
- {/* View Mode Tabs */} -
- - -
- {/* Search and Filters */} -
-
- setSearchQuery(e.target.value)} - placeholder="๐Ÿ” Search aliases..." - className="w-full pl-3 pr-8 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" - /> - {searchQuery && ( + {/* View Mode Tabs */} +
- )} -
- -
- - - -
-
- -
- {(() => { - // Filter and sort aliases - const filteredAliases = recentAliases - .filter((alias) => { - // Filter by view mode - if (viewMode === 'favorites' && !favorites.includes(alias.email)) { - return false; - } - - // Filter by search query - if (searchQuery && !alias.email.toLowerCase().includes(searchQuery.toLowerCase())) { - return false; - } - - // Filter by tag - if (filterTag !== 'all') { - const tagMatch = alias.email.match(/\+([^@]+)@/); - const emailTag = tagMatch ? tagMatch[1] : null; - if (emailTag !== filterTag) { - return false; - } - } - - return true; - }) - .sort((a, b) => { - if (sortBy === 'recent') { - return b.timestamp - a.timestamp; - } else { - return a.email.localeCompare(b.email); - } - }); - - // Calculate pagination - const totalItems = filteredAliases.length; - const totalPages = Math.ceil(totalItems / itemsPerPage); - const startIndex = (currentPage - 1) * itemsPerPage; - const endIndex = startIndex + itemsPerPage; - const paginatedAliases = filteredAliases.slice(startIndex, endIndex); - - // Empty state for favorites - if (filteredAliases.length === 0 && viewMode === 'favorites') { - return ( -
- - - -

No favorites yet

-

Star emails from your history to quick access them here

-
- ); - } - - // Render paginated list - return ( - <> - {paginatedAliases.map((alias) => ( -
setViewMode("favorites")} + className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-all ${ + viewMode === "favorites" + ? "bg-white text-yellow-600 shadow-sm" + : "text-gray-600 hover:text-gray-900" + }`} > - - {alias.email} - - -
+ +
+ + {/* Search and Filters */} +
+
+ setSearchQuery(e.target.value)} + placeholder="๐Ÿ” Search aliases..." + className="w-full pl-3 pr-8 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + {searchQuery && ( + + + )}
- ))} - {/* Pagination Controls */} - {totalPages > 1 && ( -
-
- {/* Page info and items per page selector */} -
-
- Showing {startIndex + 1}-{Math.min(endIndex, totalItems)} of {totalItems} -
- -
- - {/* Page navigation */} -
+
+ + + +
+
+ +
+ {(() => { + // Filter and sort aliases + const filteredAliases = recentAliases + .filter((alias) => { + // Filter by view mode + if ( + viewMode === "favorites" && + !favorites.includes(alias.email) + ) { + return false; + } + + // Filter by search query + if ( + searchQuery && + !alias.email + .toLowerCase() + .includes(searchQuery.toLowerCase()) + ) { + return false; + } + + // Filter by tag + if (filterTag !== "all") { + const tagMatch = alias.email.match(/\+([^@]+)@/); + const emailTag = tagMatch ? tagMatch[1] : null; + if (emailTag !== filterTag) { + return false; + } + } + + return true; + }) + .sort((a, b) => { + if (sortBy === "recent") { + return b.timestamp - a.timestamp; + } else { + return a.email.localeCompare(b.email); + } + }); + + // Calculate pagination + const totalItems = filteredAliases.length; + const totalPages = Math.ceil(totalItems / itemsPerPage); + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + const paginatedAliases = filteredAliases.slice( + startIndex, + endIndex, + ); + + // Empty state for favorites + if ( + filteredAliases.length === 0 && + viewMode === "favorites" + ) { + return ( +
+ + + +

+ No favorites yet +

+

+ Star emails from your history to quick access them + here +

+
+ ); + } + + // Render paginated list + return ( + <> + {paginatedAliases.map((alias) => ( +
+ + {alias.email} + -
- {Array.from({ length: totalPages }, (_, i) => i + 1) - .filter(page => { - // Show first, last, current, and pages around current - if (page === 1 || page === totalPages) return true; - if (Math.abs(page - currentPage) <= 1) return true; - return false; - }) - .map((page, index, array) => { - // Add ellipsis - const prevPage = array[index - 1]; - const showEllipsis = prevPage && page - prevPage > 1; - - return ( -
- {showEllipsis && ...} - -
- ); - })} +
+ ))} + + {/* Pagination Controls */} + {totalPages > 1 && ( +
+
+ {/* Page info and items per page selector */} +
+
+ Showing {startIndex + 1}- + {Math.min(endIndex, totalItems)} of{" "} + {totalItems} +
+ +
+ + {/* Page navigation */} +
+ + +
+ {Array.from( + { length: totalPages }, + (_, i) => i + 1, + ) + .filter((page) => { + // Show first, last, current, and pages around current + if (page === 1 || page === totalPages) + return true; + if (Math.abs(page - currentPage) <= 1) + return true; + return false; + }) + .map((page, index, array) => { + // Add ellipsis + const prevPage = array[index - 1]; + const showEllipsis = + prevPage && page - prevPage > 1; + + return ( +
+ {showEllipsis && ( + + ... + + )} + +
+ ); + })} +
+ + +
- -
-
-
- )} - - ); - })()} -
-
- )} + )} + + ); + })()} +
+
+ )} - {/* Statistics - Collapsible */} - + {/* Statistics - Collapsible */} + - {/* Success Message */} - {copiedEmail && ( -
- โœ“ Copied to clipboard! + {/* Success Message */} + {copiedEmail && ( +
+ โœ“ Copied to clipboard! +
+ )}
- )} -
- + )} {/* Settings Modal */} diff --git a/entrypoints/popup/components/SiteAliasManager.tsx b/entrypoints/popup/components/SiteAliasManager.tsx new file mode 100644 index 0000000..bffc94b --- /dev/null +++ b/entrypoints/popup/components/SiteAliasManager.tsx @@ -0,0 +1,642 @@ +import { useEffect, useMemo, useState } from "react"; +import { getHostnameFromUrl, normalizeDomain } from "../../../src/utils/domain"; +import { + detectCategory, + CATEGORY_MAP, + labelForCategory, +} from "../../../src/utils/categoryDetector"; +import { + generateAliasSuggestions, + isGmailAddress, +} from "../../../src/utils/aliasGenerator"; +import { + buildGmailFilterQuery, + buildGmailSearchUrl, +} from "../../../src/utils/gmailFilter"; +import { calculateAliasQuality } from "../../../src/utils/qualityScore"; +import { + deleteSiteAlias, + loadAliasData, + migrateStorageIfNeeded, + saveSiteAlias, + touchAlias, + updateAliasStatus, +} from "../../../src/utils/storage"; +import type { + AliasCategory, + AliasStatus, + SiteAlias, +} from "../../../src/types/alias"; + +interface Props { + baseEmail: string; + gmailAccountIndex?: number; + onAliasUsed?: (email: string) => void; +} + +const statuses: AliasStatus[] = [ + "normal", + "important", + "spam", + "leaked", + "inactive", +]; +const categories = Object.keys(CATEGORY_MAP) as AliasCategory[]; +const nowIso = () => new Date().toISOString(); +const newId = () => `${Date.now()}-${Math.random().toString(36).slice(2)}`; + +export default function SiteAliasManager({ + baseEmail, + gmailAccountIndex = 0, + onAliasUsed, +}: Props) { + const [hostname, setHostname] = useState(null); + const [manualHost, setManualHost] = useState(""); + const [aliases, setAliases] = useState>({}); + const [toast, setToast] = useState(""); + const [showDashboard, setShowDashboard] = useState(false); + const [purpose, setPurpose] = useState(""); + const [query, setQuery] = useState(""); + const [statusFilter, setStatusFilter] = useState<"all" | AliasStatus>("all"); + const [categoryFilter, setCategoryFilter] = useState<"all" | AliasCategory>( + "all", + ); + const [sortBy, setSortBy] = useState<"lastUsedAt" | "createdAt" | "useCount">( + "lastUsedAt", + ); + + const activeHost = hostname || manualHost.trim().toLowerCase() || null; + const normalizedDomain = activeHost ? normalizeDomain(activeHost) : ""; + const category = normalizedDomain + ? detectCategory(normalizedDomain, activeHost || "") + : "other"; + const previous = activeHost ? aliases[activeHost] : undefined; + + const suggestions = useMemo(() => { + try { + return baseEmail && normalizedDomain + ? generateAliasSuggestions({ + baseEmail, + domainKeyword: normalizedDomain, + category, + purpose, + }) + : []; + } catch { + return []; + } + }, [baseEmail, normalizedDomain, category, purpose]); + + const activeAlias = previous?.alias || suggestions[0]?.alias || ""; + const quality = activeAlias + ? calculateAliasQuality(previous || suggestions[0]) + : null; + + useEffect(() => { + (async () => { + await migrateStorageIfNeeded(); + const data = await loadAliasData(); + setAliases(data.siteAliases); + const [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + setHostname(tab?.url ? getHostnameFromUrl(tab.url) : null); + })(); + }, []); + + const notify = (message: string) => { + setToast(message); + setTimeout(() => setToast(""), 2200); + }; + + const rememberUse = async (email: string) => { + onAliasUsed?.(email); + if (activeHost && aliases[activeHost]) { + const updated = await touchAlias(activeHost); + if (updated) + setAliases((current) => ({ ...current, [activeHost]: updated })); + } + }; + + const saveAlias = async (email = activeAlias) => { + if (!activeHost || !email) return; + const existing = aliases[activeHost]; + const siteAlias: SiteAlias = { + id: existing?.id || newId(), + hostname: activeHost, + normalizedDomain, + baseEmail, + alias: email, + category, + note: existing?.note || "", + status: existing?.status || "normal", + createdAt: existing?.createdAt || nowIso(), + updatedAt: nowIso(), + lastUsedAt: existing?.lastUsedAt, + useCount: existing?.useCount || 0, + }; + await saveSiteAlias(siteAlias); + setAliases((current) => ({ ...current, [activeHost]: siteAlias })); + notify("Saved locally for this website"); + }; + + const copyAlias = async (email = activeAlias) => { + await navigator.clipboard.writeText(email); + await rememberUse(email); + notify("Alias copied"); + }; + + const autofillAlias = async (email = activeAlias) => { + const [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + const response = tab?.id + ? await browser.tabs + .sendMessage(tab.id, { action: "autofillAlias", email }) + .catch(() => ({ ok: false })) + : { ok: false }; + if (!response?.ok) { + await navigator.clipboard.writeText(email); + notify("No email input found; copied instead"); + } else { + notify("Alias autofilled"); + } + await rememberUse(email); + }; + + const setStatus = async (alias: SiteAlias, status: AliasStatus) => { + await updateAliasStatus(alias.hostname, status); + setAliases((current) => ({ + ...current, + [alias.hostname]: { ...alias, status, updatedAt: nowIso() }, + })); + }; + + const editNote = async (alias: SiteAlias) => { + const note = prompt("Alias note", alias.note || ""); + if (note === null) return; + const updated = { ...alias, note, updatedAt: nowIso() }; + await saveSiteAlias(updated); + setAliases((current) => ({ ...current, [alias.hostname]: updated })); + }; + + const removeAlias = async (alias: SiteAlias) => { + if (!confirm(`Delete alias for ${alias.hostname}?`)) return; + await deleteSiteAlias(alias.hostname); + setAliases((current) => { + const next = { ...current }; + delete next[alias.hostname]; + return next; + }); + }; + + const exportJson = async () => { + const data = await loadAliasData(); + download( + `gmail-alias-toolkit-${Date.now()}.json`, + JSON.stringify( + { + version: 1, + exportedAt: nowIso(), + siteAliases: data.siteAliases, + settings: data.settings, + }, + null, + 2, + ), + "application/json", + ); + }; + + const exportCsv = () => { + const header = + "hostname,normalizedDomain,alias,baseEmail,category,status,note,createdAt,lastUsedAt,useCount"; + const lines = Object.values(aliases).map((alias) => + [ + alias.hostname, + alias.normalizedDomain, + alias.alias, + alias.baseEmail, + alias.category, + alias.status, + alias.note || "", + alias.createdAt, + alias.lastUsedAt || "", + alias.useCount, + ] + .map((value) => `"${String(value).replace(/"/g, '""')}"`) + .join(","), + ); + download( + `gmail-aliases-${Date.now()}.csv`, + [header, ...lines].join("\n"), + "text/csv", + ); + }; + + const importJson = () => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = "application/json"; + input.onchange = async () => { + const file = input.files?.[0]; + if (!file) return; + try { + const parsed = JSON.parse(await file.text()); + if (!parsed.siteAliases || typeof parsed.siteAliases !== "object") + throw new Error("Missing siteAliases"); + const next = { ...aliases }; + for (const alias of Object.values(parsed.siteAliases) as SiteAlias[]) { + if (!alias.hostname || !alias.alias || !alias.alias.includes("@")) + throw new Error("Invalid alias record"); + next[alias.hostname] = alias; + } + await browser.storage.local.set({ + backup_before_import: await loadAliasData(), + siteAliases: next, + }); + setAliases(next); + notify("Imported aliases"); + } catch (error: any) { + notify(`Import failed: ${error.message}`); + } + }; + input.click(); + }; + + const download = (filename: string, content: string, type: string) => { + const url = URL.createObjectURL(new Blob([content], { type })); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + link.click(); + URL.revokeObjectURL(url); + }; + + const rows = Object.values(aliases) + .filter( + (alias) => + (statusFilter === "all" || alias.status === statusFilter) && + (categoryFilter === "all" || alias.category === categoryFilter) && + `${alias.hostname} ${alias.alias} ${alias.note || ""}` + .toLowerCase() + .includes(query.toLowerCase()), + ) + .sort((a, b) => + sortBy === "useCount" + ? b.useCount - a.useCount + : String(b[sortBy] || "").localeCompare(String(a[sortBy] || "")), + ); + + return ( +
+
+
+

+ Website Alias Manager +

+

+ Local-first. No account. No server. No tracking. +

+
+ +
+ + {!showDashboard ? ( + <> +
+

+ Current site +

+ {hostname ? ( +

{hostname}

+ ) : ( +
+

+ Manual mode: this page URL cannot be used. +

+ setManualHost(event.target.value)} + placeholder="github.com" + className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" + /> +
+ )} +
+ + {!isGmailAddress(baseEmail) && ( +

+ Only Gmail/Googlemail plus aliases are supported for website + aliases. +

+ )} + + {previous && ( +
+

+ Previously used for this site +

+

+ {previous.alias} +

+ {["spam", "leaked"].includes(previous.status) && ( +

+ This alias may have been shared or leaked. Consider creating a + new alias for this website. +

+ )} +
+ + + {( + ["important", "spam", "leaked", "inactive"] as AliasStatus[] + ).map((status) => ( + + ))} +
+
+ )} + +
+

+ Suggested alias +

+
+ {activeAlias || + "Add a Gmail account and supported website first."} +
+
+
+ + + +
+ + setPurpose(event.target.value)} + placeholder="Optional purpose (e.g., jobs, invoices)" + className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" + /> + + {suggestions.length > 1 && ( +
+

+ Other suggestions +

+ {suggestions.slice(1).map((suggestion) => ( +
+ + {suggestion.alias} + + +
+ ))} +
+ )} + + {quality && ( +
+

+ Alias quality: {quality.label} ({quality.score} + ) +

+

+ Tracking quality: {quality.trackingLevel} +

+

+ Privacy level: Basic +

+

+ Gmail plus aliases help with filtering and tracking, but they do + not hide your real Gmail address. +

+
+ )} + + {activeAlias && ( +
+

+ Gmail filter suggestion +

+

+ To: {activeAlias} +

+

Apply label: {labelForCategory(category)}

+

Optional: Skip Inbox

+
+ + +
+
+ )} + + ) : ( +
+
+ setQuery(event.target.value)} + placeholder="Search aliases" + className="px-3 py-2 border border-gray-300 rounded-md text-xs" + /> + + + +
+
+ + + +
+ {rows.length === 0 ? ( +

+ No aliases saved yet. Create your first website alias from the + popup. +

+ ) : ( + rows.map((alias) => ( +
+
+
+

+ {alias.hostname} +

+

+ {alias.alias} +

+
+ + {alias.status} + +
+

+ {alias.category} ยท used {alias.useCount}x ยท last{" "} + {alias.lastUsedAt?.slice(0, 10) || "never"} +

+ {alias.note && ( +

Note: {alias.note}

+ )} +
+ + + + {statuses.map((status) => ( + + ))} + +
+
+ )) + )} +
+ )} + +
+ Privacy: No account required. No server. No tracking. + Alias data is stored locally in your browser. Gmail plus aliases help + with filtering and tracking, but they do not hide your real Gmail + address. +
+ {toast && ( +
+ {toast} +
+ )} +
+ ); +} diff --git a/package.json b/package.json index eb4fc1a..38d6139 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "gmail-alias-toolkit", - "description": "Generate and manage Gmail aliases with plus addressing and presets", + "description": "Local-first Gmail plus alias manager. No account. No server. No tracking.", "private": true, - "version": "1.1.0", + "version": "1.6.1", "author": "dev@eplus.dev", "license": "MIT", "homepage_url": "https://eplus.dev", diff --git a/src/types/alias.ts b/src/types/alias.ts new file mode 100644 index 0000000..919a963 --- /dev/null +++ b/src/types/alias.ts @@ -0,0 +1,38 @@ +export type AliasStatus = + | "normal" + | "important" + | "spam" + | "leaked" + | "inactive"; +export type AliasCategory = + | "shopping" + | "developer" + | "career" + | "social" + | "finance" + | "travel" + | "education" + | "productivity" + | "entertainment" + | "other"; +export interface SiteAlias { + id: string; + hostname: string; + normalizedDomain: string; + baseEmail: string; + alias: string; + category: AliasCategory; + note?: string; + status: AliasStatus; + createdAt: string; + updatedAt: string; + lastUsedAt?: string; + useCount: number; +} +export interface AliasSuggestion { + alias: string; + label: string; + format: string; + category?: AliasCategory; + domainKeyword?: string; +} diff --git a/src/types/settings.ts b/src/types/settings.ts new file mode 100644 index 0000000..ad59435 --- /dev/null +++ b/src/types/settings.ts @@ -0,0 +1,22 @@ +import type { SiteAlias } from "./alias"; +export interface AliasSettings { + baseEmails: string[]; + defaultBaseEmail?: string; + defaultAliasFormat: + | "domain" + | "category-domain" + | "domain-date" + | "domain-random" + | "domain-purpose"; + theme: "system" | "light" | "dark"; + autoDetectCategory: boolean; + autofillFocusedInputFirst: boolean; + fallbackToCopyWhenNoInput: boolean; + gmailAccountIndex: number; +} +export interface AliasStorageSchema { + siteAliases: Record; + settings: AliasSettings; + recentAliases: SiteAlias[]; + favoriteAliases: SiteAlias[]; +} diff --git a/src/utils/aliasGenerator.ts b/src/utils/aliasGenerator.ts new file mode 100644 index 0000000..bcef43d --- /dev/null +++ b/src/utils/aliasGenerator.ts @@ -0,0 +1,73 @@ +import type { AliasCategory, AliasSuggestion } from "../types/alias"; +import { formatYYYYMMDD } from "./date"; +import { randomString } from "./random"; +export interface GenerateAliasInput { + baseEmail: string; + domainKeyword: string; + category?: AliasCategory; + purpose?: string; + date?: Date; +} +export function splitEmail( + email: string, +): { local: string; domain: string } | null { + const trimmed = email.trim().toLowerCase(); + const match = trimmed.match(/^([^@\s]+)@([^@\s]+\.[^@\s]+)$/); + return match ? { local: match[1], domain: match[2] } : null; +} +export function isGmailAddress(email: string): boolean { + const p = splitEmail(email); + return !!p && ["gmail.com", "googlemail.com"].includes(p.domain); +} +export function sanitizeAliasTag(tag: string): string { + return tag + .toLowerCase() + .trim() + .replace(/\s+/g, "-") + .replace(/[^a-z0-9-_]/g, "") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} +export function buildPlusAlias(baseEmail: string, tag: string): string { + const p = splitEmail(baseEmail); + if (!p) throw new Error("Invalid email"); + if (!["gmail.com", "googlemail.com"].includes(p.domain)) + throw new Error("Only Gmail/Googlemail plus aliases are supported"); + const clean = sanitizeAliasTag(tag); + if (!clean) throw new Error("Alias tag is empty"); + return `${p.local}+${clean}@${p.domain}`; +} +export function generateAliasSuggestions( + input: GenerateAliasInput, +): AliasSuggestion[] { + const domain = sanitizeAliasTag(input.domainKeyword); + const category = input.category || "other"; + const date = formatYYYYMMDD(input.date || new Date()); + const tags = [ + { tag: domain, label: "Domain alias", format: "domain" }, + { + tag: `${category}-${domain}`, + label: "Category + domain", + format: "category-domain", + }, + { tag: `${domain}-${date}`, label: "Domain + date", format: "domain-date" }, + { + tag: `${domain}-${randomString(4)}`, + label: "Domain + random", + format: "domain-random", + }, + ]; + if (input.purpose) + tags.push({ + tag: `${domain}-${input.purpose}`, + label: "Domain + purpose", + format: "domain-purpose", + }); + return tags.map((t) => ({ + alias: buildPlusAlias(input.baseEmail, t.tag), + label: t.label, + format: t.format, + category, + domainKeyword: domain, + })); +} diff --git a/src/utils/autofill.ts b/src/utils/autofill.ts new file mode 100644 index 0000000..05ae0a4 --- /dev/null +++ b/src/utils/autofill.ts @@ -0,0 +1,40 @@ +export const EMAIL_INPUT_SELECTOR = [ + 'input[type="email"]', + 'input[name*="email" i]', + 'input[id*="email" i]', + 'input[autocomplete="email"]', + 'input[placeholder*="email" i]', +].join(","); +export function setNativeValue( + element: HTMLInputElement | HTMLTextAreaElement, + value: string, +) { + const valueSetter = Object.getOwnPropertyDescriptor(element, "value")?.set; + const prototype = Object.getPrototypeOf(element); + const prototypeValueSetter = Object.getOwnPropertyDescriptor( + prototype, + "value", + )?.set; + if (prototypeValueSetter && valueSetter !== prototypeValueSetter) + prototypeValueSetter.call(element, value); + else if (valueSetter) valueSetter.call(element, value); + else element.value = value; + element.dispatchEvent(new Event("input", { bubbles: true })); + element.dispatchEvent(new Event("change", { bubbles: true })); +} +export function findBestEmailInput(): HTMLInputElement | null { + const active = document.activeElement; + if ( + active instanceof HTMLInputElement && + active.matches(EMAIL_INPUT_SELECTOR) + ) + return active; + return document.querySelector(EMAIL_INPUT_SELECTOR); +} +export function autofillEmail(alias: string): boolean { + const input = findBestEmailInput(); + if (!input) return false; + input.focus(); + setNativeValue(input, alias); + return true; +} diff --git a/src/utils/categoryDetector.ts b/src/utils/categoryDetector.ts new file mode 100644 index 0000000..2e03b47 --- /dev/null +++ b/src/utils/categoryDetector.ts @@ -0,0 +1,95 @@ +import type { AliasCategory } from "../types/alias"; +export const CATEGORY_MAP: Record = { + shopping: [ + "amazon", + "shopee", + "lazada", + "tiki", + "ebay", + "aliexpress", + "etsy", + "temu", + ], + developer: [ + "github", + "gitlab", + "bitbucket", + "npmjs", + "vercel", + "netlify", + "cloudflare", + "docker", + "stackoverflow", + ], + career: [ + "linkedin", + "topcv", + "vietnamworks", + "indeed", + "glassdoor", + "wellfound", + ], + social: [ + "facebook", + "instagram", + "x", + "twitter", + "tiktok", + "threads", + "reddit", + ], + finance: [ + "paypal", + "stripe", + "wise", + "revolut", + "bank", + "binance", + "momo", + "zalopay", + ], + travel: ["booking", "agoda", "airbnb", "tripadvisor", "expedia", "traveloka"], + education: [ + "coursera", + "udemy", + "edx", + "cloudskillsboost", + "kaggle", + "duolingo", + ], + productivity: [ + "notion", + "slack", + "trello", + "asana", + "clickup", + "figma", + "canva", + ], + entertainment: [ + "netflix", + "spotify", + "youtube", + "steam", + "epicgames", + "twitch", + ], + other: [], +}; +export function detectCategory( + domainKeyword: string, + hostname = "", +): AliasCategory { + const haystack = `${domainKeyword} ${hostname}`.toLowerCase(); + for (const [cat, words] of Object.entries(CATEGORY_MAP) as [ + AliasCategory, + string[], + ][]) + if (words.some((w) => haystack.includes(w))) return cat; + return "other"; +} +export function labelForCategory(category: AliasCategory): string { + return category === "other" + ? "Alias" + : category[0].toUpperCase() + category.slice(1); +} diff --git a/src/utils/date.ts b/src/utils/date.ts new file mode 100644 index 0000000..2181d41 --- /dev/null +++ b/src/utils/date.ts @@ -0,0 +1,7 @@ +export function formatYYYYMMDD(date = new Date()): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + return `${y}${m}${d}`; +} +export const nowIso = () => new Date().toISOString(); diff --git a/src/utils/domain.ts b/src/utils/domain.ts new file mode 100644 index 0000000..3dbe534 --- /dev/null +++ b/src/utils/domain.ts @@ -0,0 +1,61 @@ +const SPECIAL_DOMAIN_MAP: Record = { + "accounts.google.com": "google", + "mail.google.com": "gmail", + "login.microsoftonline.com": "microsoft", + "account.live.com": "microsoft", + "github.com": "github", + "gitlab.com": "gitlab", + "bitbucket.org": "bitbucket", + "dashboard.stripe.com": "stripe", + "app.vercel.com": "vercel", + "app.netlify.com": "netlify", + "m.facebook.com": "facebook", +}; +const COMMON_SUBDOMAINS = new Set([ + "www", + "m", + "mobile", + "login", + "account", + "accounts", + "auth", + "app", + "dashboard", + "seller", + "admin", + "mail", + "portal", + "console", +]); +const BLOCKED = [ + "chrome://", + "edge://", + "about:", + "file://", + "chrome-extension://", + "moz-extension://", + "devtools://", +]; +export function isSupportedPageUrl(url?: string): boolean { + return !!url && !BLOCKED.some((p) => url.startsWith(p)); +} +export function getHostnameFromUrl(url: string): string | null { + try { + if (!isSupportedPageUrl(url)) return null; + return new URL(url).hostname.toLowerCase(); + } catch { + return null; + } +} +export function normalizeDomain(hostname: string): string { + const host = hostname.toLowerCase().replace(/^www\./, ""); + if (SPECIAL_DOMAIN_MAP[host]) return SPECIAL_DOMAIN_MAP[host]; + const parts = host.split(".").filter(Boolean); + if (parts.length < 2) return host.replace(/[^a-z0-9-]/g, ""); + let candidate = parts[parts.length - 2]; + if (parts.length >= 3 && COMMON_SUBDOMAINS.has(parts[0])) + candidate = parts[parts.length - 2]; + if (parts.length >= 3 && COMMON_SUBDOMAINS.has(candidate)) + candidate = parts[parts.length - 3]; + return candidate.replace(/[^a-z0-9-]/g, ""); +} diff --git a/src/utils/gmailFilter.ts b/src/utils/gmailFilter.ts new file mode 100644 index 0000000..c7130f7 --- /dev/null +++ b/src/utils/gmailFilter.ts @@ -0,0 +1,12 @@ +import type { AliasCategory } from "../types/alias"; +import { labelForCategory } from "./categoryDetector"; +export function buildGmailFilterQuery(alias: string): string { + return `to:${alias}`; +} +export function buildGmailSearchUrl(alias: string, accountIndex = 0): string { + const query = encodeURIComponent(buildGmailFilterQuery(alias)); + return `https://mail.google.com/mail/u/${accountIndex}/#search/${query}`; +} +export function suggestedGmailLabel(category: AliasCategory): string { + return labelForCategory(category); +} diff --git a/src/utils/qualityScore.ts b/src/utils/qualityScore.ts new file mode 100644 index 0000000..c5a7ade --- /dev/null +++ b/src/utils/qualityScore.ts @@ -0,0 +1,45 @@ +import type { AliasSuggestion, SiteAlias } from "../types/alias"; +export interface AliasQuality { + score: number; + label: "Weak" | "Fair" | "Good" | "Strong"; + trackingLevel: "Low" | "Medium" | "High"; + privacyLevel: "Basic"; + warnings: string[]; +} +export function calculateAliasQuality( + alias: SiteAlias | AliasSuggestion, +): AliasQuality { + const email = "alias" in alias ? alias.alias : ""; + const tag = email.match(/\+([^@]+)@/)?.[1] || ""; + let score = 0; + const warnings = ["Gmail plus aliases do not hide your real Gmail address."]; + if ( + ("normalizedDomain" in alias && tag.includes(alias.normalizedDomain)) || + ("domainKeyword" in alias && + alias.domainKeyword && + tag.includes(alias.domainKeyword)) + ) + score += 30; + if ( + ("category" in alias && alias.category && alias.category !== "other") || + /^(shopping|developer|career|social|finance|travel|education|productivity|entertainment)-/.test( + tag, + ) + ) + score += 20; + if (/\d{8}|-[a-z0-9]{4}$/.test(tag)) score += 20; + if (email.length <= 64) score += 10; + if (!["test", "temp", "demo"].some((w) => tag === w || tag.includes(`-${w}`))) + score += 10; + if ("hostname" in alias) score += 10; + const label = + score >= 80 + ? "Strong" + : score >= 60 + ? "Good" + : score >= 35 + ? "Fair" + : "Weak"; + const trackingLevel = score >= 60 ? "High" : score >= 35 ? "Medium" : "Low"; + return { score, label, trackingLevel, privacyLevel: "Basic", warnings }; +} diff --git a/src/utils/random.ts b/src/utils/random.ts new file mode 100644 index 0000000..71c83dd --- /dev/null +++ b/src/utils/random.ts @@ -0,0 +1,7 @@ +export function randomString(length = 4): string { + const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; + return Array.from( + { length }, + () => chars[Math.floor(Math.random() * chars.length)], + ).join(""); +} diff --git a/src/utils/storage.ts b/src/utils/storage.ts new file mode 100644 index 0000000..2e9f844 --- /dev/null +++ b/src/utils/storage.ts @@ -0,0 +1,99 @@ +import type { SiteAlias, AliasStatus } from "../types/alias"; +import type { AliasSettings, AliasStorageSchema } from "../types/settings"; +import { nowIso } from "./date"; +export const STORAGE_KEYS = { + SITE_ALIASES: "siteAliases", + SETTINGS: "settings", + RECENT_ALIASES: "recentAliases", + FAVORITE_ALIASES: "favoriteAliases", +} as const; +export const DEFAULT_SETTINGS: AliasSettings = { + baseEmails: [], + defaultAliasFormat: "domain", + theme: "system", + autoDetectCategory: true, + autofillFocusedInputFirst: true, + fallbackToCopyWhenNoInput: true, + gmailAccountIndex: 0, +}; +export async function getActiveBaseEmail(): Promise { + const r: any = await browser.storage.local.get([ + "settings", + "email_accounts", + "base_email", + ]); + const s = (r.settings || {}) as AliasSettings; + if (s.defaultBaseEmail) return s.defaultBaseEmail; + if (s.baseEmails?.[0]) return s.baseEmails[0]; + const active = Array.isArray(r.email_accounts) + ? r.email_accounts.find((a: any) => a.isActive) + : null; + return active?.email || r.base_email || ""; +} +export async function loadAliasData(): Promise { + const r: any = await browser.storage.local.get(Object.values(STORAGE_KEYS)); + return { + siteAliases: (r.siteAliases || {}) as Record, + settings: { + ...DEFAULT_SETTINGS, + ...((r.settings || {}) as Partial), + }, + recentAliases: (r.recentAliases || []) as SiteAlias[], + favoriteAliases: (r.favoriteAliases || []) as SiteAlias[], + }; +} +export async function migrateStorageIfNeeded() { + const r: any = await browser.storage.local.get([ + "settings", + "email_accounts", + "base_email", + "backup_before_alias_manager_migration", + ]); + const settings = { ...DEFAULT_SETTINGS, ...(r.settings || {}) }; + const emails = new Set(settings.baseEmails || []); + if (Array.isArray(r.email_accounts)) + r.email_accounts.forEach((a: any) => a.email && emails.add(a.email)); + if (r.base_email) emails.add(r.base_email); + const next = { + ...settings, + baseEmails: [...emails], + defaultBaseEmail: settings.defaultBaseEmail || [...emails][0], + }; + if (!r.backup_before_alias_manager_migration) + await browser.storage.local.set({ + backup_before_alias_manager_migration: r, + }); + await browser.storage.local.set({ settings: next }); +} +export async function saveSiteAlias(alias: SiteAlias) { + const data = await loadAliasData(); + const siteAliases = { ...data.siteAliases, [alias.hostname]: alias }; + const recentAliases = [ + alias, + ...data.recentAliases.filter((a) => a.id !== alias.id), + ].slice(0, 50); + await browser.storage.local.set({ siteAliases, recentAliases }); +} +export async function touchAlias(hostname: string): Promise { + const data = await loadAliasData(); + const a = data.siteAliases[hostname]; + if (!a) return null; + const updated = { + ...a, + lastUsedAt: nowIso(), + updatedAt: nowIso(), + useCount: (a.useCount || 0) + 1, + }; + await saveSiteAlias(updated); + return updated; +} +export async function updateAliasStatus(hostname: string, status: AliasStatus) { + const data = await loadAliasData(); + const a = data.siteAliases[hostname]; + if (a) await saveSiteAlias({ ...a, status, updatedAt: nowIso() }); +} +export async function deleteSiteAlias(hostname: string) { + const data = await loadAliasData(); + delete data.siteAliases[hostname]; + await browser.storage.local.set({ siteAliases: data.siteAliases }); +} diff --git a/wxt.config.ts b/wxt.config.ts index c2d68f8..95335d0 100644 --- a/wxt.config.ts +++ b/wxt.config.ts @@ -9,8 +9,8 @@ export default defineConfig({ manifest: { name: "Gmail Alias Toolkit", description: - "Generate and manage Gmail aliases with plus addressing and presets", - permissions: ["storage", "clipboardWrite", "contextMenus"], + "Local-first Gmail plus alias manager. No account. No server. No tracking.", + permissions: ["storage", "activeTab", "clipboardWrite", "contextMenus"], host_permissions: [""], browser_specific_settings: { gecko: {