浏览代码

Add A4Sky IMAP helper integration

chendeben 4 月之前
父节点
当前提交
3997a1b8e8

+ 1 - 0
.gitignore

@@ -8,3 +8,4 @@
 /data/account-run-history.json
 .npm-test.log
 .omx/
+/data/a4sky-imap.local.json

+ 94 - 0
background.js

@@ -1832,6 +1832,98 @@ async function requestHotmailLocalCode(account, pollPayload = {}) {
   };
 }
 
+async function requestA4skyLocalImapCode(state, pollPayload = {}) {
+  const helperBaseUrl = normalizeHotmailLocalBaseUrl(state?.hotmailLocalBaseUrl);
+  const { timeoutMs } = getHotmailMailApiRequestConfig();
+  const requestTimeoutMs = Math.max(timeoutMs, HOTMAIL_LOCAL_HELPER_TIMEOUT_MS);
+  const controller = new AbortController();
+  const timeoutId = setTimeout(() => controller.abort(new Error('timeout')), requestTimeoutMs);
+
+  let response;
+  try {
+    response = await fetch(buildHotmailLocalEndpoint(helperBaseUrl, '/imap-code'), {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        Accept: 'application/json',
+      },
+      body: JSON.stringify({
+        targetEmail: String(state?.email || '').trim().toLowerCase(),
+        mailbox: 'INBOX',
+        top: 10,
+        senderFilters: pollPayload.senderFilters || [],
+        subjectFilters: pollPayload.subjectFilters || [],
+        excludeCodes: pollPayload.excludeCodes || [],
+        filterAfterTimestamp: Number(pollPayload.filterAfterTimestamp || 0) || 0,
+      }),
+      signal: controller.signal,
+    });
+  } catch (err) {
+    if (err?.name === 'AbortError') {
+      throw new Error(`A4Sky 本地 IMAP 助手请求超时(>${Math.round(requestTimeoutMs / 1000)} 秒)`);
+    }
+    throw new Error(`A4Sky 本地 IMAP 助手请求失败:${err.message}`);
+  } finally {
+    clearTimeout(timeoutId);
+  }
+
+  const text = await response.text();
+  let payload = {};
+  try {
+    payload = text ? JSON.parse(text) : {};
+  } catch {
+    payload = { raw: text };
+  }
+
+  if (!response.ok || payload?.ok === false) {
+    const errorText = payload?.error || payload?.message || text || `HTTP ${response.status}`;
+    throw new Error(`A4Sky 本地 IMAP 助手返回失败:${errorText}`);
+  }
+
+  return {
+    code: String(payload?.code || ''),
+    message: payload?.message || null,
+    usedTimeFallback: Boolean(payload?.usedTimeFallback),
+    transport: String(payload?.transport || ''),
+  };
+}
+
+async function pollA4skyImapVerificationCode(step, state, pollPayload = {}) {
+  const maxAttempts = Number(pollPayload.maxAttempts) || 5;
+  const intervalMs = Number(pollPayload.intervalMs) || 3000;
+  let lastError = null;
+
+  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
+    throwIfStopped();
+    try {
+      await addLog(`步骤 ${step}:正在通过本地 IMAP 助手轮询 A4Sky 验证码(${attempt}/${maxAttempts})...`, 'info');
+      const fetchResult = await requestA4skyLocalImapCode(state, pollPayload);
+
+      if (fetchResult.code) {
+        await addLog(`步骤 ${step}:已通过本地 IMAP 助手找到 A4Sky 验证码:${fetchResult.code}`, 'ok');
+        return {
+          ok: true,
+          code: fetchResult.code,
+          emailTimestamp: Number(fetchResult.message?.receivedTimestamp || 0) || Date.now(),
+          mailId: String(fetchResult.message?.id || ''),
+        };
+      }
+
+      lastError = new Error(`步骤 ${step}:本地 IMAP 助手暂未返回匹配验证码(${attempt}/${maxAttempts})。`);
+      await addLog(lastError.message, attempt === maxAttempts ? 'warn' : 'info');
+    } catch (err) {
+      lastError = err;
+      await addLog(`步骤 ${step}:本地 IMAP 助手轮询 A4Sky 失败:${err.message}`, 'warn');
+    }
+
+    if (attempt < maxAttempts) {
+      await sleepWithStop(intervalMs);
+    }
+  }
+
+  throw lastError || new Error(`步骤 ${step}:本地 IMAP 助手未返回新的匹配验证码。`);
+}
+
 async function pollHotmailVerificationCodeViaLocalHelper(step, account, pollPayload = {}) {
   const maxAttempts = Number(pollPayload.maxAttempts) || 5;
   const intervalMs = Number(pollPayload.intervalMs) || 3000;
@@ -5754,6 +5846,7 @@ const verificationFlowHelpers = self.MultiPageBackgroundVerificationFlow?.create
   LUCKMAIL_PROVIDER,
   MAIL_2925_VERIFICATION_INTERVAL_MS,
   MAIL_2925_VERIFICATION_MAX_ATTEMPTS,
+  pollA4skyImapVerificationCode,
   pollCloudflareTempEmailVerificationCode,
   pollHotmailVerificationCode,
   pollLuckmailVerificationCode,
@@ -5842,6 +5935,7 @@ const step7Executor = self.MultiPageBackgroundStep7?.createStep7Executor({
   throwIfStopped,
 });
 const step8Executor = self.MultiPageBackgroundStep8?.createStep8Executor({
+  A4SKY_PROVIDER,
   addLog,
   chrome,
   CLOUDFLARE_TEMP_EMAIL_PROVIDER,

+ 2 - 1
background/steps/fetch-login-code.js

@@ -3,6 +3,7 @@
 })(typeof self !== 'undefined' ? self : globalThis, function createBackgroundStep8Module() {
   function createStep8Executor(deps = {}) {
     const {
+      A4SKY_PROVIDER,
       addLog,
       chrome,
       CLOUDFLARE_TEMP_EMAIL_PROVIDER,
@@ -78,7 +79,7 @@
       }
 
       throwIfStopped();
-      if (mail.provider === HOTMAIL_PROVIDER || mail.provider === LUCKMAIL_PROVIDER || mail.provider === CLOUDFLARE_TEMP_EMAIL_PROVIDER) {
+      if (mail.provider === HOTMAIL_PROVIDER || mail.provider === LUCKMAIL_PROVIDER || mail.provider === CLOUDFLARE_TEMP_EMAIL_PROVIDER || mail.provider === A4SKY_PROVIDER) {
         await addLog(`步骤 8:正在通过 ${mail.label} 轮询验证码...`);
       } else {
         await addLog(`步骤 8:正在打开${mail.label}...`);

+ 1 - 1
background/steps/fetch-signup-code.js

@@ -67,7 +67,7 @@
       }
 
       throwIfStopped();
-      if (mail.provider === HOTMAIL_PROVIDER || mail.provider === LUCKMAIL_PROVIDER || mail.provider === CLOUDFLARE_TEMP_EMAIL_PROVIDER) {
+      if (mail.provider === HOTMAIL_PROVIDER || mail.provider === LUCKMAIL_PROVIDER || mail.provider === CLOUDFLARE_TEMP_EMAIL_PROVIDER || mail.provider === A4SKY_PROVIDER) {
         await addLog(`步骤 4:正在通过 ${mail.label} 轮询验证码...`);
       } else {
         await addLog(`步骤 4:正在打开${mail.label}...`);

+ 8 - 0
background/verification-flow.js

@@ -18,6 +18,7 @@
       LUCKMAIL_PROVIDER,
       MAIL_2925_VERIFICATION_INTERVAL_MS,
       MAIL_2925_VERIFICATION_MAX_ATTEMPTS,
+      pollA4skyImapVerificationCode,
       pollCloudflareTempEmailVerificationCode,
       pollHotmailVerificationCode,
       pollLuckmailVerificationCode,
@@ -390,6 +391,13 @@
         ...cleanPollOverrides
       } = pollOverrides;
 
+      if (mail.provider === A4SKY_PROVIDER) {
+        const timedPoll = await applyMailPollingTimeBudget(step, {
+          ...getVerificationPollPayload(step, state),
+          ...cleanPollOverrides,
+        }, cleanPollOverrides, `轮询${getVerificationCodeLabel(step)}验证码邮箱`);
+        return pollA4skyImapVerificationCode(step, state, timedPoll.payload);
+      }
       if (mail.provider === HOTMAIL_PROVIDER) {
         const hotmailPollConfig = getHotmailVerificationPollConfig(step);
         const timedPoll = await applyMailPollingTimeBudget(step, {

+ 180 - 9
scripts/hotmail_helper.py

@@ -9,15 +9,18 @@ import time
 import traceback
 from datetime import datetime, timezone
 from email.header import decode_header
-from email.utils import parseaddr, parsedate_to_datetime
+from email.utils import getaddresses, parseaddr, parsedate_to_datetime
 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
 from urllib.error import HTTPError, URLError
 from urllib.parse import urlencode
 from urllib.request import Request, urlopen
 
 
-HOST = "127.0.0.1"
-PORT = 17373
+HOST = os.environ.get("HOTMAIL_HELPER_HOST", "127.0.0.1").strip() or "127.0.0.1"
+try:
+    PORT = int(os.environ.get("HOTMAIL_HELPER_PORT", "17373") or 17373)
+except Exception:
+    PORT = 17373
 LIVE_TOKEN_URL = "https://login.live.com/oauth20_token.srf"
 ENTRA_COMMON_TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
 ENTRA_CONSUMERS_TOKEN_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"
@@ -65,6 +68,7 @@ FETCH_LIMIT_DEFAULT = 5
 BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
 ACCOUNT_LOG_PATH = os.path.join(BASE_DIR, "data", "account-run-history.txt")
 ACCOUNT_RECORDS_SNAPSHOT_PATH = os.path.join(BASE_DIR, "data", "account-run-history.json")
+A4SKY_IMAP_CONFIG_PATH = os.path.join(BASE_DIR, "data", "a4sky-imap.local.json")
 ACCOUNT_RECORDS_LOCK = threading.Lock()
 
 
@@ -328,12 +332,51 @@ def refresh_access_token(client_id, refresh_token, strategy_names=None):
     raise RuntimeError(f"Token refresh failed on all endpoints: {details}")
 
 
+def load_local_imap_config():
+    if not os.path.exists(A4SKY_IMAP_CONFIG_PATH):
+        return {}
+    try:
+        with open(A4SKY_IMAP_CONFIG_PATH, "r", encoding="utf-8") as handle:
+            payload = json.load(handle)
+            return payload if isinstance(payload, dict) else {}
+    except Exception as exc:
+        raise RuntimeError(f"Invalid local IMAP config: {exc}") from exc
+
+
+def resolve_basic_imap_settings(payload):
+    local_config = load_local_imap_config()
+    host = str(payload.get("host") or local_config.get("host") or "").strip()
+    username = str(payload.get("username") or local_config.get("username") or "").strip()
+    password = str(payload.get("password") or local_config.get("password") or "").strip()
+    port_raw = payload.get("port") if payload.get("port") is not None else local_config.get("port")
+    try:
+        port = int(port_raw or 993)
+    except Exception as exc:
+        raise RuntimeError(f"Invalid IMAP port: {exc}") from exc
+
+    if not host or not username or not password:
+        raise RuntimeError("Missing IMAP host/username/password. Please fill data/a4sky-imap.local.json or pass credentials explicitly.")
+
+    return {
+        "host": host,
+        "port": max(1, port),
+        "username": username,
+        "password": password,
+    }
+
+
+def open_basic_imap_mailbox(host, port, username, password):
+    client = imaplib.IMAP4_SSL(host, port)
+    client.login(username, password)
+    return client
+
+
 def build_xoauth2(email_addr, access_token):
     return f"user={email_addr}\x01auth=Bearer {access_token}\x01\x01".encode("utf-8")
 
 
 def open_mailbox(email_addr, access_token):
-    client = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT, timeout=REQUEST_TIMEOUT_SECONDS)
+    client = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT)
     client.authenticate("XOAUTH2", lambda _: build_xoauth2(email_addr, access_token))
     return client
 
@@ -427,6 +470,25 @@ def normalize_message(message_id, raw_bytes, mailbox):
     subject = decode_mime_header(parsed.get("Subject", ""))
     body = extract_text_part(parsed)
     timestamp_ms = to_timestamp_ms(parsed.get("Date"))
+
+    recipient_headers = []
+    for header_name in ["To", "Delivered-To", "Envelope-To", "X-Original-To", "Cc"]:
+        recipient_headers.extend(parsed.get_all(header_name, []))
+
+    recipient_items = []
+    recipient_addresses = []
+    for recipient_name, recipient_addr in getaddresses(recipient_headers):
+        normalized_addr = str(recipient_addr or "").strip().lower()
+        if not normalized_addr:
+            continue
+        recipient_addresses.append(normalized_addr)
+        recipient_items.append({
+            "emailAddress": {
+                "address": normalized_addr,
+                "name": str(recipient_name or "").strip(),
+            }
+        })
+
     return {
         "id": str(message_id),
         "mailbox": mailbox,
@@ -437,6 +499,8 @@ def normalize_message(message_id, raw_bytes, mailbox):
                 "name": sender_name.strip(),
             }
         },
+        "toRecipients": recipient_items,
+        "recipientAddresses": recipient_addresses,
         "bodyPreview": body[:500],
         "receivedDateTime": to_iso_string(timestamp_ms),
         "receivedTimestamp": timestamp_ms,
@@ -488,6 +552,70 @@ def fetch_messages_for_mailboxes(email_addr, access_token, mailboxes, top):
     return {"mailboxResults": mailbox_results, "messages": all_messages}
 
 
+def fetch_basic_imap_messages(host, port, username, password, mailbox="INBOX", top=FETCH_LIMIT_DEFAULT):
+    client = None
+    logical_mailbox = normalize_mailbox_label(mailbox)
+    try:
+        client = open_basic_imap_mailbox(host, port, username, password)
+        select_mailbox(client, mailbox)
+        status, data = client.search(None, "ALL")
+        if status != "OK" or not data or not data[0]:
+            return {"mailbox": logical_mailbox, "messages": [], "count": 0}
+
+        message_ids = data[0].split()
+        selected_ids = list(reversed(message_ids[-max(1, min(int(top or FETCH_LIMIT_DEFAULT), 30)):]))
+        messages = []
+        for message_id in selected_ids:
+            fetch_status, fetch_data = client.fetch(message_id, "(RFC822)")
+            if fetch_status != "OK" or not fetch_data:
+                continue
+            raw_bytes = b""
+            for item in fetch_data:
+                if isinstance(item, tuple) and len(item) >= 2:
+                    raw_bytes = item[1]
+                    break
+            if not raw_bytes:
+                continue
+            messages.append(normalize_message(message_id.decode("utf-8", errors="ignore"), raw_bytes, logical_mailbox))
+        return {"mailbox": logical_mailbox, "messages": messages, "count": len(messages)}
+    finally:
+        if client is not None:
+            try:
+                client.logout()
+            except Exception:
+                pass
+
+
+def fetch_basic_imap_messages_for_mailboxes(host, port, username, password, mailboxes, top):
+    mailbox_results = []
+    all_messages = []
+    for mailbox in mailboxes or ["INBOX"]:
+        result = fetch_basic_imap_messages(host, port, username, password, mailbox=mailbox, top=top)
+        mailbox_results.append(result)
+        all_messages.extend(result["messages"])
+    all_messages.sort(key=lambda item: int(item.get("receivedTimestamp") or 0), reverse=True)
+    return {"mailboxResults": mailbox_results, "messages": all_messages}
+
+
+def collect_basic_imap_messages(payload, mailboxes, top):
+    settings = resolve_basic_imap_settings(payload)
+    result = fetch_basic_imap_messages_for_mailboxes(
+        settings["host"],
+        settings["port"],
+        settings["username"],
+        settings["password"],
+        mailboxes,
+        top,
+    )
+    result["transport"] = "imap-basic"
+    result["settings"] = {
+        "host": settings["host"],
+        "port": settings["port"],
+        "username": settings["username"],
+    }
+    return result
+
+
 def normalize_graph_message(message, mailbox):
     sender = message.get("from", {}) or {}
     email_addr = sender.get("emailAddress", {}) if isinstance(sender, dict) else {}
@@ -664,11 +792,13 @@ def extract_code(text):
     return ""
 
 
-def select_latest_code(messages, sender_filters, subject_filters, exclude_codes, filter_after_timestamp):
+def select_latest_code(messages, sender_filters, subject_filters, exclude_codes, filter_after_timestamp, target_email=""):
     sender_keywords = [str(item).strip().lower() for item in sender_filters or [] if str(item).strip()]
     subject_keywords = [str(item).strip().lower() for item in subject_filters or [] if str(item).strip()]
     excluded = {str(item).strip() for item in exclude_codes or [] if str(item).strip()}
 
+    normalized_target_email = str(target_email or "").strip().lower()
+
     def match_message(message, apply_time_filter):
         timestamp = int(message.get("receivedTimestamp") or 0)
         if apply_time_filter and filter_after_timestamp and timestamp and timestamp < int(filter_after_timestamp):
@@ -677,7 +807,16 @@ def select_latest_code(messages, sender_filters, subject_filters, exclude_codes,
         sender = str(message.get("from", {}).get("emailAddress", {}).get("address", "")).lower()
         subject = str(message.get("subject", ""))
         preview = str(message.get("bodyPreview", ""))
-        combined = " ".join([sender, subject.lower(), preview.lower()])
+        recipient_addresses = [
+            str(item or "").strip().lower()
+            for item in message.get("recipientAddresses", [])
+            if str(item or "").strip()
+        ]
+        recipient_text = " ".join(recipient_addresses)
+        combined = " ".join([sender, subject.lower(), preview.lower(), recipient_text])
+        if normalized_target_email and recipient_addresses and normalized_target_email not in recipient_addresses:
+            return None
+
         code = extract_code(" ".join([subject, preview, sender]))
         if not code or code in excluded:
             return None
@@ -740,15 +879,46 @@ class HotmailHelperHandler(BaseHTTPRequestHandler):
                 })
                 return
 
+            top = max(1, min(int(payload.get("top") or FETCH_LIMIT_DEFAULT), 30))
+            mailboxes = payload.get("mailboxes") if isinstance(payload.get("mailboxes"), list) else [payload.get("mailbox") or "INBOX"]
+
+            if self.path == "/imap-messages":
+                result = collect_basic_imap_messages(payload, mailboxes, top)
+                json_response(self, 200, {
+                    "ok": True,
+                    "messages": result["messages"],
+                    "mailboxResults": result["mailboxResults"],
+                    "transport": result.get("transport") or "",
+                    "settings": result.get("settings") or {},
+                })
+                return
+
+            if self.path == "/imap-code":
+                result = collect_basic_imap_messages(payload, mailboxes, top)
+                selected = select_latest_code(
+                    result["messages"],
+                    payload.get("senderFilters") or [],
+                    payload.get("subjectFilters") or [],
+                    payload.get("excludeCodes") or [],
+                    int(payload.get("filterAfterTimestamp") or 0),
+                    payload.get("targetEmail") or payload.get("email") or "",
+                )
+                json_response(self, 200, {
+                    "ok": True,
+                    "code": selected["code"],
+                    "message": selected["message"],
+                    "usedTimeFallback": selected["usedTimeFallback"],
+                    "transport": result.get("transport") or "",
+                    "settings": result.get("settings") or {},
+                })
+                return
+
             email_addr = str(payload.get("email") or "").strip()
             client_id = str(payload.get("clientId") or "").strip()
             refresh_token = str(payload.get("refreshToken") or "").strip()
             if not email_addr or not client_id or not refresh_token:
                 raise RuntimeError("Missing email/clientId/refreshToken")
 
-            top = max(1, min(int(payload.get("top") or FETCH_LIMIT_DEFAULT), 30))
-            mailboxes = payload.get("mailboxes") if isinstance(payload.get("mailboxes"), list) else [payload.get("mailbox") or "INBOX"]
-
             if self.path == "/messages":
                 result = collect_messages(email_addr, client_id, refresh_token, mailboxes, top)
                 json_response(self, 200, {
@@ -769,6 +939,7 @@ class HotmailHelperHandler(BaseHTTPRequestHandler):
                     payload.get("subjectFilters") or [],
                     payload.get("excludeCodes") or [],
                     int(payload.get("filterAfterTimestamp") or 0),
+                    payload.get("targetEmail") or "",
                 )
                 json_response(self, 200, {
                     "ok": True,

+ 20 - 2
sidepanel/sidepanel.js

@@ -2366,7 +2366,7 @@ function updateMailProviderUI() {
   }
 
   if (hotmailSection) {
-    hotmailSection.style.display = useHotmail ? '' : 'none';
+    hotmailSection.style.display = useHotmail || useA4sky ? '' : 'none';
   }
   if (luckmailSection) {
     luckmailSection.style.display = useLuckmail ? '' : 'none';
@@ -2387,7 +2387,25 @@ function updateMailProviderUI() {
     rowHotmailRemoteBaseUrl.style.display = useHotmail && hotmailServiceMode === HOTMAIL_SERVICE_MODE_REMOTE ? '' : 'none';
   }
   if (rowHotmailLocalBaseUrl) {
-    rowHotmailLocalBaseUrl.style.display = useHotmail && hotmailServiceMode === HOTMAIL_SERVICE_MODE_LOCAL ? '' : 'none';
+    rowHotmailLocalBaseUrl.style.display = (useHotmail && hotmailServiceMode === HOTMAIL_SERVICE_MODE_LOCAL) || useA4sky ? '' : 'none';
+  }
+  if (inputHotmailEmail?.closest('.data-row')) {
+    inputHotmailEmail.closest('.data-row').style.display = useHotmail ? '' : 'none';
+  }
+  if (inputHotmailClientId?.closest('.data-row')) {
+    inputHotmailClientId.closest('.data-row').style.display = useHotmail ? '' : 'none';
+  }
+  if (inputHotmailPassword?.closest('.data-row')) {
+    inputHotmailPassword.closest('.data-row').style.display = useHotmail ? '' : 'none';
+  }
+  if (inputHotmailRefreshToken?.closest('.data-row')) {
+    inputHotmailRefreshToken.closest('.data-row').style.display = useHotmail ? '' : 'none';
+  }
+  if (btnAddHotmailAccount?.closest('.data-row')) {
+    btnAddHotmailAccount.closest('.data-row').style.display = useHotmail ? '' : 'none';
+  }
+  if (inputHotmailImport?.closest('.data-row')) {
+    inputHotmailImport.closest('.data-row').style.display = useHotmail ? '' : 'none';
   }
   btnFetchEmail.hidden = useHotmail || useLuckmail || useCustomEmail;
   inputEmail.readOnly = useHotmail || useLuckmail;

+ 52 - 0
tests/verification-flow-polling.test.js

@@ -8,6 +8,7 @@ const api = new Function('self', `${source}; return self.MultiPageBackgroundVeri
 
 test('verification flow extends 2925 polling window', () => {
   const helpers = api.createVerificationFlowHelpers({
+    A4SKY_PROVIDER: 'a4sky',
     addLog: async () => {},
     chrome: { tabs: { update: async () => {} } },
     CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
@@ -22,6 +23,7 @@ test('verification flow extends 2925 polling window', () => {
     LUCKMAIL_PROVIDER: 'luckmail-api',
     MAIL_2925_VERIFICATION_INTERVAL_MS: 15000,
     MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15,
+    pollA4skyImapVerificationCode: async () => ({}),
     pollCloudflareTempEmailVerificationCode: async () => ({}),
     pollHotmailVerificationCode: async () => ({}),
     pollLuckmailVerificationCode: async () => ({}),
@@ -43,6 +45,56 @@ test('verification flow extends 2925 polling window', () => {
   assert.equal(step8Payload.intervalMs, 15000);
 });
 
+test('verification flow routes A4Sky provider to IMAP helper polling', async () => {
+  let capturedPayload = null;
+
+  const helpers = api.createVerificationFlowHelpers({
+    A4SKY_PROVIDER: 'a4sky',
+    addLog: async () => {},
+    chrome: { tabs: { update: async () => {} } },
+    CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
+    completeStepFromBackground: async () => {},
+    confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
+    getHotmailVerificationPollConfig: () => ({}),
+    getHotmailVerificationRequestTimestamp: () => 123,
+    getState: async () => ({}),
+    getTabId: async () => 1,
+    HOTMAIL_PROVIDER: 'hotmail-api',
+    isStopError: () => false,
+    LUCKMAIL_PROVIDER: 'luckmail-api',
+    MAIL_2925_VERIFICATION_INTERVAL_MS: 15000,
+    MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15,
+    pollA4skyImapVerificationCode: async (_step, _state, payload) => {
+      capturedPayload = payload;
+      return { code: '123456', emailTimestamp: 1 };
+    },
+    pollCloudflareTempEmailVerificationCode: async () => ({}),
+    pollHotmailVerificationCode: async () => ({}),
+    pollLuckmailVerificationCode: async () => ({}),
+    sendToContentScript: async () => ({}),
+    sendToMailContentScriptResilient: async () => ({}),
+    setState: async () => {},
+    setStepStatus: async () => {},
+    sleepWithStop: async () => {},
+    throwIfStopped: () => {},
+    VERIFICATION_POLL_MAX_ROUNDS: 5,
+  });
+
+  const result = await helpers.pollFreshVerificationCode(4, {
+    email: 'target@a4sky.com',
+    mailProvider: 'a4sky',
+  }, {
+    provider: 'a4sky',
+    label: 'A4Sky',
+  }, {
+    filterAfterTimestamp: 555,
+  });
+
+  assert.equal(result.code, '123456');
+  assert.equal(capturedPayload.filterAfterTimestamp, 555);
+  assert.equal(capturedPayload.targetEmail, 'target@a4sky.com');
+});
+
 test('verification flow runs beforeSubmit hook before filling the code', async () => {
   const events = [];