3ae3a65f503f
fix: detect separate email fields + exclude personal name fields from autofill (v3.6.156)
- time
- 2026-06-04 15:46 UTC · 4 months ago
- machine
- koh2
- agent
- claude-sonnet-4-6
- session
- signup form username+email+name detection
- parent
- 46998e6f05b2
↓ peach-browser-3ae3a65f.tar.gzatlas at this save
files changed10 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/content/content.adversarial.test.ts+2 −2src/content/detect.test.ts+1 −1src/content/detect.ts+16 −2src/content/fill.ts+6 −1src/content/index.ts+15 −7src/content/inject.ts+1 −1src/content/interaction.ts+12 −2manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22 "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33 "manifest_version": 3,44 "name": "Peach Password Manager",5- "version": "3.6.155",5+ "version": "3.6.156",66 "description": "Zero-knowledge password manager",77 "browser_specific_settings": {88 "gecko": {manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "3.6.155",4+ "version": "3.6.156",55 "description": "Zero-knowledge password manager",66 "action": {77 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "3.6.155",3+ "version": "3.6.156",44 "description": "Zero-knowledge password manager",55 "type": "module",66 "scripts": {src/content/content.adversarial.test.ts+2 −2
@@ -190,10 +190,10 @@190190 );191191 });192192 193- it("email input is detected as username", () => {193+ it("email input is detected as email type", () => {194194 const i = visibleInput({ type: "email" });195195 const fields = detectLoginFields();196- expect(fields.some((f) => f.el === i && f.type === "username")).toBe(196+ expect(fields.some((f) => f.el === i && f.type === "email")).toBe(197197 true198198 );199199 });src/content/detect.test.ts+1 −1
@@ -86,7 +86,7 @@8686 8787 const fields = detectLoginFields(document);8888 89- expect(fields.some((field) => field.el === email && field.type === "username")).toBe(true);89+ expect(fields.some((field) => field.el === email && field.type === "email")).toBe(true);9090 expect(fields.some((field) => field.el === username && field.type === "username")).toBe(true);9191 expect(fields.some((field) => field.el === password && field.type === "password")).toBe(true);9292 expect(fields.some((field) => field.el === tagName)).toBe(false);src/content/detect.ts+16 −2
@@ -11,6 +11,10 @@1111 // at a word boundary.1212 const USERNAME_PATTERNS = /(user(name|id|login|email)?|e?mail(box)?|login(email|username)?|account|identifier|signin|sign.?in|credential)/i;1313 14+// Personal name fields (display name, first/last name) — not credential fields.15+// Applied to prevent the fallback from picking up name inputs on signup forms.16+const NAME_FIELD_PATTERNS = /\b(first[\s_\-]?name|last[\s_\-]?name|full[\s_\-]?name|display[\s_\-]?name|real[\s_\-]?name|given[\s_\-]?name|family[\s_\-]?name|middle[\s_\-]?name)\b|^(name|fname|lname|fullname|displayname|realname)$/i;17+1418 // Input types that are never text-entry fields and must never get autofill1519 // icons. submit/button/reset/image are interactive controls — putting the1620 // icon over them blocks the click. The rest are structurally incapable of@@ -208,11 +212,18 @@208212 continue;209213 }210214 211- if (ac === "username" || ac === "email" || type === "email") {215+ if (ac === "username") {212216 fields.push({ type: "username", el });213217 continue;214218 }215219 220+ // Explicit email autocomplete or type — classified as "email" so interaction.ts221+ // can track it separately from the username field on signup forms.222+ if (ac === "email" || type === "email") {223+ fields.push({ type: "email", el });224+ continue;225+ }226+216227 if (USERNAME_PATTERNS.test(name)) {217228 fields.push({ type: "username", el });218229 continue;@@ -235,11 +246,14 @@235246 for (let i = 0; i < pwIndex; i++) {236247 const candidate = allInputs[i];237248 const candidateType = candidate.type.toLowerCase();249+ const candidateHint = (candidate.name + " " + candidate.id + " " + candidate.placeholder).toLowerCase();250+ const candidateLabel = findLabelText(candidate);238251 if (239252 (candidateType === "text" || candidateType === "email" || candidateType === "") &&240253 isVisible(candidate) &&241254 !SKIP_INPUT_TYPES.has(candidateType) &&242- !fields.some((f) => f.el === candidate)255+ !fields.some((f) => f.el === candidate) &&256+ !NAME_FIELD_PATTERNS.test(candidateHint + " " + candidateLabel)243257 ) {244258 fields.push({ type: "username", el: candidate });245259 break; // only take the first onesrc/content/fill.ts+6 −1
@@ -23,7 +23,9 @@2323 username: string,2424 password: string,2525 form?: HTMLFormElement | null,26- expectedDomain?: string26+ expectedDomain?: string,27+ emailEl?: HTMLInputElement | null,28+ email?: string2729 ): void {2830 if (expectedDomain && form && !checkFormAction(form, expectedDomain)) {2931 return;@@ -35,6 +37,9 @@3537 }3638 if (passwordEl && isVisible(passwordEl)) {3739 setNativeValue(passwordEl, password);40+ }41+ if (emailEl && email && isVisible(emailEl)) {42+ setNativeValue(emailEl, email);3843 }3944 }4045 src/content/index.ts+15 −7
@@ -306,12 +306,17 @@306306 _field: HTMLInputElement,307307 usernameEl: HTMLInputElement | null,308308 passwordEl: HTMLInputElement | null,309- entry: VaultEntry309+ entry: VaultEntry,310+ emailEl?: HTMLInputElement | null310311 ): Promise<HTMLInputElement | null> {311- if (!entry.username && !entry.password) return null;312+ if (!entry.username && !entry.password && !entry.email) return null;312313 const pageDomain = getPageDomain();313314 const form = (passwordEl ?? usernameEl)?.closest("form") as HTMLFormElement | null ?? null;314- fillFields(usernameEl, passwordEl, entry.username ?? "", entry.password ?? "", form, pageDomain);315+ // Fill the email field with entry.email if set; fall back to entry.username when316+ // it looks like an email address (sites that store email-as-username and have a317+ // separate email field on signup forms).318+ const emailValue = entry.email ?? (entry.username?.includes("@") ? entry.username : "");319+ fillFields(usernameEl, passwordEl, entry.username ?? "", entry.password ?? "", form, pageDomain, emailEl, emailValue);315320 let filledTOTPField: HTMLInputElement | null = null;316321 317322 if (entry.canary) {@@ -433,7 +438,8 @@433438 domainEntries: SelectorEntry[],434439 searchableEntries: SelectorEntry[],435440 domain: string,436- identityKind: "email" | "username"441+ identityKind: "email" | "username",442+ emailEl?: HTMLInputElement | null437443 ): Promise<void> {438444 // No saved entries for this site → signup/new-account mode.439445 // Fetch vault identities for the identity-first surface and locate@@ -469,7 +475,7 @@469475 });470476 const entry = (entryResponse as { ok: boolean; data?: VaultEntry }).data;471477 if (entry) {472- const filledTOTPField = await handleAutofill(anchorField, usernameEl, passwordEl, entry);478+ const filledTOTPField = await handleAutofill(anchorField, usernameEl, passwordEl, entry, emailEl);473479 recordFillSuccess(anchorField, usernameEl, passwordEl, filledTOTPField);474480 // Offer to remember the current site for this entry only if none of475481 // its known domains (primary + additional) match — otherwise the@@ -492,9 +498,9 @@492498 isSignup,493499 identityKind,494500 savedIdentities,495- onFillFields: (username, password) => {501+ onFillFields: (username, password, email) => {496502 const form = (passwordEl ?? usernameEl)?.closest("form") as HTMLFormElement | null ?? null;497- fillFields(usernameEl, passwordEl, username, password, form, getPageDomain());503+ fillFields(usernameEl, passwordEl, username, password, form, getPageDomain(), emailEl, email);498504 if (password && confirmEl) setNativeValue(confirmEl, password);499505 recordFillSuccess(anchorField, usernameEl, passwordEl);500506 },@@ -693,6 +699,7 @@693699 if (!explicitSummon && !pwField && !isEmailFirstForm(fields)) return;694700 const usernameEl = targets.usernameEl;695701 const passwordEl = targets.passwordEl;702+ const emailEl = targets.emailEl;696703 697704 await openAutofillSelector(698705 field,@@ -702,6 +709,7 @@702709 allEntries,703710 domain,704711 autofillFieldType === "email" ? "email" : "username",712+ emailEl,705713 );706714 } finally {707715 pendingInteractions.delete(field);src/content/inject.ts+1 −1
@@ -70,7 +70,7 @@7070 * after successfully saving a new entry on a signup form.7171 * Lets the caller fill the visible username + password fields.7272 */73- onFillFields?: (username: string, password: string) => void;73+ onFillFields?: (username: string, password: string, email?: string) => void;7474 /**7575 * When true, show an inline unlock form instead of the entries list.7676 * Used when the vault is locked so the user can unlock without openingsrc/content/interaction.ts+12 −2
@@ -4,6 +4,8 @@44 export interface AutofillInteractionTargets {55 usernameEl: HTMLInputElement | null;66 passwordEl: HTMLInputElement | null;7+ /** Separate email field, present only when the form has both a username and a distinct email field. */8+ emailEl: HTMLInputElement | null;79 shouldOpen: boolean;810 }911 @@ -17,14 +19,22 @@1719 fields: DetectedField[],1820 explicitSummon = false1921 ): AutofillInteractionTargets {20- const userField = fields.find((f) => f.type === "username" || f.type === "email");22+ const usernameField = fields.find((f) => f.type === "username");23+ const emailField = fields.find((f) => f.type === "email");2124 const pwField = fields.find((f) => f.type === "password");2225 const anchorType = anchorField.type.toLowerCase();2326 const fallbackType = anchorType === "password" ? "password" : "username";2427 28+ // When both a username and a dedicated email field exist, track them separately.29+ // When only an email field exists (email-first login / email-as-identifier), treat it30+ // as usernameEl so callers fill it with entry.username; emailEl stays null.31+ const hasDistinctUsername = !!usernameField;32+ const identityEl = usernameField?.el ?? emailField?.el ?? null;33+2534 return {26- usernameEl: userField?.el ?? (explicitSummon && fallbackType === "username" ? anchorField : null),35+ usernameEl: identityEl ?? (explicitSummon && fallbackType === "username" ? anchorField : null),2736 passwordEl: pwField?.el ?? (explicitSummon && fallbackType === "password" ? anchorField : null),37+ emailEl: hasDistinctUsername ? (emailField?.el ?? null) : null,2838 shouldOpen: explicitSummon || isAnyCredentialForm(fields),2939 };3040 }