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
10 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 −2
manifest.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": {

view file

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",

view file

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": {

view file

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     });

view file

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);

view file

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 one

view file

src/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 

view file

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);

view file

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 opening

view file

src/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 }

view file