Sfoglia il codice sorgente

feat: temp email API, duck API generation, remove debugger

- Add Temp Email API provider: poll verification codes via HTTP fetch
  instead of opening QQ/163 mail tabs, with MIME Base64 subject decoding
- Duck email generation via API: extract access_token on first run,
  then use quack.duckduckgo.com/api/email/addresses directly
- Remove chrome.debugger usage in Step 8: use content script
  simulateClick instead, eliminating "controlled by debugger" banner
- Detect phone verification page in Step 7 and skip to next run
- Fix bfcache error handling with automatic tab reload and retry
- Fix null entry crash in ensureAutomationWindowId
- Preserve tempApiUrl and duckToken across auto-run iterations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
chendeben 4 mesi fa
parent
commit
4b96c4c5e6
7 ha cambiato i file con 808 aggiunte e 239 eliminazioni
  1. 467 160
      background.js
  2. 53 14
      content/duck-mail.js
  3. 14 2
      content/qq-mail.js
  4. 239 59
      content/signup-page.js
  5. 0 1
      manifest.json
  6. 5 0
      sidepanel/sidepanel.html
  7. 30 3
      sidepanel/sidepanel.js

+ 467 - 160
background.js

@@ -23,7 +23,7 @@ async function ensureAutomationWindowId() {
   }
   const registry = await getTabRegistry();
   for (const entry of Object.values(registry)) {
-    if (entry.tabId) {
+    if (entry && entry.tabId) {
       try {
         const tab = await chrome.tabs.get(entry.tabId);
         automationWindowId = tab.windowId;
@@ -38,9 +38,18 @@ async function ensureAutomationWindowId() {
 
 
 // ============================================================
-// State Management (chrome.storage.session)
+// State Management (chrome.storage.session + local for persistence)
 // ============================================================
 
+// Keys that should survive extension reload (saved to chrome.storage.local)
+const PERSISTENT_KEYS = [
+  'vpsUrl', 'customPassword', 'mailProvider',
+  'tempApiUrl',
+  'inbucketHost', 'inbucketMailbox', 'accounts',
+  'seenCodes', 'seenInbucketMailIds',
+  'duckToken',
+];
+
 const DEFAULT_STATE = {
   currentStep: 0,
   stepStatuses: {
@@ -58,7 +67,8 @@ const DEFAULT_STATE = {
   logs: [],
   vpsUrl: '',
   customPassword: '',
-  mailProvider: '163', // 'qq' or '163'
+  mailProvider: 'temp-api', // 'temp-api', 'qq', '163', or 'inbucket'
+  tempApiUrl: '',
   inbucketHost: '',
   inbucketMailbox: '',
 };
@@ -79,11 +89,35 @@ async function initializeSessionStorageAccess() {
   } catch (err) {
     console.warn(LOG_PREFIX, 'Failed to enable storage.session for content scripts:', err?.message || err);
   }
+
+  // Restore persistent settings from chrome.storage.local into session
+  try {
+    const saved = await chrome.storage.local.get(PERSISTENT_KEYS);
+    const toRestore = {};
+    for (const key of PERSISTENT_KEYS) {
+      if (saved[key] !== undefined) toRestore[key] = saved[key];
+    }
+    if (Object.keys(toRestore).length > 0) {
+      await chrome.storage.session.set(toRestore);
+      console.log(LOG_PREFIX, 'Restored persistent settings from local storage:', Object.keys(toRestore).join(', '));
+    }
+  } catch (err) {
+    console.warn(LOG_PREFIX, 'Failed to restore persistent settings:', err?.message || err);
+  }
 }
 
 async function setState(updates) {
   console.log(LOG_PREFIX, 'storage.set:', JSON.stringify(updates).slice(0, 200));
   await chrome.storage.session.set(updates);
+
+  // Persist relevant keys to chrome.storage.local so they survive reload
+  const toPersist = {};
+  for (const key of PERSISTENT_KEYS) {
+    if (key in updates) toPersist[key] = updates[key];
+  }
+  if (Object.keys(toPersist).length > 0) {
+    await chrome.storage.local.set(toPersist);
+  }
 }
 
 function broadcastDataUpdate(payload) {
@@ -117,18 +151,22 @@ async function resetState() {
     'inbucketHost',
     'inbucketMailbox',
   ]);
+
+  // Also check chrome.storage.local in case session was empty (after reload)
+  const local = await chrome.storage.local.get(PERSISTENT_KEYS);
+
   await chrome.storage.session.clear();
   await chrome.storage.session.set({
     ...DEFAULT_STATE,
-    seenCodes: prev.seenCodes || [],
-    seenInbucketMailIds: prev.seenInbucketMailIds || [],
-    accounts: prev.accounts || [],
+    seenCodes: prev.seenCodes || local.seenCodes || [],
+    seenInbucketMailIds: prev.seenInbucketMailIds || local.seenInbucketMailIds || [],
+    accounts: prev.accounts || local.accounts || [],
     tabRegistry: prev.tabRegistry || {},
-    vpsUrl: prev.vpsUrl || '',
-    customPassword: prev.customPassword || '',
-    mailProvider: prev.mailProvider || '163',
-    inbucketHost: prev.inbucketHost || '',
-    inbucketMailbox: prev.inbucketMailbox || '',
+    vpsUrl: prev.vpsUrl || local.vpsUrl || '',
+    customPassword: prev.customPassword || local.customPassword || '',
+    mailProvider: prev.mailProvider || local.mailProvider || '163',
+    inbucketHost: prev.inbucketHost || local.inbucketHost || '',
+    inbucketMailbox: prev.inbucketMailbox || local.inbucketMailbox || '',
   });
 }
 
@@ -392,7 +430,38 @@ async function sendToContentScript(source, message) {
   }
 
   console.log(LOG_PREFIX, `Sending to ${source} (tab ${entry.tabId}):`, message.type);
-  return chrome.tabs.sendMessage(entry.tabId, message);
+  try {
+    return await chrome.tabs.sendMessage(entry.tabId, message);
+  } catch (err) {
+    // Handle bfcache / port closed errors by reloading the tab and retrying
+    if (err?.message?.includes('back/forward cache') || err?.message?.includes('message channel is closed')) {
+      console.log(LOG_PREFIX, `${source} hit bfcache error, reloading tab and retrying...`);
+      try {
+        await chrome.tabs.reload(entry.tabId);
+        // Wait for page to reload and content script to re-inject
+        await new Promise((resolve) => {
+          const timer = setTimeout(() => { chrome.tabs.onUpdated.removeListener(listener); resolve(); }, 15000);
+          const listener = (tid, info) => {
+            if (tid === entry.tabId && info.status === 'complete') {
+              chrome.tabs.onUpdated.removeListener(listener);
+              clearTimeout(timer);
+              resolve();
+            }
+          };
+          chrome.tabs.onUpdated.addListener(listener);
+        });
+        // Mark as not ready and wait for READY signal
+        const reg = await getTabRegistry();
+        if (reg[source]) reg[source].ready = false;
+        await setState({ tabRegistry: reg });
+        // Queue the command — it'll be sent once the content script signals READY
+        return queueCommand(source, message);
+      } catch (retryErr) {
+        throw new Error(`${source} bfcache recovery failed: ${retryErr.message}`);
+      }
+    }
+    throw err;
+  }
 }
 
 // ============================================================
@@ -455,57 +524,7 @@ async function humanStepDelay(min = HUMAN_STEP_DELAY_MIN, max = HUMAN_STEP_DELAY
   await sleepWithStop(duration);
 }
 
-async function clickWithDebugger(tabId, rect) {
-  if (!tabId) {
-    throw new Error('No auth tab found for debugger click.');
-  }
-  if (!rect || !Number.isFinite(rect.centerX) || !Number.isFinite(rect.centerY)) {
-    throw new Error('Step 8 debugger fallback needs a valid button position.');
-  }
-
-  const target = { tabId };
-  try {
-    await chrome.debugger.attach(target, '1.3');
-  } catch (err) {
-    throw new Error(
-      `Debugger attach failed during step 8 fallback: ${err.message}. ` +
-      'If DevTools is open on the auth tab, close it and retry.'
-    );
-  }
-
-  try {
-    const x = Math.round(rect.centerX);
-    const y = Math.round(rect.centerY);
-
-    await chrome.debugger.sendCommand(target, 'Page.bringToFront');
-    await chrome.debugger.sendCommand(target, 'Input.dispatchMouseEvent', {
-      type: 'mouseMoved',
-      x,
-      y,
-      button: 'none',
-      buttons: 0,
-      clickCount: 0,
-    });
-    await chrome.debugger.sendCommand(target, 'Input.dispatchMouseEvent', {
-      type: 'mousePressed',
-      x,
-      y,
-      button: 'left',
-      buttons: 1,
-      clickCount: 1,
-    });
-    await chrome.debugger.sendCommand(target, 'Input.dispatchMouseEvent', {
-      type: 'mouseReleased',
-      x,
-      y,
-      button: 'left',
-      buttons: 0,
-      clickCount: 1,
-    });
-  } finally {
-    await chrome.debugger.detach(target).catch(() => {});
-  }
-}
+// clickWithDebugger removed — Step 8 now uses content script simulateClick directly
 
 async function broadcastStopToContentScripts() {
   const registry = await getTabRegistry();
@@ -627,6 +646,7 @@ async function handleMessage(message, sender) {
       if (message.payload.vpsUrl !== undefined) updates.vpsUrl = message.payload.vpsUrl;
       if (message.payload.customPassword !== undefined) updates.customPassword = message.payload.customPassword;
       if (message.payload.mailProvider !== undefined) updates.mailProvider = message.payload.mailProvider;
+      if (message.payload.tempApiUrl !== undefined) updates.tempApiUrl = message.payload.tempApiUrl;
       if (message.payload.inbucketHost !== undefined) updates.inbucketHost = message.payload.inbucketHost;
       if (message.payload.inbucketMailbox !== undefined) updates.inbucketMailbox = message.payload.inbucketMailbox;
       await setState(updates);
@@ -821,29 +841,84 @@ async function executeStepAndWait(step, delayAfter = 2000) {
 
 async function fetchDuckEmail(options = {}) {
   throwIfStopped();
-  const { generateNew = true } = options;
 
-  await addLog(`Duck Mail: Opening autofill settings (${generateNew ? 'generate new' : 'reuse current'})...`);
+  // Try API-based generation first (no tab needed)
+  const state = await getState();
+  let token = state.duckToken;
+
+  if (token) {
+    try {
+      const email = await generateDuckEmailViaApi(token);
+      await setEmailState(email);
+      await addLog(`Duck Mail: Generated ${email} (via API, no tab)`, 'ok');
+      return email;
+    } catch (err) {
+      await addLog(`Duck Mail: API generation failed: ${err.message}, falling back to page...`, 'warn');
+      // Token may be expired, clear it so we re-extract
+      token = null;
+      await setState({ duckToken: null });
+    }
+  }
+
+  // Fallback: open page, extract token for future use, then generate
+  await addLog('Duck Mail: Opening page to extract token...');
   await reuseOrCreateTab('duck-mail', DUCK_AUTOFILL_URL);
 
+  // Try to extract and save the access token
+  try {
+    const tokenResult = await sendToContentScript('duck-mail', {
+      type: 'EXTRACT_DUCK_TOKEN',
+      source: 'background',
+      payload: {},
+    });
+    if (tokenResult?.token) {
+      token = tokenResult.token;
+      await setState({ duckToken: token });
+      await addLog(`Duck Mail: Token saved for ${tokenResult.username}, future runs won't need page`, 'ok');
+
+      // Now use API to generate
+      const email = await generateDuckEmailViaApi(token);
+      await setEmailState(email);
+      await addLog(`Duck Mail: Generated ${email} (via API)`, 'ok');
+      return email;
+    }
+  } catch (err) {
+    await addLog(`Duck Mail: Token extraction failed: ${err.message}, using page method`, 'warn');
+  }
+
+  // Final fallback: use the old page-based method
   const result = await sendToContentScript('duck-mail', {
     type: 'FETCH_DUCK_EMAIL',
     source: 'background',
-    payload: { generateNew },
+    payload: { generateNew: true },
   });
 
-  if (result?.error) {
-    throw new Error(result.error);
-  }
-  if (!result?.email) {
-    throw new Error('Duck email not returned.');
-  }
+  if (result?.error) throw new Error(result.error);
+  if (!result?.email) throw new Error('Duck email not returned.');
 
   await setEmailState(result.email);
-  await addLog(`Duck Mail: ${result.generated ? 'Generated' : 'Loaded'} ${result.email}`, 'ok');
+  await addLog(`Duck Mail: ${result.generated ? 'Generated' : 'Loaded'} ${result.email} (via page)`, 'ok');
   return result.email;
 }
 
+async function generateDuckEmailViaApi(token) {
+  const resp = await fetch('https://quack.duckduckgo.com/api/email/addresses', {
+    method: 'POST',
+    headers: {
+      'Authorization': `Bearer ${token}`,
+      'Content-Type': 'application/json',
+    },
+  });
+  if (!resp.ok) {
+    throw new Error(`DuckDuckGo API returned ${resp.status}`);
+  }
+  const data = await resp.json();
+  // Response: { address: "random-words" }
+  const address = data?.address;
+  if (!address) throw new Error('No address in API response');
+  return `${address}@duck.com`;
+}
+
 // ============================================================
 // Auto Run Flow
 // ============================================================
@@ -862,6 +937,8 @@ async function autoRunLoop(totalRuns) {
   clearStopRequest();
   autoRunActive = true;
   autoRunTotalRuns = totalRuns;
+  let successCount = 0;
+  let failCount = 0;
   await setState({ autoRunning: true });
 
   for (let run = 1; run <= totalRuns; run++) {
@@ -872,8 +949,10 @@ async function autoRunLoop(totalRuns) {
     const keepSettings = {
       vpsUrl: prevState.vpsUrl,
       mailProvider: prevState.mailProvider,
+      tempApiUrl: prevState.tempApiUrl,
       inbucketHost: prevState.inbucketHost,
       inbucketMailbox: prevState.inbucketMailbox,
+      duckToken: prevState.duckToken,
       autoRunning: true,
     };
     await resetState();
@@ -931,29 +1010,30 @@ async function autoRunLoop(totalRuns) {
       await executeStepAndWait(8, 2000);
       await executeStepAndWait(9, 1000);
 
-      await addLog(`=== Run ${run}/${totalRuns} COMPLETE! ===`, 'ok');
+      successCount++;
+      await addLog(`=== Run ${run}/${totalRuns} COMPLETE! (${successCount} succeeded, ${failCount} failed) ===`, 'ok');
 
     } catch (err) {
       if (isStopError(err)) {
         await addLog(`Run ${run}/${totalRuns} stopped by user`, 'warn');
+        chrome.runtime.sendMessage(status('stopped')).catch(() => {});
+        break; // Only stop on explicit user stop
       } else {
-        await addLog(`Run ${run}/${totalRuns} failed: ${err.message}`, 'error');
+        failCount++;
+        await addLog(`Run ${run}/${totalRuns} failed: ${err.message} (${successCount} succeeded, ${failCount} failed)`, 'error');
+        await addLog(`Skipping to next run...`, 'warn');
+        continue; // Continue to next run on error
       }
-      chrome.runtime.sendMessage(status('stopped')).catch(() => {});
-      break; // Stop on error
     }
   }
 
-  const completedRuns = autoRunCurrentRun;
+  const attempted = autoRunCurrentRun;
   if (stopRequested) {
-    await addLog(`=== Stopped after ${Math.max(0, completedRuns - 1)}/${autoRunTotalRuns} runs ===`, 'warn');
-    chrome.runtime.sendMessage({ type: 'AUTO_RUN_STATUS', payload: { phase: 'stopped', currentRun: completedRuns, totalRuns: autoRunTotalRuns } }).catch(() => {});
-  } else if (completedRuns >= autoRunTotalRuns) {
-    await addLog(`=== All ${autoRunTotalRuns} runs completed successfully ===`, 'ok');
-    chrome.runtime.sendMessage({ type: 'AUTO_RUN_STATUS', payload: { phase: 'complete', currentRun: completedRuns, totalRuns: autoRunTotalRuns } }).catch(() => {});
+    await addLog(`=== Stopped after ${Math.max(0, attempted - 1)}/${autoRunTotalRuns} runs — ${successCount} succeeded, ${failCount} failed ===`, 'warn');
+    chrome.runtime.sendMessage({ type: 'AUTO_RUN_STATUS', payload: { phase: 'stopped', currentRun: attempted, totalRuns: autoRunTotalRuns, successCount, failCount } }).catch(() => {});
   } else {
-    await addLog(`=== Stopped after ${completedRuns}/${autoRunTotalRuns} runs ===`, 'warn');
-    chrome.runtime.sendMessage({ type: 'AUTO_RUN_STATUS', payload: { phase: 'stopped', currentRun: completedRuns, totalRuns: autoRunTotalRuns } }).catch(() => {});
+    await addLog(`=== All ${autoRunTotalRuns} runs finished — ${successCount} succeeded, ${failCount} failed ===`, successCount > 0 ? 'ok' : 'warn');
+    chrome.runtime.sendMessage({ type: 'AUTO_RUN_STATUS', payload: { phase: 'complete', currentRun: attempted, totalRuns: autoRunTotalRuns, successCount, failCount } }).catch(() => {});
   }
   autoRunActive = false;
   await setState({ autoRunning: false });
@@ -1053,8 +1133,38 @@ async function executeStep3(state) {
 // Step 4: Get Signup Verification Code (qq-mail.js polls, then fills in signup-page.js)
 // ============================================================
 
-function getMailConfig(state) {
-  const provider = state.mailProvider || 'qq';
+async function getMailConfig(state) {
+  const provider = state.mailProvider || 'temp-api';
+  if (provider === 'temp-api') {
+    let rawUrl = (state.tempApiUrl || '').trim();
+    // Fallback: read directly from chrome.storage.local in case session is stale
+    if (!rawUrl) {
+      const local = await chrome.storage.local.get('tempApiUrl');
+      rawUrl = (local.tempApiUrl || '').trim();
+    }
+    if (!rawUrl) {
+      return { error: 'Temp Email API URL is empty. Please paste the full URL with JWT in Side Panel.' };
+    }
+    // Extract base origin and JWT from the URL (e.g. https://host/?jwt=xxx)
+    let apiOrigin, jwt;
+    try {
+      const parsed = new URL(rawUrl);
+      apiOrigin = parsed.origin;
+      jwt = parsed.searchParams.get('jwt');
+    } catch {
+      return { error: 'Temp Email API URL is invalid.' };
+    }
+    if (!jwt) {
+      return { error: 'Temp Email API URL has no jwt parameter.' };
+    }
+    return {
+      source: 'temp-api',
+      apiOrigin,
+      jwt,
+      label: 'Temp Email API',
+      useFetch: true, // signal that this provider uses fetch, not a tab
+    };
+  }
   if (provider === '163') {
     return { source: 'mail-163', url: 'https://mail.163.com/js6/main.jsp?df=mail163_letter#module=mbox.ListModule%7C%7B%22fid%22%3A1%2C%22order%22%3A%22date%22%2C%22desc%22%3Atrue%7D', label: '163 Mail' };
   }
@@ -1093,6 +1203,130 @@ function normalizeInbucketOrigin(rawValue) {
   }
 }
 
+// ============================================================
+// Temp Email API: fetch-based polling (no tab needed)
+// ============================================================
+
+function extractVerificationCodeFromText(text) {
+  // Pattern 1: Chinese format "代码为 370794" or "验证码...370794"
+  const matchCn = text.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/);
+  if (matchCn) return matchCn[1];
+  // Pattern 2: English format "code is 370794" or "code: 370794"
+  const matchEn = text.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i);
+  if (matchEn) return matchEn[1] || matchEn[2];
+  // Pattern 3: standalone 6-digit number
+  const match6 = text.match(/\b(\d{6})\b/);
+  if (match6) return match6[1];
+  return null;
+}
+
+function decodeMimeWord(encoded) {
+  // Decode =?charset?encoding?text?= (RFC 2047)
+  return encoded.replace(/=\?([^?]+)\?(B|Q)\?([^?]*)\?=/gi, (_, charset, enc, text) => {
+    if (enc.toUpperCase() === 'B') {
+      // Base64
+      try { return atob(text); } catch { return text; }
+    }
+    // Quoted-Printable
+    return text.replace(/_/g, ' ').replace(/=([0-9A-Fa-f]{2})/g, (__, hex) =>
+      String.fromCharCode(parseInt(hex, 16))
+    );
+  });
+}
+
+function decodeUtf8Bytes(str) {
+  // If the decoded string contains raw UTF-8 bytes, decode them
+  try { return decodeURIComponent(escape(str)); } catch { return str; }
+}
+
+function extractSubjectFromRaw(raw) {
+  // Extract Subject header (may span multiple lines with folding)
+  const match = raw.match(/^Subject:\s*([\s\S]*?)(?=\r?\n[^\s]|\r?\n\r?\n)/mi);
+  if (!match) return '';
+  // Unfold continuation lines and decode MIME words
+  const unfolded = match[1].replace(/\r?\n\s+/g, ' ').trim();
+  return decodeUtf8Bytes(decodeMimeWord(unfolded));
+}
+
+function extractFromHeader(raw) {
+  const match = raw.match(/^From:\s*([\s\S]*?)(?=\r?\n[^\s]|\r?\n\r?\n)/mi);
+  if (!match) return '';
+  return match[1].replace(/\r?\n\s+/g, ' ').trim().toLowerCase();
+}
+
+function extractPlainTextFromRaw(raw) {
+  // Try to extract the text/plain part from a multipart email
+  const plainMatch = raw.match(/Content-Type:\s*text\/plain[\s\S]*?\r?\n\r?\n([\s\S]*?)(?:\r?\n--|\r?\n$)/i);
+  if (plainMatch) {
+    let text = plainMatch[1];
+    // Decode quoted-printable
+    text = text.replace(/=\r?\n/g, '');
+    text = text.replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
+    try { return decodeURIComponent(escape(text)); } catch { return text; }
+  }
+  return raw;
+}
+
+async function pollTempEmailApi(mail, step, payload) {
+  const { senderFilters, subjectFilters, maxAttempts, intervalMs, usedMailIds, usedCodes } = payload;
+  const usedIdSet = new Set((usedMailIds || []).map(String));
+  const usedCodeSet = new Set(usedCodes || []);
+
+  const headers = { 'Authorization': `Bearer ${mail.jwt}` };
+
+  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+    throwIfStopped();
+    await addLog(`Step ${step}: Polling Temp Email API... attempt ${attempt}/${maxAttempts}`);
+
+    try {
+      const resp = await fetch(`${mail.apiOrigin}/api/mails?limit=10&offset=0`, { headers });
+      if (!resp.ok) throw new Error(`API returned ${resp.status}`);
+      const data = await resp.json();
+      const mails = data.results || [];
+
+      for (const m of mails) {
+        if (usedIdSet.has(String(m.id))) continue;
+
+        const raw = m.raw || '';
+        const source = (m.source || '').toLowerCase();
+        const fromHeader = extractFromHeader(raw);
+        const subject = extractSubjectFromRaw(raw).toLowerCase();
+        const plainText = extractPlainTextFromRaw(raw);
+
+        // Match against both envelope sender and From header
+        const senderMatch = senderFilters.some(f => {
+          const fl = f.toLowerCase();
+          return source.includes(fl) || fromHeader.includes(fl);
+        });
+        const subjectMatch = subjectFilters.some(f => subject.includes(f.toLowerCase()));
+
+        if (senderMatch || subjectMatch) {
+          const code = extractVerificationCodeFromText(subject + ' ' + plainText);
+          if (code) {
+            if (usedCodeSet.has(code)) {
+              console.log(LOG_PREFIX, `Step ${step}: Skipping already-used code ${code} (id: ${m.id})`);
+              continue;
+            }
+            await addLog(`Step ${step}: Code found from API: ${code} (subject: ${subject.slice(0, 40)})`, 'ok');
+            return { ok: true, code, emailTimestamp: Date.now(), mailId: String(m.id) };
+          }
+        }
+      }
+    } catch (err) {
+      await addLog(`Step ${step}: API fetch error: ${err.message}`, 'warn');
+    }
+
+    if (attempt < maxAttempts) {
+      await sleepWithStop(intervalMs);
+    }
+  }
+
+  throw new Error(
+    `No matching email found via Temp Email API after ${(maxAttempts * intervalMs / 1000).toFixed(0)}s. ` +
+    'Email may be delayed.'
+  );
+}
+
 async function clickResendOnSignupPage(step) {
   const signupTabId = await getTabId('signup-page');
   if (!signupTabId) return;
@@ -1115,49 +1349,68 @@ async function executeStep4(state) {
   // Click "重新发送电子邮件" on the signup page before polling
   await clickResendOnSignupPage(4);
 
-  const mail = getMailConfig(state);
+  // Re-read state to ensure tempApiUrl is fresh (may have been saved after initial getState)
+  const freshState = await getState();
+  const mail = await getMailConfig(freshState);
   if (mail.error) throw new Error(mail.error);
-  await addLog(`Step 4: Opening ${mail.label}...`);
 
-  // For mail tabs, only create if not alive — don't navigate (preserves login session)
-  const alive = await isTabAlive(mail.source);
-  if (alive) {
-    if (mail.navigateOnReuse) {
+  const pollPayload = {
+    filterAfterTimestamp: state.flowStartTime || 0,
+    senderFilters: ['openai', 'noreply', 'verify', 'auth', 'duckduckgo', 'forward'],
+    subjectFilters: ['verify', 'verification', 'code', '验证', 'confirm'],
+    targetEmail: state.email,
+    maxAttempts: 20,
+    intervalMs: 3000,
+    usedMailIds: state.usedMailIds || [],
+    usedCodes: state.usedCodes || [],
+  };
+
+  let result;
+
+  if (mail.useFetch) {
+    // Temp Email API: fetch directly, no tab needed
+    await addLog(`Step 4: Polling ${mail.label}...`);
+    result = await pollTempEmailApi(mail, 4, pollPayload);
+  } else {
+    await addLog(`Step 4: Opening ${mail.label}...`);
+    // For mail tabs, only create if not alive — don't navigate (preserves login session)
+    const alive = await isTabAlive(mail.source);
+    if (alive) {
+      if (mail.navigateOnReuse) {
+        await reuseOrCreateTab(mail.source, mail.url, {
+          inject: mail.inject,
+          injectSource: mail.injectSource,
+        });
+      } else {
+        const tabId = await getTabId(mail.source);
+        await chrome.tabs.update(tabId, { active: true });
+      }
+    } else {
       await reuseOrCreateTab(mail.source, mail.url, {
         inject: mail.inject,
         injectSource: mail.injectSource,
       });
-    } else {
-      const tabId = await getTabId(mail.source);
-      await chrome.tabs.update(tabId, { active: true });
     }
-  } else {
-    await reuseOrCreateTab(mail.source, mail.url, {
-      inject: mail.inject,
-      injectSource: mail.injectSource,
+
+    result = await sendToContentScript(mail.source, {
+      type: 'POLL_EMAIL',
+      step: 4,
+      source: 'background',
+      payload: pollPayload,
     });
   }
 
-  const result = await sendToContentScript(mail.source, {
-    type: 'POLL_EMAIL',
-    step: 4,
-    source: 'background',
-    payload: {
-      filterAfterTimestamp: state.flowStartTime || 0,
-      senderFilters: ['openai', 'noreply', 'verify', 'auth', 'duckduckgo', 'forward'],
-      subjectFilters: ['verify', 'verification', 'code', '验证', 'confirm'],
-      targetEmail: state.email,
-      maxAttempts: 20,
-      intervalMs: 3000,
-    },
-  });
-
   if (result && result.error) {
     throw new Error(result.error);
   }
 
   if (result && result.code) {
-    await setState({ lastEmailTimestamp: result.emailTimestamp });
+    // Track used mail IDs and codes to avoid reusing stale ones
+    const usedMailIds = [...(state.usedMailIds || [])];
+    const usedCodes = [...(state.usedCodes || [])];
+    if (result.mailId) usedMailIds.push(result.mailId);
+    usedCodes.push(result.code);
+    await setState({ lastEmailTimestamp: result.emailTimestamp, usedMailIds, usedCodes });
     await addLog(`Step 4: Got verification code: ${result.code}`);
 
     // Switch to signup tab and fill code
@@ -1184,6 +1437,9 @@ async function executeStep5(state) {
   const { firstName, lastName } = generateRandomName();
   const { year, month, day } = generateRandomBirthday();
 
+  // Save birthday data to state for potential reuse on about-you page
+  await setState({ birthYear: year, birthMonth: month, birthDay: day, fullName: `${firstName} ${lastName}` });
+
   await addLog(`Step 5: Generated name: ${firstName} ${lastName}, Birthday: ${year}-${month}-${day}`);
 
   await sendToContentScript('signup-page', {
@@ -1224,50 +1480,105 @@ async function executeStep6(state) {
 // ============================================================
 
 async function executeStep7(state) {
+  // Check if page landed on add-phone — means phone verification required, skip this run
+  const signupTabIdCheck = await getTabId('signup-page');
+  if (signupTabIdCheck) {
+    try {
+      const tab = await chrome.tabs.get(signupTabIdCheck);
+      if (tab.url && (tab.url.includes('add-phone') || tab.url.includes('/phone'))) {
+        throw new Error('Phone verification required — skipping this account.');
+      }
+    } catch (err) {
+      if (err.message.includes('Phone verification')) throw err;
+    }
+  }
+
+  // Check if the page is on /about-you (birthday not filled during signup)
+  // and handle it before proceeding with verification code polling
+  try {
+    const aboutYouResult = await sendToContentScript('signup-page', {
+      type: 'HANDLE_ABOUT_YOU',
+      step: 7,
+      source: 'background',
+      payload: {
+        year: state.birthYear,
+        month: state.birthMonth,
+        day: state.birthDay,
+        fullName: state.fullName,
+      },
+    });
+    if (aboutYouResult && aboutYouResult.handled) {
+      await addLog('Step 7: Handled about-you page, waiting for next page...', 'info');
+      await sleep(3000);
+    }
+  } catch (e) {
+    // Not on about-you page, continue normally
+  }
+
   // Click "重新发送电子邮件" on the auth page before polling
   await clickResendOnSignupPage(7);
 
-  const mail = getMailConfig(state);
+  const mail = await getMailConfig(state);
   if (mail.error) throw new Error(mail.error);
-  await addLog(`Step 7: Opening ${mail.label}...`);
 
-  const alive = await isTabAlive(mail.source);
-  if (alive) {
-    if (mail.navigateOnReuse) {
+  // Re-read state to get latest usedMailIds/usedCodes
+  const freshState = await getState();
+
+  const pollPayload = {
+    filterAfterTimestamp: freshState.lastEmailTimestamp || freshState.flowStartTime || 0,
+    senderFilters: ['openai', 'noreply', 'verify', 'auth', 'chatgpt', 'duckduckgo', 'forward'],
+    subjectFilters: ['verify', 'verification', 'code', '验证', 'confirm', 'login'],
+    targetEmail: freshState.email,
+    maxAttempts: 20,
+    intervalMs: 3000,
+    usedMailIds: freshState.usedMailIds || [],
+    usedCodes: freshState.usedCodes || [],
+  };
+
+  let result;
+
+  if (mail.useFetch) {
+    await addLog(`Step 7: Polling ${mail.label}...`);
+    result = await pollTempEmailApi(mail, 7, pollPayload);
+  } else {
+    await addLog(`Step 7: Opening ${mail.label}...`);
+    const alive = await isTabAlive(mail.source);
+    if (alive) {
+      if (mail.navigateOnReuse) {
+        await reuseOrCreateTab(mail.source, mail.url, {
+          inject: mail.inject,
+          injectSource: mail.injectSource,
+        });
+      } else {
+        const tabId = await getTabId(mail.source);
+        await chrome.tabs.update(tabId, { active: true });
+      }
+    } else {
       await reuseOrCreateTab(mail.source, mail.url, {
         inject: mail.inject,
         injectSource: mail.injectSource,
       });
-    } else {
-      const tabId = await getTabId(mail.source);
-      await chrome.tabs.update(tabId, { active: true });
     }
-  } else {
-    await reuseOrCreateTab(mail.source, mail.url, {
-      inject: mail.inject,
-      injectSource: mail.injectSource,
+
+    result = await sendToContentScript(mail.source, {
+      type: 'POLL_EMAIL',
+      step: 7,
+      source: 'background',
+      payload: pollPayload,
     });
   }
 
-  const result = await sendToContentScript(mail.source, {
-    type: 'POLL_EMAIL',
-    step: 7,
-    source: 'background',
-    payload: {
-      filterAfterTimestamp: state.lastEmailTimestamp || state.flowStartTime || 0,
-      senderFilters: ['openai', 'noreply', 'verify', 'auth', 'chatgpt', 'duckduckgo', 'forward'],
-      subjectFilters: ['verify', 'verification', 'code', '验证', 'confirm', 'login'],
-      targetEmail: state.email,
-      maxAttempts: 20,
-      intervalMs: 3000,
-    },
-  });
-
   if (result && result.error) {
     throw new Error(result.error);
   }
 
   if (result && result.code) {
+    // Track used mail IDs and codes
+    const usedMailIds = [...(freshState.usedMailIds || [])];
+    const usedCodes = [...(freshState.usedCodes || [])];
+    if (result.mailId) usedMailIds.push(result.mailId);
+    usedCodes.push(result.code);
+    await setState({ usedMailIds, usedCodes });
     await addLog(`Step 7: Got login verification code: ${result.code}`);
 
     // Switch to signup/auth tab and fill code
@@ -1360,17 +1671,16 @@ async function executeStep8(state) {
     chrome.webNavigation.onErrorOccurred.addListener(webNavListener);
 
     // After step 7, the auth page shows a consent screen ("使用 ChatGPT 登录到 Codex")
-    // with a "继续" button. We locate the button in-page, then click it through
-    // the debugger Input API directly.
+    // with a "继续" button. Content script clicks it directly (no debugger needed).
     (async () => {
       try {
         let signupTabId = await getTabId('signup-page');
         if (signupTabId) {
           await chrome.tabs.update(signupTabId, { active: true });
-          await addLog('Step 8: Switched to auth page. Preparing debugger click...');
+          await addLog('Step 8: Switched to auth page...');
         } else {
           signupTabId = await reuseOrCreateTab('signup-page', state.oauthUrl);
-          await addLog('Step 8: Auth tab reopened. Preparing debugger click...');
+          await addLog('Step 8: Auth tab reopened...');
         }
 
         const clickResult = await sendToContentScript('signup-page', {
@@ -1383,21 +1693,18 @@ async function executeStep8(state) {
           throw new Error(clickResult.error);
         }
 
-        if (!resolved) {
-          await clickWithDebugger(signupTabId, clickResult?.rect);
-          await addLog('Step 8: Debugger click dispatched, waiting for redirect...');
-
-          // Fallback: poll tab URL in case webNavigation listeners missed the redirect
-          for (let i = 0; i < 30 && !resolved; i++) {
-            await new Promise(r => setTimeout(r, 1000));
-            try {
-              const tab = await chrome.tabs.get(signupTabId);
-              if (isLocalhostUrl(tab.url)) {
-                captureLocalhostUrl(tab.url);
-                break;
-              }
-            } catch { break; }
-          }
+        await addLog('Step 8: "继续" button clicked, waiting for redirect...');
+
+        // Poll tab URL in case webNavigation listeners missed the redirect
+        for (let i = 0; i < 30 && !resolved; i++) {
+          await new Promise(r => setTimeout(r, 1000));
+          try {
+            const tab = await chrome.tabs.get(signupTabId);
+            if (isLocalhostUrl(tab.url)) {
+              captureLocalhostUrl(tab.url);
+              break;
+            }
+          } catch { break; }
         }
       } catch (err) {
         clearTimeout(timeout);

+ 53 - 14
content/duck-mail.js

@@ -3,23 +3,62 @@
 console.log('[MultiPage:duck-mail] Content script loaded on', location.href);
 
 chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
-  if (message.type !== 'FETCH_DUCK_EMAIL') return;
-
-  resetStopState();
-  fetchDuckEmail(message.payload).then(result => {
-    sendResponse(result);
-  }).catch(err => {
-    if (isStopError(err)) {
-      log('Duck Mail: Stopped by user.', 'warn');
-      sendResponse({ stopped: true, error: err.message });
-      return;
-    }
-    sendResponse({ error: err.message });
-  });
+  if (message.type === 'FETCH_DUCK_EMAIL') {
+    resetStopState();
+    fetchDuckEmail(message.payload).then(result => {
+      sendResponse(result);
+    }).catch(err => {
+      if (isStopError(err)) {
+        log('Duck Mail: Stopped by user.', 'warn');
+        sendResponse({ stopped: true, error: err.message });
+        return;
+      }
+      sendResponse({ error: err.message });
+    });
+    return true;
+  }
 
-  return true;
+  if (message.type === 'EXTRACT_DUCK_TOKEN') {
+    extractDuckToken().then(result => {
+      sendResponse(result);
+    }).catch(err => {
+      sendResponse({ error: err.message });
+    });
+    return true;
+  }
 });
 
+async function extractDuckToken() {
+  log('Duck Mail: Extracting access token...');
+
+  // The DuckDuckGo email app stores userData in a React state.
+  // We can intercept it by reading the __NEXT_DATA__ or by calling their API
+  // with cookies that the browser already has.
+  // Strategy: call the dashboard API endpoint which returns user info including token.
+
+  // Strategy 1: Try to find token in page's fetch calls by hooking into the app state
+  // The app calls quack.duckduckgo.com/api/email/dashboard with credentials
+  try {
+    const resp = await fetch('https://quack.duckduckgo.com/api/email/dashboard', {
+      credentials: 'include',
+    });
+    if (resp.ok) {
+      const data = await resp.json();
+      // Response has { user: { access_token, username, ... } }
+      const token = data?.user?.access_token;
+      const username = data?.user?.username;
+      if (token) {
+        log(`Duck Mail: Token extracted for ${username}`, 'ok');
+        return { token, username };
+      }
+    }
+  } catch (e) {
+    log(`Duck Mail: Dashboard API failed: ${e.message}`, 'warn');
+  }
+
+  throw new Error('Could not extract DuckDuckGo access token. Make sure you are logged in.');
+}
+
 async function fetchDuckEmail(payload = {}) {
   const { generateNew = true } = payload;
 

+ 14 - 2
content/qq-mail.js

@@ -55,7 +55,11 @@ function getCurrentMailIds() {
 // ============================================================
 
 async function handlePollEmail(step, payload) {
-  const { senderFilters, subjectFilters, maxAttempts, intervalMs } = payload;
+  const { senderFilters, subjectFilters, maxAttempts, intervalMs, usedMailIds, usedCodes } = payload;
+
+  // Build sets of already-used mail IDs and codes to skip during fallback
+  const usedMailIdSet = new Set(usedMailIds || []);
+  const usedCodeSet = new Set(usedCodes || []);
 
   log(`Step ${step}: Starting email poll (max ${maxAttempts} attempts, every ${intervalMs / 1000}s)`);
 
@@ -88,12 +92,15 @@ async function handlePollEmail(step, payload) {
     const useFallback = attempt > FALLBACK_AFTER;
 
     // Phase 1 (attempt 1~3): only look at NEW emails (not in snapshot)
-    // Phase 2 (attempt 4+): fallback to first matching email in list
+    // Phase 2 (attempt 4+): fallback to first matching email in list (but skip used ones)
     for (const item of allItems) {
       const mailId = item.getAttribute('data-mailid');
 
       if (!useFallback && existingMailIds.has(mailId)) continue;
 
+      // Skip mail IDs that have already been used in previous runs
+      if (usedMailIdSet.has(mailId)) continue;
+
       const sender = (item.querySelector('.cmp-account-nick')?.textContent || '').toLowerCase();
       const subject = (item.querySelector('.mail-subject')?.textContent || '').toLowerCase();
       const digest = item.querySelector('.mail-digest')?.textContent || '';
@@ -104,6 +111,11 @@ async function handlePollEmail(step, payload) {
       if (senderMatch || subjectMatch) {
         const code = extractVerificationCode(subject + ' ' + digest);
         if (code) {
+          // Skip codes that have already been used
+          if (usedCodeSet.has(code)) {
+            log(`Step ${step}: Skipping already-used code ${code} (mailId: ${mailId})`);
+            continue;
+          }
           const source = useFallback && existingMailIds.has(mailId) ? 'fallback-first-match' : 'new';
           log(`Step ${step}: Code found: ${code} (${source}, subject: ${subject.slice(0, 40)})`, 'ok');
           return { ok: true, code, emailTimestamp: Date.now(), mailId };

+ 239 - 59
content/signup-page.js

@@ -5,7 +5,7 @@ console.log('[MultiPage:signup-page] Content script loaded on', location.href);
 
 // Listen for commands from Background
 chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
-  if (message.type === 'EXECUTE_STEP' || message.type === 'FILL_CODE' || message.type === 'STEP8_FIND_AND_CLICK' || message.type === 'CLICK_RESEND_EMAIL') {
+  if (message.type === 'EXECUTE_STEP' || message.type === 'FILL_CODE' || message.type === 'STEP8_FIND_AND_CLICK' || message.type === 'CLICK_RESEND_EMAIL' || message.type === 'HANDLE_ABOUT_YOU') {
     resetStopState();
     handleCommand(message).then((result) => {
       sendResponse({ ok: true, ...(result || {}) });
@@ -47,6 +47,8 @@ async function handleCommand(message) {
       return await clickResendEmail(message.step);
     case 'STEP8_FIND_AND_CLICK':
       return await step8_findAndClick();
+    case 'HANDLE_ABOUT_YOU':
+      return await handleAboutYouPage(message.payload);
   }
 }
 
@@ -210,6 +212,8 @@ async function fillVerificationCode(step, payload) {
         await sleep(100);
       }
       await sleep(1000);
+      // Verify page navigated away from verification page
+      await verifyCodeAccepted(step);
       reportComplete(step);
       return;
     }
@@ -219,19 +223,49 @@ async function fillVerificationCode(step, payload) {
   fillInput(codeInput, code);
   log(`Step ${step}: Code filled`);
 
-  // Report complete BEFORE submit (page may navigate away)
-  reportComplete(step);
-
   // Submit
   await sleep(500);
   const submitBtn = document.querySelector('button[type="submit"]')
-    || await waitForElementByText('button', /verify|confirm|submit|continue|确认|验证/i, 5000).catch(() => null);
+    || await waitForElementByText('button', /verify|confirm|submit|continue|确认|验证|继续/i, 5000).catch(() => null);
 
   if (submitBtn) {
     await humanPause(450, 1200);
     simulateClick(submitBtn);
     log(`Step ${step}: Verification submitted`);
   }
+
+  // Wait and verify the page actually moved past the verification page
+  await verifyCodeAccepted(step);
+  reportComplete(step);
+}
+
+async function verifyCodeAccepted(step, timeout = 8000) {
+  const start = Date.now();
+  const verificationPaths = ['/email-verification', '/verify', '/otp'];
+
+  while (Date.now() - start < timeout) {
+    throwIfStopped();
+    const currentPath = location.pathname;
+    const stillOnVerification = verificationPaths.some(p => currentPath.includes(p));
+    if (!stillOnVerification) {
+      log(`Step ${step}: Verification code accepted, page navigated to ${currentPath}`);
+      return;
+    }
+
+    // Check for error messages on the page (wrong code)
+    const errorEl = document.querySelector('[class*="error"], [class*="Error"], [role="alert"]');
+    if (errorEl) {
+      const errorText = (errorEl.textContent || '').trim();
+      if (errorText && errorText.length < 200) {
+        throw new Error(`Verification code rejected: ${errorText}. URL: ${location.href}`);
+      }
+    }
+
+    await sleep(500);
+  }
+
+  // Still on verification page after timeout — code was likely wrong
+  throw new Error(`Verification code ${step === 4 ? 'signup' : 'login'} was not accepted (page did not navigate). URL: ${location.href}`);
 }
 
 // ============================================================
@@ -350,10 +384,11 @@ async function step8_findAndClick() {
   continueBtn.focus();
   await sleep(250);
 
-  const rect = getSerializableRect(continueBtn);
-  log('Step 8: Found "继续" button and prepared debugger click coordinates.');
+  // Click directly from content script — no debugger needed
+  simulateClick(continueBtn);
+  log('Step 8: Clicked "继续" button directly.', 'ok');
   return {
-    rect,
+    clicked: true,
     buttonText: (continueBtn.textContent || '').trim(),
     url: location.href,
   };
@@ -406,6 +441,185 @@ function getSerializableRect(el) {
   };
 }
 
+// ============================================================
+// Handle /about-you page (appears after login if birthday was missing)
+// ============================================================
+
+async function handleAboutYouPage(payload) {
+  if (!location.href.includes('/about-you')) {
+    return { handled: false };
+  }
+
+  log('Detected /about-you page, filling birthday info...');
+
+  const { year, month, day, fullName } = payload || {};
+  if (!year || !month || !day) {
+    throw new Error('No birthday data available for about-you page.');
+  }
+
+  // Wait for the page to fully load
+  await sleep(1000);
+
+  // Fill name if present and empty
+  const nameInput = document.querySelector('input[name="name"], input[placeholder*="全名"], input[autocomplete="name"]');
+  if (nameInput && !nameInput.value && fullName) {
+    fillInput(nameInput, fullName);
+    log('About-you: Name filled');
+    await humanPause(300, 800);
+  }
+
+  // Fill birthday using the shared helper
+  await fillBirthdayFields(year, month, day, 'About-you');
+
+  // Click continue/submit button
+  await sleep(500);
+  const submitBtn = document.querySelector('button[type="submit"]')
+    || await waitForElementByText('button', /continue|继续|完成|done|agree|submit/i, 5000).catch(() => null);
+  if (submitBtn) {
+    await humanPause(500, 1300);
+    simulateClick(submitBtn);
+    log('About-you: Submitted form');
+  }
+
+  return { handled: true };
+}
+
+// ============================================================
+// Shared birthday filling helper (used by Step 5 and about-you)
+// ============================================================
+
+async function fillBirthdayFields(year, month, day, logPrefix) {
+  const prefix = logPrefix || 'Birthday';
+
+  // Strategy 1: React Aria DateField spinbuttons
+  const yearSpinner = document.querySelector('[role="spinbutton"][data-type="year"]');
+  const monthSpinner = document.querySelector('[role="spinbutton"][data-type="month"]');
+  const daySpinner = document.querySelector('[role="spinbutton"][data-type="day"]');
+
+  if (yearSpinner && monthSpinner && daySpinner) {
+    log(`${prefix}: Filling spinbutton date fields...`);
+
+    async function setSpinButton(el, value) {
+      el.focus();
+      await sleep(100);
+      document.execCommand('selectAll', false, null);
+      await sleep(50);
+
+      const valueStr = String(value);
+      for (const char of valueStr) {
+        el.dispatchEvent(new KeyboardEvent('keydown', { key: char, code: `Digit${char}`, bubbles: true }));
+        el.dispatchEvent(new KeyboardEvent('keypress', { key: char, code: `Digit${char}`, bubbles: true }));
+        el.dispatchEvent(new InputEvent('beforeinput', { inputType: 'insertText', data: char, bubbles: true }));
+        el.dispatchEvent(new InputEvent('input', { inputType: 'insertText', data: char, bubbles: true }));
+        await sleep(50);
+      }
+
+      el.dispatchEvent(new KeyboardEvent('keyup', { key: 'Tab', code: 'Tab', bubbles: true }));
+      el.blur();
+      await sleep(100);
+    }
+
+    await humanPause(450, 1100);
+    await setSpinButton(yearSpinner, year);
+    await humanPause(250, 650);
+    await setSpinButton(monthSpinner, String(month).padStart(2, '0'));
+    await humanPause(250, 650);
+    await setSpinButton(daySpinner, String(day).padStart(2, '0'));
+    log(`${prefix}: Spinbutton date filled: ${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`);
+    return true;
+  }
+
+  // Strategy 2: Select dropdowns (month/day/year)
+  const selects = document.querySelectorAll('select');
+  if (selects.length >= 2) {
+    let monthSelect = null, daySelect = null, yearSelect = null;
+    for (const sel of selects) {
+      const name = (sel.name || sel.id || sel.getAttribute('aria-label') || '').toLowerCase();
+      const opts = Array.from(sel.options).map(o => o.value);
+      if (name.includes('month') || name.includes('mm')) {
+        monthSelect = sel;
+      } else if (name.includes('day') || name.includes('dd')) {
+        daySelect = sel;
+      } else if (name.includes('year') || name.includes('yyyy')) {
+        yearSelect = sel;
+      } else {
+        // Heuristic: identify by option count and values
+        const numericOpts = opts.filter(v => /^\d+$/.test(v));
+        if (!monthSelect && numericOpts.length >= 12 && numericOpts.length <= 13) {
+          monthSelect = sel;
+        } else if (!daySelect && numericOpts.length >= 28 && numericOpts.length <= 32) {
+          daySelect = sel;
+        } else if (!yearSelect && numericOpts.some(v => Number(v) > 1900 && Number(v) < 2100)) {
+          yearSelect = sel;
+        }
+      }
+    }
+
+    if (monthSelect || daySelect || yearSelect) {
+      log(`${prefix}: Filling select dropdown date fields...`);
+      if (monthSelect) {
+        setSelectValue(monthSelect, String(month));
+        await humanPause(200, 500);
+      }
+      if (daySelect) {
+        setSelectValue(daySelect, String(day));
+        await humanPause(200, 500);
+      }
+      if (yearSelect) {
+        setSelectValue(yearSelect, String(year));
+        await humanPause(200, 500);
+      }
+      log(`${prefix}: Select date filled: ${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`);
+      return true;
+    }
+  }
+
+  // Strategy 3: input[type="date"]
+  const dateInput = document.querySelector('input[type="date"]');
+  if (dateInput) {
+    const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
+    const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
+    nativeSetter.call(dateInput, dateStr);
+    dateInput.dispatchEvent(new Event('input', { bubbles: true }));
+    dateInput.dispatchEvent(new Event('change', { bubbles: true }));
+    log(`${prefix}: Date input filled: ${dateStr}`);
+    return true;
+  }
+
+  // Strategy 4: Hidden input[name="birthday"] (fallback)
+  const hiddenBirthday = document.querySelector('input[name="birthday"]');
+  if (hiddenBirthday) {
+    const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
+    hiddenBirthday.value = dateStr;
+    hiddenBirthday.dispatchEvent(new Event('change', { bubbles: true }));
+    log(`${prefix}: Hidden birthday input set: ${dateStr}`);
+    return true;
+  }
+
+  return false;
+}
+
+function setSelectValue(selectEl, value) {
+  // Try exact match first, then try padded value
+  const candidates = [value, value.padStart(2, '0')];
+  for (const v of candidates) {
+    const option = Array.from(selectEl.options).find(o => o.value === v || o.textContent.trim() === v);
+    if (option) {
+      selectEl.value = option.value;
+      selectEl.dispatchEvent(new Event('change', { bubbles: true }));
+      selectEl.dispatchEvent(new Event('input', { bubbles: true }));
+      return;
+    }
+  }
+  // Last resort: set by index if value is numeric
+  const numVal = Number(value);
+  if (!isNaN(numVal) && numVal > 0 && numVal < selectEl.options.length) {
+    selectEl.selectedIndex = numVal;
+    selectEl.dispatchEvent(new Event('change', { bubbles: true }));
+    selectEl.dispatchEvent(new Event('input', { bubbles: true }));
+  }
+}
+
 // ============================================================
 // Step 5: Fill Name & Birthday / Age
 // ============================================================
@@ -442,74 +656,40 @@ async function step5_fillNameBirthday(payload) {
   fillInput(nameInput, fullName);
   log(`Step 5: Name filled: ${fullName}`);
 
-  let birthdayMode = false;
+  // Detect birthday/age input type with polling
   let ageInput = null;
+  let hasBirthdayUI = false;
 
   for (let i = 0; i < 100; i++) {
-    const yearSpinner = document.querySelector('[role="spinbutton"][data-type="year"]');
-    const monthSpinner = document.querySelector('[role="spinbutton"][data-type="month"]');
-    const daySpinner = document.querySelector('[role="spinbutton"][data-type="day"]');
-    const hiddenBirthday = document.querySelector('input[name="birthday"]');
     ageInput = document.querySelector('input[name="age"]');
-
     // Some pages include a hidden birthday input even though the real UI is "age".
     // In that case we must prioritize filling age to satisfy required validation.
     if (ageInput) break;
 
-    if ((yearSpinner && monthSpinner && daySpinner) || hiddenBirthday) {
-      birthdayMode = true;
+    // Check for any supported birthday UI (spinbuttons, selects, date input, hidden)
+    const yearSpinner = document.querySelector('[role="spinbutton"][data-type="year"]');
+    const monthSpinner = document.querySelector('[role="spinbutton"][data-type="month"]');
+    const daySpinner = document.querySelector('[role="spinbutton"][data-type="day"]');
+    const hiddenBirthday = document.querySelector('input[name="birthday"]');
+    const dateInput = document.querySelector('input[type="date"]');
+    const selects = document.querySelectorAll('select');
+
+    if ((yearSpinner && monthSpinner && daySpinner) || hiddenBirthday || dateInput || selects.length >= 2) {
+      hasBirthdayUI = true;
       break;
     }
     await sleep(100);
   }
 
-  if (birthdayMode) {
+  if (hasBirthdayUI) {
     if (!hasBirthdayData) {
       throw new Error('Birthday field detected, but no birthday data provided.');
     }
 
-    const yearSpinner = document.querySelector('[role="spinbutton"][data-type="year"]');
-    const monthSpinner = document.querySelector('[role="spinbutton"][data-type="month"]');
-    const daySpinner = document.querySelector('[role="spinbutton"][data-type="day"]');
-
-    if (yearSpinner && monthSpinner && daySpinner) {
-      log('Step 5: Birthday fields detected, filling birthday...');
-
-      async function setSpinButton(el, value) {
-        el.focus();
-        await sleep(100);
-        document.execCommand('selectAll', false, null);
-        await sleep(50);
-
-        const valueStr = String(value);
-        for (const char of valueStr) {
-          el.dispatchEvent(new KeyboardEvent('keydown', { key: char, code: `Digit${char}`, bubbles: true }));
-          el.dispatchEvent(new KeyboardEvent('keypress', { key: char, code: `Digit${char}`, bubbles: true }));
-          el.dispatchEvent(new InputEvent('beforeinput', { inputType: 'insertText', data: char, bubbles: true }));
-          el.dispatchEvent(new InputEvent('input', { inputType: 'insertText', data: char, bubbles: true }));
-          await sleep(50);
-        }
-
-        el.dispatchEvent(new KeyboardEvent('keyup', { key: 'Tab', code: 'Tab', bubbles: true }));
-        el.blur();
-        await sleep(100);
-      }
-
-      await humanPause(450, 1100);
-      await setSpinButton(yearSpinner, year);
-      await humanPause(250, 650);
-      await setSpinButton(monthSpinner, String(month).padStart(2, '0'));
-      await humanPause(250, 650);
-      await setSpinButton(daySpinner, String(day).padStart(2, '0'));
-      log(`Step 5: Birthday filled: ${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`);
-    }
-
-    const hiddenBirthday = document.querySelector('input[name="birthday"]');
-    if (hiddenBirthday) {
-      const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
-      hiddenBirthday.value = dateStr;
-      hiddenBirthday.dispatchEvent(new Event('change', { bubbles: true }));
-      log(`Step 5: Hidden birthday input set: ${dateStr}`);
+    // Use shared helper that tries spinbuttons → selects → date input → hidden input
+    const filled = await fillBirthdayFields(year, month, day, 'Step 5');
+    if (!filled) {
+      log('Step 5: Warning - could not fill any visible birthday control', 'warn');
     }
   } else if (ageInput) {
     if (resolvedAge == null || Number.isNaN(Number(resolvedAge))) {

+ 0 - 1
manifest.json

@@ -7,7 +7,6 @@
     "sidePanel",
     "tabs",
     "webNavigation",
-    "debugger",
     "storage",
     "scripting",
     "activeTab"

+ 5 - 0
sidepanel/sidepanel.html

@@ -51,11 +51,16 @@
       <div class="data-row">
         <span class="data-label">Mail</span>
         <select id="select-mail-provider" class="data-select">
+          <option value="temp-api">Temp Email API</option>
           <option value="163">163 Mail (mail.163.com)</option>
           <option value="qq">QQ Mail (wx.mail.qq.com)</option>
           <option value="inbucket">Inbucket (custom host)</option>
         </select>
       </div>
+      <div class="data-row" id="row-temp-api-url" style="display:none;">
+        <span class="data-label">API URL</span>
+        <input type="text" id="input-temp-api-url" class="data-input" placeholder="https://temp-email-api.xxx.cc/?jwt=..." />
+      </div>
       <div class="data-row" id="row-inbucket-host" style="display:none;">
         <span class="data-label">Inbucket</span>
         <input type="text" id="input-inbucket-host" class="data-input" placeholder="your-inbucket-host or https://your-inbucket-host" />

+ 30 - 3
sidepanel/sidepanel.js

@@ -26,6 +26,8 @@ const autoContinueBar = document.getElementById('auto-continue-bar');
 const btnClearLog = document.getElementById('btn-clear-log');
 const inputVpsUrl = document.getElementById('input-vps-url');
 const selectMailProvider = document.getElementById('select-mail-provider');
+const rowTempApiUrl = document.getElementById('row-temp-api-url');
+const inputTempApiUrl = document.getElementById('input-temp-api-url');
 const rowInbucketHost = document.getElementById('row-inbucket-host');
 const inputInbucketHost = document.getElementById('input-inbucket-host');
 const rowInbucketMailbox = document.getElementById('row-inbucket-mailbox');
@@ -90,6 +92,9 @@ async function restoreState() {
     if (state.mailProvider) {
       selectMailProvider.value = state.mailProvider;
     }
+    if (state.tempApiUrl) {
+      inputTempApiUrl.value = state.tempApiUrl;
+    }
     if (state.inbucketHost) {
       inputInbucketHost.value = state.inbucketHost;
     }
@@ -112,6 +117,16 @@ async function restoreState() {
     updateStatusDisplay(state);
     updateProgressCounter();
     updateMailProviderUI();
+
+    // Sync temp API URL from UI to state (handles browser autocomplete / form restore)
+    const currentTempApiUrl = inputTempApiUrl.value.trim();
+    if (currentTempApiUrl && currentTempApiUrl !== (state.tempApiUrl || '')) {
+      await chrome.runtime.sendMessage({
+        type: 'SAVE_SETTING',
+        source: 'sidepanel',
+        payload: { tempApiUrl: currentTempApiUrl },
+      });
+    }
   } catch (err) {
     console.error('Failed to restore state:', err);
   }
@@ -122,9 +137,10 @@ function syncPasswordField(state) {
 }
 
 function updateMailProviderUI() {
-  const useInbucket = selectMailProvider.value === 'inbucket';
-  rowInbucketHost.style.display = useInbucket ? '' : 'none';
-  rowInbucketMailbox.style.display = useInbucket ? '' : 'none';
+  const val = selectMailProvider.value;
+  rowTempApiUrl.style.display = val === 'temp-api' ? '' : 'none';
+  rowInbucketHost.style.display = val === 'inbucket' ? '' : 'none';
+  rowInbucketMailbox.style.display = val === 'inbucket' ? '' : 'none';
 }
 
 // ============================================================
@@ -412,6 +428,17 @@ inputInbucketMailbox.addEventListener('change', async () => {
   });
 });
 
+// Save on both 'change' (blur) and 'input' (immediate, e.g. paste)
+for (const evt of ['change', 'input']) {
+  inputTempApiUrl.addEventListener(evt, async () => {
+    await chrome.runtime.sendMessage({
+      type: 'SAVE_SETTING',
+      source: 'sidepanel',
+      payload: { tempApiUrl: inputTempApiUrl.value.trim() },
+    });
+  });
+}
+
 inputInbucketHost.addEventListener('change', async () => {
   await chrome.runtime.sendMessage({
     type: 'SAVE_SETTING',