瀏覽代碼

Add A4Sky IMAP helper integration

chendeben 4 月之前
父節點
當前提交
3997a1b8e8

+ 1 - 0
.gitignore

@@ -8,3 +8,4 @@
 /data/account-run-history.json
 /data/account-run-history.json
 .npm-test.log
 .npm-test.log
 .omx/
 .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 = {}) {
 async function pollHotmailVerificationCodeViaLocalHelper(step, account, pollPayload = {}) {
   const maxAttempts = Number(pollPayload.maxAttempts) || 5;
   const maxAttempts = Number(pollPayload.maxAttempts) || 5;
   const intervalMs = Number(pollPayload.intervalMs) || 3000;
   const intervalMs = Number(pollPayload.intervalMs) || 3000;
@@ -5754,6 +5846,7 @@ const verificationFlowHelpers = self.MultiPageBackgroundVerificationFlow?.create
   LUCKMAIL_PROVIDER,
   LUCKMAIL_PROVIDER,
   MAIL_2925_VERIFICATION_INTERVAL_MS,
   MAIL_2925_VERIFICATION_INTERVAL_MS,
   MAIL_2925_VERIFICATION_MAX_ATTEMPTS,
   MAIL_2925_VERIFICATION_MAX_ATTEMPTS,
+  pollA4skyImapVerificationCode,
   pollCloudflareTempEmailVerificationCode,
   pollCloudflareTempEmailVerificationCode,
   pollHotmailVerificationCode,
   pollHotmailVerificationCode,
   pollLuckmailVerificationCode,
   pollLuckmailVerificationCode,
@@ -5842,6 +5935,7 @@ const step7Executor = self.MultiPageBackgroundStep7?.createStep7Executor({
   throwIfStopped,
   throwIfStopped,
 });
 });
 const step8Executor = self.MultiPageBackgroundStep8?.createStep8Executor({
 const step8Executor = self.MultiPageBackgroundStep8?.createStep8Executor({
+  A4SKY_PROVIDER,
   addLog,
   addLog,
   chrome,
   chrome,
   CLOUDFLARE_TEMP_EMAIL_PROVIDER,
   CLOUDFLARE_TEMP_EMAIL_PROVIDER,

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

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

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

@@ -67,7 +67,7 @@
       }
       }
 
 
       throwIfStopped();
       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} 轮询验证码...`);
         await addLog(`步骤 4:正在通过 ${mail.label} 轮询验证码...`);
       } else {
       } else {
         await addLog(`步骤 4:正在打开${mail.label}...`);
         await addLog(`步骤 4:正在打开${mail.label}...`);

+ 8 - 0
background/verification-flow.js

@@ -18,6 +18,7 @@
       LUCKMAIL_PROVIDER,
       LUCKMAIL_PROVIDER,
       MAIL_2925_VERIFICATION_INTERVAL_MS,
       MAIL_2925_VERIFICATION_INTERVAL_MS,
       MAIL_2925_VERIFICATION_MAX_ATTEMPTS,
       MAIL_2925_VERIFICATION_MAX_ATTEMPTS,
+      pollA4skyImapVerificationCode,
       pollCloudflareTempEmailVerificationCode,
       pollCloudflareTempEmailVerificationCode,
       pollHotmailVerificationCode,
       pollHotmailVerificationCode,
       pollLuckmailVerificationCode,
       pollLuckmailVerificationCode,
@@ -390,6 +391,13 @@
         ...cleanPollOverrides
         ...cleanPollOverrides
       } = pollOverrides;
       } = 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) {
       if (mail.provider === HOTMAIL_PROVIDER) {
         const hotmailPollConfig = getHotmailVerificationPollConfig(step);
         const hotmailPollConfig = getHotmailVerificationPollConfig(step);
         const timedPoll = await applyMailPollingTimeBudget(step, {
         const timedPoll = await applyMailPollingTimeBudget(step, {

+ 180 - 9
scripts/hotmail_helper.py

@@ -9,15 +9,18 @@ import time
 import traceback
 import traceback
 from datetime import datetime, timezone
 from datetime import datetime, timezone
 from email.header import decode_header
 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 http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
 from urllib.error import HTTPError, URLError
 from urllib.error import HTTPError, URLError
 from urllib.parse import urlencode
 from urllib.parse import urlencode
 from urllib.request import Request, urlopen
 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"
 LIVE_TOKEN_URL = "https://login.live.com/oauth20_token.srf"
 ENTRA_COMMON_TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
 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"
 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__), ".."))
 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_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")
 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()
 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}")
     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):
 def build_xoauth2(email_addr, access_token):
     return f"user={email_addr}\x01auth=Bearer {access_token}\x01\x01".encode("utf-8")
     return f"user={email_addr}\x01auth=Bearer {access_token}\x01\x01".encode("utf-8")
 
 
 
 
 def open_mailbox(email_addr, access_token):
 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))
     client.authenticate("XOAUTH2", lambda _: build_xoauth2(email_addr, access_token))
     return client
     return client
 
 
@@ -427,6 +470,25 @@ def normalize_message(message_id, raw_bytes, mailbox):
     subject = decode_mime_header(parsed.get("Subject", ""))
     subject = decode_mime_header(parsed.get("Subject", ""))
     body = extract_text_part(parsed)
     body = extract_text_part(parsed)
     timestamp_ms = to_timestamp_ms(parsed.get("Date"))
     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 {
     return {
         "id": str(message_id),
         "id": str(message_id),
         "mailbox": mailbox,
         "mailbox": mailbox,
@@ -437,6 +499,8 @@ def normalize_message(message_id, raw_bytes, mailbox):
                 "name": sender_name.strip(),
                 "name": sender_name.strip(),
             }
             }
         },
         },
+        "toRecipients": recipient_items,
+        "recipientAddresses": recipient_addresses,
         "bodyPreview": body[:500],
         "bodyPreview": body[:500],
         "receivedDateTime": to_iso_string(timestamp_ms),
         "receivedDateTime": to_iso_string(timestamp_ms),
         "receivedTimestamp": 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}
     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):
 def normalize_graph_message(message, mailbox):
     sender = message.get("from", {}) or {}
     sender = message.get("from", {}) or {}
     email_addr = sender.get("emailAddress", {}) if isinstance(sender, dict) else {}
     email_addr = sender.get("emailAddress", {}) if isinstance(sender, dict) else {}
@@ -664,11 +792,13 @@ def extract_code(text):
     return ""
     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()]
     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()]
     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()}
     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):
     def match_message(message, apply_time_filter):
         timestamp = int(message.get("receivedTimestamp") or 0)
         timestamp = int(message.get("receivedTimestamp") or 0)
         if apply_time_filter and filter_after_timestamp and timestamp and timestamp < int(filter_after_timestamp):
         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()
         sender = str(message.get("from", {}).get("emailAddress", {}).get("address", "")).lower()
         subject = str(message.get("subject", ""))
         subject = str(message.get("subject", ""))
         preview = str(message.get("bodyPreview", ""))
         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]))
         code = extract_code(" ".join([subject, preview, sender]))
         if not code or code in excluded:
         if not code or code in excluded:
             return None
             return None
@@ -740,15 +879,46 @@ class HotmailHelperHandler(BaseHTTPRequestHandler):
                 })
                 })
                 return
                 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()
             email_addr = str(payload.get("email") or "").strip()
             client_id = str(payload.get("clientId") or "").strip()
             client_id = str(payload.get("clientId") or "").strip()
             refresh_token = str(payload.get("refreshToken") or "").strip()
             refresh_token = str(payload.get("refreshToken") or "").strip()
             if not email_addr or not client_id or not refresh_token:
             if not email_addr or not client_id or not refresh_token:
                 raise RuntimeError("Missing email/clientId/refreshToken")
                 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":
             if self.path == "/messages":
                 result = collect_messages(email_addr, client_id, refresh_token, mailboxes, top)
                 result = collect_messages(email_addr, client_id, refresh_token, mailboxes, top)
                 json_response(self, 200, {
                 json_response(self, 200, {
@@ -769,6 +939,7 @@ class HotmailHelperHandler(BaseHTTPRequestHandler):
                     payload.get("subjectFilters") or [],
                     payload.get("subjectFilters") or [],
                     payload.get("excludeCodes") or [],
                     payload.get("excludeCodes") or [],
                     int(payload.get("filterAfterTimestamp") or 0),
                     int(payload.get("filterAfterTimestamp") or 0),
+                    payload.get("targetEmail") or "",
                 )
                 )
                 json_response(self, 200, {
                 json_response(self, 200, {
                     "ok": True,
                     "ok": True,

+ 20 - 2
sidepanel/sidepanel.js

@@ -2366,7 +2366,7 @@ function updateMailProviderUI() {
   }
   }
 
 
   if (hotmailSection) {
   if (hotmailSection) {
-    hotmailSection.style.display = useHotmail ? '' : 'none';
+    hotmailSection.style.display = useHotmail || useA4sky ? '' : 'none';
   }
   }
   if (luckmailSection) {
   if (luckmailSection) {
     luckmailSection.style.display = useLuckmail ? '' : 'none';
     luckmailSection.style.display = useLuckmail ? '' : 'none';
@@ -2387,7 +2387,25 @@ function updateMailProviderUI() {
     rowHotmailRemoteBaseUrl.style.display = useHotmail && hotmailServiceMode === HOTMAIL_SERVICE_MODE_REMOTE ? '' : 'none';
     rowHotmailRemoteBaseUrl.style.display = useHotmail && hotmailServiceMode === HOTMAIL_SERVICE_MODE_REMOTE ? '' : 'none';
   }
   }
   if (rowHotmailLocalBaseUrl) {
   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;
   btnFetchEmail.hidden = useHotmail || useLuckmail || useCustomEmail;
   inputEmail.readOnly = useHotmail || useLuckmail;
   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', () => {
 test('verification flow extends 2925 polling window', () => {
   const helpers = api.createVerificationFlowHelpers({
   const helpers = api.createVerificationFlowHelpers({
+    A4SKY_PROVIDER: 'a4sky',
     addLog: async () => {},
     addLog: async () => {},
     chrome: { tabs: { update: async () => {} } },
     chrome: { tabs: { update: async () => {} } },
     CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
     CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
@@ -22,6 +23,7 @@ test('verification flow extends 2925 polling window', () => {
     LUCKMAIL_PROVIDER: 'luckmail-api',
     LUCKMAIL_PROVIDER: 'luckmail-api',
     MAIL_2925_VERIFICATION_INTERVAL_MS: 15000,
     MAIL_2925_VERIFICATION_INTERVAL_MS: 15000,
     MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15,
     MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15,
+    pollA4skyImapVerificationCode: async () => ({}),
     pollCloudflareTempEmailVerificationCode: async () => ({}),
     pollCloudflareTempEmailVerificationCode: async () => ({}),
     pollHotmailVerificationCode: async () => ({}),
     pollHotmailVerificationCode: async () => ({}),
     pollLuckmailVerificationCode: async () => ({}),
     pollLuckmailVerificationCode: async () => ({}),
@@ -43,6 +45,56 @@ test('verification flow extends 2925 polling window', () => {
   assert.equal(step8Payload.intervalMs, 15000);
   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 () => {
 test('verification flow runs beforeSubmit hook before filling the code', async () => {
   const events = [];
   const events = [];