Ver código fonte

merge: 合并最新 dev 分支(cloudflare-temp-email + iCloud)

- 解决 background.js / sidepanel.html / sidepanel.js / 测试文件共 24 处冲突
- LuckMail、Cloudflare Temp Email、iCloud 三种邮箱提供商正确共存
- step9 清理范围测试对齐 dev 分支行为(closeTabsByUrlPrefix 排除 OAuth callback URL)
- LuckMail 测试补充 dev 分支新增依赖的 mock
- 全部 76 个测试通过
笨笨 4 meses atrás
pai
commit
8571f59e99

Diferenças do arquivo suprimidas por serem muito extensas
+ 842 - 9
background.js


+ 400 - 0
cloudflare-temp-email-utils.js

@@ -0,0 +1,400 @@
+(function cloudflareTempEmailUtilsModule(root, factory) {
+  if (typeof module !== 'undefined' && module.exports) {
+    module.exports = factory();
+    return;
+  }
+
+  root.CloudflareTempEmailUtils = factory();
+})(typeof self !== 'undefined' ? self : globalThis, function createCloudflareTempEmailUtils() {
+  const DEFAULT_MAIL_PAGE_SIZE = 20;
+
+  function firstNonEmptyString(values) {
+    for (const value of values) {
+      if (value === undefined || value === null) continue;
+      const normalized = String(value).trim();
+      if (normalized) return normalized;
+    }
+    return '';
+  }
+
+  function normalizeCloudflareTempEmailBaseUrl(rawValue = '') {
+    const value = String(rawValue || '').trim();
+    if (!value) return '';
+
+    const candidate = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(value) ? value : `https://${value}`;
+    try {
+      const parsed = new URL(candidate);
+      parsed.hash = '';
+      parsed.search = '';
+      const pathname = parsed.pathname === '/' ? '' : parsed.pathname.replace(/\/+$/, '');
+      return `${parsed.origin}${pathname}`;
+    } catch {
+      return '';
+    }
+  }
+
+  function normalizeCloudflareTempEmailDomain(rawValue = '') {
+    let value = String(rawValue || '').trim().toLowerCase();
+    if (!value) return '';
+    value = value.replace(/^@+/, '');
+    value = value.replace(/^https?:\/\//, '');
+    value = value.replace(/\/.*$/, '');
+    if (!/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(value)) {
+      return '';
+    }
+    return value;
+  }
+
+  function normalizeCloudflareTempEmailDomains(values) {
+    const domains = [];
+    const seen = new Set();
+    for (const value of Array.isArray(values) ? values : []) {
+      const normalized = normalizeCloudflareTempEmailDomain(value);
+      if (!normalized || seen.has(normalized)) continue;
+      seen.add(normalized);
+      domains.push(normalized);
+    }
+    return domains;
+  }
+
+  function buildCloudflareTempEmailHeaders(config = {}, options = {}) {
+    const headers = {};
+    const adminAuth = firstNonEmptyString([config.adminAuth, config.cloudflareTempEmailAdminAuth]);
+    const customAuth = firstNonEmptyString([config.customAuth, config.cloudflareTempEmailCustomAuth]);
+    if (adminAuth) {
+      headers['x-admin-auth'] = adminAuth;
+    }
+    if (customAuth) {
+      headers['x-custom-auth'] = customAuth;
+    }
+    if (options.json) {
+      headers['Content-Type'] = 'application/json';
+    }
+    if (options.acceptJson !== false) {
+      headers.Accept = 'application/json';
+    }
+    return headers;
+  }
+
+  function joinCloudflareTempEmailUrl(baseUrl, path) {
+    const normalizedBase = normalizeCloudflareTempEmailBaseUrl(baseUrl);
+    const normalizedPath = String(path || '').trim();
+    if (!normalizedBase || !normalizedPath) return normalizedBase || '';
+    return `${normalizedBase}${normalizedPath.startsWith('/') ? '' : '/'}${normalizedPath}`;
+  }
+
+  function getCloudflareTempEmailMailRows(payload) {
+    if (Array.isArray(payload)) return payload;
+    if (!payload || typeof payload !== 'object') return [];
+
+    const candidates = [
+      payload.data,
+      payload.items,
+      payload.messages,
+      payload.mails,
+      payload.results,
+      payload.rows,
+    ];
+
+    for (const candidate of candidates) {
+      if (Array.isArray(candidate)) {
+        return candidate;
+      }
+    }
+
+    return [];
+  }
+
+  function normalizeCloudflareTempEmailAddress(value) {
+    return String(value || '').trim().toLowerCase();
+  }
+
+  function splitRawMessage(raw = '') {
+    const source = String(raw || '');
+    if (!source) {
+      return { headerText: '', bodyText: '' };
+    }
+
+    const normalized = source.replace(/\r\n/g, '\n');
+    const separatorIndex = normalized.indexOf('\n\n');
+    if (separatorIndex === -1) {
+      return { headerText: normalized, bodyText: '' };
+    }
+
+    return {
+      headerText: normalized.slice(0, separatorIndex),
+      bodyText: normalized.slice(separatorIndex + 2),
+    };
+  }
+
+  function parseRawHeaders(headerText = '') {
+    const headers = {};
+    const lines = String(headerText || '').split('\n');
+    let currentName = '';
+
+    for (const line of lines) {
+      if (!line) continue;
+      if ((line.startsWith(' ') || line.startsWith('\t')) && currentName) {
+        headers[currentName] += ` ${line.trim()}`;
+        continue;
+      }
+
+      const separatorIndex = line.indexOf(':');
+      if (separatorIndex <= 0) continue;
+      currentName = line.slice(0, separatorIndex).trim().toLowerCase();
+      headers[currentName] = line.slice(separatorIndex + 1).trim();
+    }
+
+    return headers;
+  }
+
+  function decodeMimeEncodedWords(value = '') {
+    const source = String(value || '');
+    return source.replace(/=\?([^?]+)\?([bBqQ])\?([^?]+)\?=/g, (_match, charset, encoding, encodedText) => {
+      try {
+        if (String(encoding).toUpperCase() === 'B') {
+          return decodeBytesToString(base64ToBytes(encodedText), charset);
+        }
+        return decodeBytesToString(
+          quotedPrintableToBytes(String(encodedText).replace(/_/g, ' '), { headerMode: true }),
+          charset
+        );
+      } catch {
+        return encodedText;
+      }
+    });
+  }
+
+  function base64ToBytes(value = '') {
+    const normalized = String(value || '').replace(/\s+/g, '');
+    if (!normalized) return new Uint8Array();
+
+    if (typeof atob === 'function') {
+      const decoded = atob(normalized);
+      const bytes = new Uint8Array(decoded.length);
+      for (let i = 0; i < decoded.length; i += 1) {
+        bytes[i] = decoded.charCodeAt(i);
+      }
+      return bytes;
+    }
+
+    if (typeof Buffer !== 'undefined') {
+      return Uint8Array.from(Buffer.from(normalized, 'base64'));
+    }
+
+    throw new Error('No base64 decoder available');
+  }
+
+  function quotedPrintableToBytes(value = '', options = {}) {
+    const { headerMode = false } = options;
+    const source = String(value || '')
+      .replace(/=\r?\n/g, '')
+      .replace(headerMode ? /_/g : /$^/, ' ');
+
+    const bytes = [];
+    for (let index = 0; index < source.length; index += 1) {
+      const char = source[index];
+      if (char === '=' && /^[0-9A-Fa-f]{2}$/.test(source.slice(index + 1, index + 3))) {
+        bytes.push(parseInt(source.slice(index + 1, index + 3), 16));
+        index += 2;
+        continue;
+      }
+      bytes.push(char.charCodeAt(0));
+    }
+    return Uint8Array.from(bytes);
+  }
+
+  function decodeBytesToString(bytes, charset = 'utf-8') {
+    const normalizedCharset = String(charset || 'utf-8').trim().toLowerCase();
+    const candidates = [normalizedCharset];
+    if (normalizedCharset === 'utf8') {
+      candidates.unshift('utf-8');
+    }
+    if (normalizedCharset === 'gb2312' || normalizedCharset === 'gbk') {
+      candidates.unshift('gb18030');
+    }
+
+    for (const candidate of candidates) {
+      try {
+        if (typeof TextDecoder !== 'undefined') {
+          return new TextDecoder(candidate, { fatal: false }).decode(bytes);
+        }
+      } catch {
+        // ignore and try fallback
+      }
+    }
+
+    if (typeof Buffer !== 'undefined') {
+      return Buffer.from(bytes).toString('utf8');
+    }
+
+    let result = '';
+    for (const byte of bytes) {
+      result += String.fromCharCode(byte);
+    }
+    return result;
+  }
+
+  function getCharsetFromContentType(contentType = '') {
+    const match = String(contentType || '').match(/charset="?([^";]+)"?/i);
+    return match ? match[1].trim() : 'utf-8';
+  }
+
+  function getBoundaryFromContentType(contentType = '') {
+    const match = String(contentType || '').match(/boundary="?([^";]+)"?/i);
+    return match ? match[1] : '';
+  }
+
+  function stripHtmlTags(value = '') {
+    return String(value || '')
+      .replace(/<style[\s\S]*?<\/style>/gi, ' ')
+      .replace(/<script[\s\S]*?<\/script>/gi, ' ')
+      .replace(/<[^>]+>/g, ' ')
+      .replace(/&nbsp;/gi, ' ')
+      .replace(/&amp;/gi, '&')
+      .replace(/&lt;/gi, '<')
+      .replace(/&gt;/gi, '>')
+      .replace(/\s+/g, ' ')
+      .trim();
+  }
+
+  function decodeMimeBody(bodyText = '', headers = {}) {
+    const contentType = String(headers['content-type'] || '');
+    const transferEncoding = String(headers['content-transfer-encoding'] || '').trim().toLowerCase();
+    const charset = getCharsetFromContentType(contentType);
+    let decoded = String(bodyText || '');
+
+    if (transferEncoding === 'base64') {
+      decoded = decodeBytesToString(base64ToBytes(decoded), charset);
+    } else if (transferEncoding === 'quoted-printable') {
+      decoded = decodeBytesToString(quotedPrintableToBytes(decoded), charset);
+    }
+
+    if (/text\/html/i.test(contentType)) {
+      return stripHtmlTags(decoded);
+    }
+    return decoded.replace(/\s+/g, ' ').trim();
+  }
+
+  function extractTextFromMime(rawMessage = '', depth = 0) {
+    const { headerText, bodyText } = splitRawMessage(rawMessage);
+    const headers = parseRawHeaders(headerText);
+    const contentType = String(headers['content-type'] || '');
+    const boundary = getBoundaryFromContentType(contentType);
+
+    if (/multipart\//i.test(contentType) && boundary && depth < 6) {
+      const marker = `--${boundary}`;
+      const sections = String(bodyText || '')
+        .split(marker)
+        .map((part) => part.trim())
+        .filter((part) => part && part !== '--');
+
+      const extractedParts = sections
+        .map((part) => part.replace(/--\s*$/, '').trim())
+        .map((part) => extractTextFromMime(part, depth + 1)?.text || '')
+        .filter(Boolean);
+
+      const plainText = extractedParts.join(' ').replace(/\s+/g, ' ').trim();
+      return {
+        headers,
+        text: plainText,
+      };
+    }
+
+    return {
+      headers,
+      text: decodeMimeBody(bodyText, headers),
+    };
+  }
+
+  function normalizeReceivedDateTime(value) {
+    if (!value && value !== 0) return '';
+    if (typeof value === 'number' && Number.isFinite(value)) {
+      return new Date(value).toISOString();
+    }
+    const source = String(value || '').trim();
+    if (!source) return '';
+    const parsed = Date.parse(source);
+    return Number.isFinite(parsed) ? new Date(parsed).toISOString() : source;
+  }
+
+  function normalizeCloudflareTempEmailMessage(row = {}) {
+    if (!row || typeof row !== 'object') return null;
+
+    const address = normalizeCloudflareTempEmailAddress(firstNonEmptyString([
+      row.address,
+      row.mail_address,
+      row.email,
+      row.recipient,
+    ]));
+    const raw = firstNonEmptyString([row.raw, row.source, row.mime, row.message]);
+    const parsedMime = raw ? extractTextFromMime(raw) : { headers: {}, text: '' };
+    const subject = decodeMimeEncodedWords(firstNonEmptyString([
+      row.subject,
+      parsedMime.headers.subject,
+    ]));
+    const fromAddress = decodeMimeEncodedWords(firstNonEmptyString([
+      row.from,
+      row.sender,
+      row.mail_from,
+      parsedMime.headers.from,
+    ]));
+    const bodyPreview = firstNonEmptyString([
+      row.text,
+      row.preview,
+      row.body,
+      parsedMime.text,
+      raw,
+    ]).replace(/\s+/g, ' ').trim();
+
+    return {
+      id: firstNonEmptyString([row.id, row.mail_id]),
+      address,
+      addressId: firstNonEmptyString([row.address_id, row.addressId]),
+      subject,
+      from: {
+        emailAddress: {
+          address: fromAddress,
+        },
+      },
+      bodyPreview,
+      raw,
+      receivedDateTime: normalizeReceivedDateTime(firstNonEmptyString([
+        row.receivedDateTime,
+        row.received_at,
+        row.created_at,
+        row.createdAt,
+        row.updated_at,
+        row.date,
+      ])),
+    };
+  }
+
+  function normalizeCloudflareTempEmailMailApiMessages(payload) {
+    return getCloudflareTempEmailMailRows(payload)
+      .map((row) => normalizeCloudflareTempEmailMessage(row))
+      .filter(Boolean);
+  }
+
+  function getCloudflareTempEmailAddressFromResponse(payload = {}) {
+    return firstNonEmptyString([
+      payload.address,
+      payload.email,
+      payload?.data?.address,
+      payload?.data?.email,
+    ]);
+  }
+
+  return {
+    DEFAULT_MAIL_PAGE_SIZE,
+    buildCloudflareTempEmailHeaders,
+    getCloudflareTempEmailAddressFromResponse,
+    joinCloudflareTempEmailUrl,
+    normalizeCloudflareTempEmailAddress,
+    normalizeCloudflareTempEmailBaseUrl,
+    normalizeCloudflareTempEmailDomain,
+    normalizeCloudflareTempEmailDomains,
+    normalizeCloudflareTempEmailMailApiMessages,
+    normalizeCloudflareTempEmailMessage,
+  };
+});

+ 177 - 0
icloud-utils.js

@@ -0,0 +1,177 @@
+(function icloudUtilsModule(root, factory) {
+  if (typeof module !== 'undefined' && module.exports) {
+    module.exports = factory();
+    return;
+  }
+
+  root.IcloudUtils = factory();
+})(typeof self !== 'undefined' ? self : globalThis, function createIcloudUtils() {
+  function normalizeIcloudHost(rawHost) {
+    const host = String(rawHost || '').trim().toLowerCase();
+    if (!host) return '';
+    if (host === 'icloud.com' || host === 'www.icloud.com' || host === 'setup.icloud.com') return 'icloud.com';
+    if (host === 'icloud.com.cn' || host === 'www.icloud.com.cn' || host === 'setup.icloud.com.cn') return 'icloud.com.cn';
+    return '';
+  }
+
+  function getConfiguredIcloudHostPreference(stateOrValue = '') {
+    const preference = typeof stateOrValue === 'object'
+      ? String(stateOrValue?.icloudHostPreference || '').trim().toLowerCase()
+      : String(stateOrValue || '').trim().toLowerCase();
+    if (!preference || preference === 'auto') return '';
+    return normalizeIcloudHost(preference);
+  }
+
+  function getIcloudLoginUrlForHost(host) {
+    const normalizedHost = normalizeIcloudHost(host);
+    if (normalizedHost === 'icloud.com') return 'https://www.icloud.com/';
+    if (normalizedHost === 'icloud.com.cn') return 'https://www.icloud.com.cn/';
+    return '';
+  }
+
+  function getIcloudSetupUrlForHost(host) {
+    const normalizedHost = normalizeIcloudHost(host);
+    if (normalizedHost === 'icloud.com') return 'https://setup.icloud.com/setup/ws/1';
+    if (normalizedHost === 'icloud.com.cn') return 'https://setup.icloud.com.cn/setup/ws/1';
+    return '';
+  }
+
+  function getIcloudHostHintFromMessage(message) {
+    const lower = String(message || '').toLowerCase();
+    if (lower.includes('setup.icloud.com.cn') || lower.includes('www.icloud.com.cn') || lower.includes('icloud.com.cn')) {
+      return 'icloud.com.cn';
+    }
+    if (lower.includes('setup.icloud.com') || lower.includes('www.icloud.com') || lower.includes('icloud.com')) {
+      return 'icloud.com';
+    }
+    return '';
+  }
+
+  function normalizeBooleanMap(rawValue = {}) {
+    if (!rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) {
+      return {};
+    }
+
+    return Object.entries(rawValue).reduce((result, [key, value]) => {
+      const normalizedKey = String(key || '').trim().toLowerCase();
+      if (!normalizedKey) {
+        return result;
+      }
+      result[normalizedKey] = Boolean(value);
+      return result;
+    }, {});
+  }
+
+  function toNormalizedEmailSet(values = []) {
+    if (values instanceof Set) {
+      return new Set(Array.from(values, (item) => String(item || '').trim().toLowerCase()).filter(Boolean));
+    }
+    if (Array.isArray(values)) {
+      return new Set(values.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean));
+    }
+    if (values && typeof values === 'object') {
+      const normalizedMap = normalizeBooleanMap(values);
+      return new Set(Object.entries(normalizedMap)
+        .filter(([, value]) => value)
+        .map(([email]) => email));
+    }
+    return new Set();
+  }
+
+  function findIcloudAliasArray(node, depth = 0) {
+    if (!node || depth > 4) return null;
+    if (Array.isArray(node)) {
+      return node.some((item) => item && typeof item === 'object') ? node : null;
+    }
+    if (typeof node !== 'object') return null;
+
+    const priorityKeys = ['hmeEmails', 'hmeEmailList', 'hmeList', 'hmes', 'aliases', 'items'];
+    for (const key of priorityKeys) {
+      if (Array.isArray(node[key])) return node[key];
+    }
+
+    for (const value of Object.values(node)) {
+      const nested = findIcloudAliasArray(value, depth + 1);
+      if (nested) return nested;
+    }
+
+    return null;
+  }
+
+  function normalizeIcloudAliasRecord(raw, options = {}) {
+    const usedEmails = toNormalizedEmailSet(options.usedEmails);
+    const preservedEmails = toNormalizedEmailSet(options.preservedEmails);
+    const anonymousId = String(raw?.anonymousId || raw?.id || '').trim();
+    const email = String(
+      raw?.hme
+        || raw?.email
+        || raw?.alias
+        || raw?.address
+        || raw?.metaData?.hme
+        || ''
+    ).trim().toLowerCase();
+
+    if (!email || !email.includes('@')) return null;
+
+    const label = String(raw?.label || raw?.metaData?.label || '').trim();
+    const note = String(raw?.note || raw?.metaData?.note || '').trim();
+    const state = String(raw?.state || raw?.status || '').trim().toLowerCase();
+    const createdAt = raw?.createTimestamp
+      || raw?.createTime
+      || raw?.createdAt
+      || raw?.createdDate
+      || null;
+
+    return {
+      anonymousId,
+      email,
+      label,
+      note,
+      state,
+      active: raw?.active !== false && raw?.isActive !== false && state !== 'inactive' && state !== 'deleted',
+      used: usedEmails.has(email),
+      preserved: preservedEmails.has(email),
+      createdAt,
+    };
+  }
+
+  function normalizeIcloudAliasList(response, options = {}) {
+    const aliases = findIcloudAliasArray(response);
+    if (!aliases) return [];
+
+    return aliases
+      .map((alias) => normalizeIcloudAliasRecord(alias, options))
+      .filter(Boolean)
+      .sort((left, right) => {
+        if (left.active !== right.active) return left.active ? -1 : 1;
+        if (left.used !== right.used) return left.used ? 1 : -1;
+        return String(left.email).localeCompare(String(right.email));
+      });
+  }
+
+  function pickReusableIcloudAlias(aliases = []) {
+    return (Array.isArray(aliases) ? aliases : []).find((alias) => alias?.active && !alias?.used) || null;
+  }
+
+  function findIcloudAliasByEmail(aliases = [], email = '') {
+    const normalizedEmail = String(email || '').trim().toLowerCase();
+    if (!normalizedEmail) return null;
+    return (Array.isArray(aliases) ? aliases : [])
+      .find((alias) => String(alias?.email || '').trim().toLowerCase() === normalizedEmail) || null;
+  }
+
+  return {
+    findIcloudAliasArray,
+    findIcloudAliasByEmail,
+    getConfiguredIcloudHostPreference,
+    getIcloudHostHintFromMessage,
+    getIcloudLoginUrlForHost,
+    getIcloudSetupUrlForHost,
+    normalizeBooleanMap,
+    normalizeIcloudAliasList,
+    normalizeIcloudAliasRecord,
+    normalizeIcloudHost,
+    pickReusableIcloudAlias,
+    toNormalizedEmailSet,
+  };
+});

+ 12 - 0
manifest.json

@@ -8,17 +8,29 @@
     "alarms",
     "tabs",
     "webNavigation",
+    "declarativeNetRequest",
     "debugger",
     "storage",
     "scripting",
     "activeTab"
   ],
   "host_permissions": [
+    "https://*.icloud.com/*",
+    "https://*.icloud.com.cn/*",
     "<all_urls>"
   ],
   "background": {
     "service_worker": "background.js"
   },
+  "declarative_net_request": {
+    "rule_resources": [
+      {
+        "id": "icloud_headers",
+        "enabled": true,
+        "path": "rules.json"
+      }
+    ]
+  },
   "side_panel": {
     "default_path": "sidepanel/sidepanel.html"
   },

+ 50 - 0
rules.json

@@ -0,0 +1,50 @@
+[
+  {
+    "id": 1,
+    "priority": 1,
+    "action": {
+      "type": "modifyHeaders",
+      "requestHeaders": [
+        {
+          "header": "Origin",
+          "operation": "set",
+          "value": "https://www.icloud.com"
+        },
+        {
+          "header": "Referer",
+          "operation": "set",
+          "value": "https://www.icloud.com/"
+        }
+      ]
+    },
+    "condition": {
+      "urlFilter": "|https://*.icloud.com/*",
+      "resourceTypes": ["xmlhttprequest"],
+      "excludedInitiatorDomains": ["apple.com", "icloud.com"]
+    }
+  },
+  {
+    "id": 2,
+    "priority": 1,
+    "action": {
+      "type": "modifyHeaders",
+      "requestHeaders": [
+        {
+          "header": "Origin",
+          "operation": "set",
+          "value": "https://www.icloud.com.cn"
+        },
+        {
+          "header": "Referer",
+          "operation": "set",
+          "value": "https://www.icloud.com.cn/"
+        }
+      ]
+    },
+    "condition": {
+      "urlFilter": "|https://*.icloud.com.cn/*",
+      "resourceTypes": ["xmlhttprequest"],
+      "excludedInitiatorDomains": ["apple.com.cn", "icloud.com.cn"]
+    }
+  }
+]

+ 157 - 0
sidepanel/sidepanel.css

@@ -1035,6 +1035,163 @@ header {
   text-align: center;
 }
 
+.icloud-card {
+  border: 1px solid var(--border-subtle);
+  border-radius: var(--radius-sm);
+  background: color-mix(in srgb, var(--bg-base) 84%, transparent);
+  padding: 10px;
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.icloud-summary {
+  color: var(--text-secondary);
+  font-size: 12px;
+  line-height: 1.6;
+}
+
+.icloud-login-help {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 10px;
+  border: 1px solid color-mix(in srgb, var(--orange) 26%, var(--border));
+  background: color-mix(in srgb, var(--orange-soft) 62%, var(--bg-base));
+  border-radius: var(--radius-sm);
+  padding: 10px;
+}
+
+.icloud-login-help-main {
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.icloud-login-help-title {
+  color: var(--text-primary);
+  font-weight: 700;
+  font-size: 13px;
+}
+
+.icloud-login-help-text {
+  color: var(--text-secondary);
+  font-size: 12px;
+  line-height: 1.5;
+}
+
+.icloud-toolbar {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+}
+
+.icloud-search {
+  flex: 1;
+}
+
+.icloud-filter {
+  flex: 0 0 130px;
+}
+
+.icloud-bulkbar {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+  justify-content: space-between;
+  flex-wrap: wrap;
+}
+
+.icloud-select-all {
+  flex: 0 0 auto;
+}
+
+.icloud-bulk-actions {
+  display: flex;
+  gap: 6px;
+  flex-wrap: wrap;
+}
+
+.icloud-list {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  max-height: 360px;
+  overflow: auto;
+  padding-right: 4px;
+}
+
+.icloud-item {
+  border: 1px solid var(--border);
+  background: var(--bg-base);
+  border-radius: var(--radius-sm);
+  padding: 10px;
+  display: grid;
+  grid-template-columns: auto minmax(0, 1fr) auto;
+  gap: 10px;
+  align-items: flex-start;
+}
+
+.icloud-item-check {
+  margin-top: 4px;
+}
+
+.icloud-item-main {
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.icloud-item-actions {
+  display: flex;
+  gap: 6px;
+  flex-wrap: wrap;
+  justify-content: flex-end;
+}
+
+.icloud-item-email {
+  font-weight: 600;
+  color: var(--text-primary);
+  word-break: break-all;
+}
+
+.icloud-item-meta {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+}
+
+.icloud-tag {
+  display: inline-flex;
+  align-items: center;
+  padding: 2px 8px;
+  border-radius: 999px;
+  font-size: 11px;
+  background: var(--bg-surface);
+  color: var(--text-secondary);
+}
+
+.icloud-tag.used {
+  background: var(--orange-soft);
+  color: var(--orange);
+}
+
+.icloud-tag.active {
+  background: var(--green-soft);
+  color: var(--green);
+}
+
+.icloud-empty {
+  color: var(--text-muted);
+  font-size: 12px;
+  border: 1px dashed var(--border);
+  border-radius: var(--radius-sm);
+  padding: 12px;
+  text-align: center;
+}
+
 .data-select {
   flex: 1;
   padding: 7px 10px;

+ 84 - 0
sidepanel/sidepanel.html

@@ -160,6 +160,7 @@
             <option value="qq">QQ 邮箱 (wx.mail.qq.com)</option>
             <option value="inbucket">Inbucket(自定义主机)</option>
             <option value="2925">2925 邮箱 (2925.com)</option>
+            <option value="cloudflare-temp-email">Cloudflare Temp Email</option>
           </select>
           <button id="btn-mail-login" class="btn btn-outline btn-sm data-inline-btn" type="button" disabled>登录</button>
         </div>
@@ -169,8 +170,31 @@
         <select id="select-email-generator" class="data-select">
           <option value="duck">DuckDuckGo</option>
           <option value="cloudflare">Cloudflare</option>
+          <option value="icloud">iCloud 隐私邮箱</option>
+          <option value="cloudflare-temp-email">Cloudflare Temp Email</option>
         </select>
       </div>
+      <div class="data-row" id="row-temp-email-base-url" style="display:none;">
+        <span class="data-label">Temp API</span>
+        <input type="text" id="input-temp-email-base-url" class="data-input" placeholder="https://your-worker-domain" />
+      </div>
+      <div class="data-row" id="row-temp-email-admin-auth" style="display:none;">
+        <span class="data-label">Admin Auth</span>
+        <input type="password" id="input-temp-email-admin-auth" class="data-input data-input-with-icon" placeholder="Cloudflare Temp Email admin password" />
+      </div>
+      <div class="data-row" id="row-temp-email-custom-auth" style="display:none;">
+        <span class="data-label">Custom Auth</span>
+        <input type="password" id="input-temp-email-custom-auth" class="data-input data-input-with-icon" placeholder="仅当站点启用了访问密码时再填写;这是额外鉴权,不替代 Admin Auth。" />
+      </div>
+      <div class="data-row" id="row-temp-email-domain" style="display:none;">
+        <span class="data-label">Temp 域名</span>
+        <div class="data-inline">
+          <select id="select-temp-email-domain" class="data-select"></select>
+          <input type="text" id="input-temp-email-domain" class="data-input" placeholder="例如 mail.example.com"
+            style="display:none;" />
+          <button id="btn-temp-email-domain-mode" class="btn btn-outline btn-sm" type="button">添加</button>
+        </div>
+      </div>
       <div class="data-row" id="row-cf-domain" style="display:none;">
         <span class="data-label">CF 域名</span>
         <div class="data-inline">
@@ -388,6 +412,66 @@
         <div id="luckmail-list" class="luckmail-list"></div>
       </div>
     </div>
+    <div id="icloud-section" class="data-card hotmail-card" style="display:none;">
+      <div class="section-mini-header">
+        <div class="section-mini-copy">
+          <span class="section-label">iCloud 隐私邮箱</span>
+        </div>
+        <div class="section-mini-actions">
+          <button id="btn-icloud-refresh" class="btn btn-ghost btn-xs" type="button">刷新</button>
+          <button id="btn-icloud-delete-used" class="btn btn-ghost btn-xs" type="button">删除已用</button>
+        </div>
+      </div>
+      <div class="data-row">
+        <span class="data-label">iCloud</span>
+        <select id="select-icloud-host-preference" class="data-select">
+          <option value="auto">自动</option>
+          <option value="icloud.com">iCloud.com</option>
+          <option value="icloud.com.cn">iCloud.com.cn</option>
+        </select>
+      </div>
+      <div class="data-row">
+        <span class="data-label">自动删除</span>
+        <label class="option-toggle" for="checkbox-auto-delete-icloud">
+          <input type="checkbox" id="checkbox-auto-delete-icloud" />
+          <span>成功使用后自动删除 iCloud 别名</span>
+        </label>
+      </div>
+      <div class="icloud-card">
+        <div id="icloud-summary" class="icloud-summary">加载你的 iCloud Hide My Email 别名以便在这里管理。</div>
+        <div id="icloud-login-help" class="icloud-login-help" style="display:none;">
+          <div class="icloud-login-help-main">
+            <div id="icloud-login-help-title" class="icloud-login-help-title">需要登录 iCloud</div>
+            <div id="icloud-login-help-text" class="icloud-login-help-text">我已经为你打开 iCloud 登录页。请在那个页面完成登录,然后回到这里点击“我已登录”。</div>
+          </div>
+          <button id="btn-icloud-login-done" class="btn btn-primary btn-xs" type="button">我已登录</button>
+        </div>
+        <div class="icloud-toolbar">
+          <input id="input-icloud-search" class="data-input icloud-search" type="text" placeholder="搜索邮箱 / 标签 / 备注" />
+          <select id="select-icloud-filter" class="data-select icloud-filter">
+            <option value="all">全部</option>
+            <option value="active">可用</option>
+            <option value="used">已用</option>
+            <option value="unused">未用</option>
+            <option value="preserved">保留</option>
+          </select>
+        </div>
+        <div class="icloud-bulkbar">
+          <label class="option-toggle icloud-select-all" for="checkbox-icloud-select-all">
+            <input type="checkbox" id="checkbox-icloud-select-all" />
+            <span id="icloud-selection-summary">已选 0 个</span>
+          </label>
+          <div class="icloud-bulk-actions">
+            <button id="btn-icloud-bulk-used" class="btn btn-outline btn-xs" type="button">标记已用</button>
+            <button id="btn-icloud-bulk-unused" class="btn btn-outline btn-xs" type="button">标记未用</button>
+            <button id="btn-icloud-bulk-preserve" class="btn btn-outline btn-xs" type="button">保留</button>
+            <button id="btn-icloud-bulk-unpreserve" class="btn btn-outline btn-xs" type="button">取消保留</button>
+            <button id="btn-icloud-bulk-delete" class="btn btn-outline btn-xs" type="button">删除</button>
+          </div>
+        </div>
+        <div id="icloud-list" class="icloud-list"></div>
+      </div>
+    </div>
     <div id="status-bar" class="status-bar">
       <div class="status-dot"></div>
       <span id="display-status">就绪</span>

Diferenças do arquivo suprimidas por serem muito extensas
+ 783 - 7
sidepanel/sidepanel.js


+ 23 - 0
tests/auto-run-fresh-attempt-reset.test.js

@@ -60,11 +60,20 @@ const bundle = [
   extractFunction('hasSavedProgress'),
   extractFunction('getRunningSteps'),
   extractFunction('getAutoRunStatusPayload'),
+  extractFunction('createAutoRunRoundSummary'),
+  extractFunction('normalizeAutoRunRoundSummary'),
+  extractFunction('buildAutoRunRoundSummaries'),
+  extractFunction('serializeAutoRunRoundSummaries'),
+  extractFunction('getAutoRunRoundRetryCount'),
+  extractFunction('formatAutoRunFailureReasons'),
+  extractFunction('logAutoRunFinalSummary'),
+  extractFunction('waitBetweenAutoRunRounds'),
   extractFunction('autoRunLoop'),
 ].join('\n');
 
 const api = new Function(`
 const STOP_ERROR_MESSAGE = 'Flow stopped.';
+const AUTO_RUN_MAX_RETRIES_PER_ROUND = 3;
 const DEFAULT_STATE = {
   stepStatuses: {
     1: 'pending',
@@ -180,6 +189,20 @@ function cancelPendingCommands() {}
 function normalizeAutoRunFallbackThreadIntervalMinutes(value) {
   return Math.max(0, Math.floor(Number(value) || 0));
 }
+function buildAutoRunRoundSummaries(totalRuns, rawSummaries = []) {
+  return Array.from({ length: totalRuns }, (_, index) => ({
+    round: index + 1,
+    status: rawSummaries[index]?.status || 'pending',
+    attempts: rawSummaries[index]?.attempts || 0,
+    failureReasons: [...(rawSummaries[index]?.failureReasons || [])],
+    finalFailureReason: rawSummaries[index]?.finalFailureReason || '',
+  }));
+}
+function serializeAutoRunRoundSummaries(totalRuns, roundSummaries = []) {
+  return buildAutoRunRoundSummaries(totalRuns, roundSummaries);
+}
+async function logAutoRunFinalSummary() {}
+async function waitBetweenAutoRunRounds() {}
 
 const chrome = {
   runtime: {

+ 265 - 0
tests/background-icloud.test.js

@@ -0,0 +1,265 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('fs');
+
+const source = fs.readFileSync('background.js', 'utf8');
+
+function extractFunction(name) {
+  const markers = [`async function ${name}(`, `function ${name}(`];
+  const start = markers
+    .map((marker) => source.indexOf(marker))
+    .find((index) => index >= 0);
+  if (start < 0) {
+    throw new Error(`missing function ${name}`);
+  }
+
+  let parenDepth = 0;
+  let signatureEnded = false;
+  let braceStart = -1;
+  for (let i = start; i < source.length; i += 1) {
+    const ch = source[i];
+    if (ch === '(') {
+      parenDepth += 1;
+    } else if (ch === ')') {
+      parenDepth -= 1;
+      if (parenDepth === 0) {
+        signatureEnded = true;
+      }
+    } else if (ch === '{' && signatureEnded) {
+      braceStart = i;
+      break;
+    }
+  }
+  if (braceStart < 0) {
+    throw new Error(`missing body for function ${name}`);
+  }
+
+  let depth = 0;
+  let end = braceStart;
+  for (; end < source.length; end += 1) {
+    const ch = source[end];
+    if (ch === '{') depth += 1;
+    if (ch === '}') {
+      depth -= 1;
+      if (depth === 0) {
+        end += 1;
+        break;
+      }
+    }
+  }
+
+  return source.slice(start, end);
+}
+
+const bundle = [
+  extractFunction('normalizeEmailGenerator'),
+  extractFunction('getEmailGeneratorLabel'),
+  extractFunction('normalizePersistentSettingValue'),
+  extractFunction('finalizeIcloudAliasAfterSuccessfulFlow'),
+].join('\n');
+
+function createApi(overrides = {}) {
+  return new Function('overrides', `
+const HOTMAIL_PROVIDER = 'hotmail-api';
+const HOTMAIL_SERVICE_MODE_LOCAL = 'local';
+const CLOUDFLARE_TEMP_EMAIL_GENERATOR = 'cloudflare-temp-email';
+const DEFAULT_LOCAL_CPA_STEP9_MODE = 'submit';
+const DEFAULT_HOTMAIL_REMOTE_BASE_URL = '';
+const DEFAULT_HOTMAIL_LOCAL_BASE_URL = 'http://127.0.0.1:17373';
+const PERSISTED_SETTING_DEFAULTS = {
+  mailProvider: '163',
+  autoStepDelaySeconds: null,
+};
+
+const calls = {
+  setUsed: [],
+  logs: [],
+  deletes: [],
+  listCalls: 0,
+};
+
+function normalizeIcloudHost(value) {
+  const normalized = String(value || '').trim().toLowerCase();
+  return ['icloud.com', 'icloud.com.cn'].includes(normalized) ? normalized : '';
+}
+function normalizePanelMode(value = '') {
+  return String(value || '').trim().toLowerCase() === 'sub2api' ? 'sub2api' : 'cpa';
+}
+function normalizeMailProvider(value = '') {
+  return String(value || '').trim().toLowerCase() || '163';
+}
+function normalizeAutoRunFallbackThreadIntervalMinutes(value) {
+  return Math.max(0, Math.floor(Number(value) || 0));
+}
+function normalizeAutoRunDelayMinutes(value) {
+  return Math.max(1, Math.floor(Number(value) || 30));
+}
+function normalizeAutoStepDelaySeconds(value, fallback = null) {
+  const numeric = Number(value);
+  return Number.isFinite(numeric) ? Math.max(0, Math.floor(numeric)) : fallback;
+}
+function normalizeHotmailServiceMode() {
+  return HOTMAIL_SERVICE_MODE_LOCAL;
+}
+function normalizeHotmailRemoteBaseUrl(value = '') {
+  return String(value || '').trim() || DEFAULT_HOTMAIL_REMOTE_BASE_URL;
+}
+function normalizeHotmailLocalBaseUrl(value = '') {
+  return String(value || '').trim() || DEFAULT_HOTMAIL_LOCAL_BASE_URL;
+}
+function normalizeCloudflareDomain(value = '') {
+  return String(value || '').trim().toLowerCase();
+}
+function normalizeCloudflareDomains(values = []) {
+  return Array.isArray(values) ? values : [];
+}
+function normalizeHotmailAccounts(values = []) {
+  return Array.isArray(values) ? values : [];
+}
+function getManualAliasUsageMap(state) {
+  return { ...(state?.manualAliasUsage || {}) };
+}
+function getPreservedAliasMap(state) {
+  return { ...(state?.preservedAliases || {}) };
+}
+function isAliasPreserved(state, email) {
+  return Boolean(getPreservedAliasMap(state)[String(email || '').trim().toLowerCase()]);
+}
+async function setIcloudAliasUsedState(payload, options = {}) {
+  calls.setUsed.push({ payload, options });
+}
+async function addLog(message, level = 'info') {
+  calls.logs.push({ message, level });
+}
+async function deleteIcloudAlias(alias) {
+  calls.deletes.push(alias);
+}
+async function listIcloudAliases() {
+  calls.listCalls += 1;
+  return overrides.listIcloudAliases ? overrides.listIcloudAliases() : [];
+}
+function findIcloudAliasByEmail(aliases, email) {
+  return (aliases || []).find((alias) => String(alias.email || '').toLowerCase() === String(email || '').toLowerCase()) || null;
+}
+function getErrorMessage(error) {
+  return String(typeof error === 'string' ? error : error?.message || '');
+}
+
+${bundle}
+
+return {
+  calls,
+  normalizeEmailGenerator,
+  getEmailGeneratorLabel,
+  normalizePersistentSettingValue,
+  finalizeIcloudAliasAfterSuccessfulFlow,
+};
+`)(overrides);
+}
+
+test('normalizeEmailGenerator and label support icloud', () => {
+  const api = createApi();
+  assert.equal(api.normalizeEmailGenerator('icloud'), 'icloud');
+  assert.equal(api.getEmailGeneratorLabel('icloud'), 'iCloud 隐私邮箱');
+});
+
+test('normalizePersistentSettingValue handles icloud settings', () => {
+  const api = createApi();
+  assert.equal(api.normalizePersistentSettingValue('icloudHostPreference', 'icloud.com'), 'icloud.com');
+  assert.equal(api.normalizePersistentSettingValue('icloudHostPreference', 'bad-host'), 'auto');
+  assert.equal(api.normalizePersistentSettingValue('autoDeleteUsedIcloudAlias', 1), true);
+});
+
+test('finalizeIcloudAliasAfterSuccessfulFlow marks icloud aliases as used without deleting when auto-delete is off', async () => {
+  const api = createApi();
+  const result = await api.finalizeIcloudAliasAfterSuccessfulFlow({
+    email: 'alias@icloud.com',
+    emailGenerator: 'icloud',
+    autoDeleteUsedIcloudAlias: false,
+    manualAliasUsage: {},
+    preservedAliases: {},
+  });
+
+  assert.deepEqual(result, { handled: true, deleted: false });
+  assert.equal(api.calls.setUsed.length, 1);
+  assert.equal(api.calls.listCalls, 0);
+  assert.equal(api.calls.deletes.length, 0);
+});
+
+test('finalizeIcloudAliasAfterSuccessfulFlow skips deleting preserved aliases', async () => {
+  const api = createApi();
+  const result = await api.finalizeIcloudAliasAfterSuccessfulFlow({
+    email: 'alias@icloud.com',
+    emailGenerator: 'icloud',
+    autoDeleteUsedIcloudAlias: true,
+    manualAliasUsage: {},
+    preservedAliases: { 'alias@icloud.com': true },
+  });
+
+  assert.deepEqual(result, { handled: true, deleted: false });
+  assert.equal(api.calls.setUsed.length, 1);
+  assert.equal(api.calls.listCalls, 0);
+  assert.equal(api.calls.deletes.length, 0);
+});
+
+test('finalizeIcloudAliasAfterSuccessfulFlow skips deleting aliases that are preserved in the latest alias list', async () => {
+  const api = createApi({
+    listIcloudAliases() {
+      return [
+        { email: 'alias@icloud.com', anonymousId: 'anon-1', preserved: true },
+      ];
+    },
+  });
+
+  const result = await api.finalizeIcloudAliasAfterSuccessfulFlow({
+    email: 'alias@icloud.com',
+    emailGenerator: 'icloud',
+    autoDeleteUsedIcloudAlias: true,
+    manualAliasUsage: {},
+    preservedAliases: {},
+  });
+
+  assert.deepEqual(result, { handled: true, deleted: false });
+  assert.equal(api.calls.setUsed.length, 1);
+  assert.equal(api.calls.listCalls, 1);
+  assert.equal(api.calls.deletes.length, 0);
+});
+
+test('finalizeIcloudAliasAfterSuccessfulFlow deletes alias when auto-delete is enabled and alias exists', async () => {
+  const api = createApi({
+    listIcloudAliases() {
+      return [
+        { email: 'alias@icloud.com', anonymousId: 'anon-1', preserved: false },
+      ];
+    },
+  });
+
+  const result = await api.finalizeIcloudAliasAfterSuccessfulFlow({
+    email: 'alias@icloud.com',
+    emailGenerator: 'icloud',
+    autoDeleteUsedIcloudAlias: true,
+    manualAliasUsage: {},
+    preservedAliases: {},
+  });
+
+  assert.deepEqual(result, { handled: true, deleted: true });
+  assert.equal(api.calls.setUsed.length, 1);
+  assert.equal(api.calls.listCalls, 1);
+  assert.deepEqual(api.calls.deletes, [
+    { email: 'alias@icloud.com', anonymousId: 'anon-1', preserved: false },
+  ]);
+});
+
+test('finalizeIcloudAliasAfterSuccessfulFlow ignores non-icloud flows', async () => {
+  const api = createApi();
+  const result = await api.finalizeIcloudAliasAfterSuccessfulFlow({
+    email: 'plain@example.com',
+    emailGenerator: 'duck',
+    autoDeleteUsedIcloudAlias: true,
+    manualAliasUsage: {},
+    preservedAliases: {},
+  });
+
+  assert.deepEqual(result, { handled: false, deleted: false });
+  assert.equal(api.calls.setUsed.length, 0);
+});

+ 4 - 0
tests/background-luckmail.test.js

@@ -440,6 +440,9 @@ test('resetState preserves LuckMail session config, used map, and preserve tag c
     'async function getPersistedSettings() {',
     "  return { mailProvider: '163' };",
     '}',
+    'async function getPersistedAliasState() {',
+    '  return {};',
+    '}',
     'const chrome = {',
     '  storage: {',
     '    session: {',
@@ -545,6 +548,7 @@ function broadcastDataUpdate() {}
 function isLocalhostOAuthCallbackUrl() {
   return true;
 }
+async function finalizeIcloudAliasAfterSuccessfulFlow() {}
 
 ${bundle}
 

+ 160 - 0
tests/cloudflare-temp-email-provider.test.js

@@ -0,0 +1,160 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('fs');
+
+const source = fs.readFileSync('background.js', 'utf8');
+
+function extractFunction(name) {
+  const markers = [`async function ${name}(`, `function ${name}(`];
+  const start = markers
+    .map((marker) => source.indexOf(marker))
+    .find((index) => index >= 0);
+  if (start < 0) {
+    throw new Error(`missing function ${name}`);
+  }
+
+  let parenDepth = 0;
+  let signatureEnded = false;
+  let braceStart = -1;
+  for (let i = start; i < source.length; i += 1) {
+    const ch = source[i];
+    if (ch === '(') {
+      parenDepth += 1;
+    } else if (ch === ')') {
+      parenDepth -= 1;
+      if (parenDepth === 0) {
+        signatureEnded = true;
+      }
+    } else if (ch === '{' && signatureEnded) {
+      braceStart = i;
+      break;
+    }
+  }
+  if (braceStart < 0) {
+    throw new Error(`missing body for function ${name}`);
+  }
+
+  let depth = 0;
+  let end = braceStart;
+  for (; end < source.length; end += 1) {
+    const ch = source[end];
+    if (ch === '{') depth += 1;
+    if (ch === '}') {
+      depth -= 1;
+      if (depth === 0) {
+        end += 1;
+        break;
+      }
+    }
+  }
+
+  return source.slice(start, end);
+}
+
+test('pollCloudflareTempEmailVerificationCode returns code even if delete fails', async () => {
+  const bundle = [
+    extractFunction('isStopError'),
+    extractFunction('throwIfStopped'),
+    extractFunction('summarizeCloudflareTempEmailMessagesForLog'),
+    extractFunction('pollCloudflareTempEmailVerificationCode'),
+  ].join('\n');
+
+const api = new Function(`
+let stopRequested = false;
+const STOP_ERROR_MESSAGE = '流程已被用户停止。';
+const CLOUDFLARE_TEMP_EMAIL_DEFAULT_PAGE_SIZE = 20;
+const logs = [];
+function normalizeCloudflareTempEmailAddress(value) {
+  return String(value || '').trim().toLowerCase();
+}
+
+async function addLog(message, level) {
+  logs.push({ message, level });
+}
+async function sleepWithStop() {}
+async function listCloudflareTempEmailMessages() {
+  return {
+    config: {},
+    messages: [{
+      id: 'mail-1',
+      address: 'user@example.com',
+      receivedDateTime: '2026-04-13T09:20:00.000Z',
+      subject: 'OpenAI verification code',
+      from: { emailAddress: { address: 'noreply@tm.openai.com' } },
+      bodyPreview: 'Your verification code is 123456.',
+    }],
+  };
+}
+function pickVerificationMessageWithTimeFallback(messages) {
+  return {
+    match: {
+      code: '123456',
+      receivedAt: Date.parse(messages[0].receivedDateTime),
+      message: messages[0],
+    },
+    usedRelaxedFilters: false,
+    usedTimeFallback: false,
+  };
+}
+async function deleteCloudflareTempEmailMail() {
+  throw new Error('delete failed');
+}
+
+${bundle}
+
+return {
+  pollCloudflareTempEmailVerificationCode,
+  snapshot() {
+    return { logs };
+  },
+};
+`)();
+
+  const result = await api.pollCloudflareTempEmailVerificationCode(4, { email: 'user@example.com' }, {
+    targetEmail: 'user@example.com',
+    maxAttempts: 1,
+    intervalMs: 1,
+  });
+
+  assert.equal(result.code, '123456');
+  const state = api.snapshot();
+  assert.equal(state.logs.some((entry) => entry.message.includes('删除 Cloudflare Temp Email 邮件失败')), true);
+});
+
+test('pollCloudflareTempEmailVerificationCode requires target email', async () => {
+  const bundle = [
+    extractFunction('isStopError'),
+    extractFunction('throwIfStopped'),
+    extractFunction('pollCloudflareTempEmailVerificationCode'),
+  ].join('\n');
+
+  const api = new Function(`
+let stopRequested = false;
+const STOP_ERROR_MESSAGE = '流程已被用户停止。';
+const CLOUDFLARE_TEMP_EMAIL_DEFAULT_PAGE_SIZE = 20;
+function normalizeCloudflareTempEmailAddress(value) {
+  return String(value || '').trim().toLowerCase();
+}
+async function addLog() {}
+async function sleepWithStop() {}
+async function listCloudflareTempEmailMessages() {
+  throw new Error('should not reach list');
+}
+function pickVerificationMessageWithTimeFallback() {
+  return { match: null, usedRelaxedFilters: false, usedTimeFallback: false };
+}
+async function deleteCloudflareTempEmailMail() {}
+function summarizeCloudflareTempEmailMessagesForLog() {
+  return '';
+}
+
+${bundle}
+
+return { pollCloudflareTempEmailVerificationCode };
+`)();
+
+  await assert.rejects(
+    api.pollCloudflareTempEmailVerificationCode(4, {}, {}),
+    /缺少目标邮箱地址/
+  );
+});

+ 107 - 0
tests/cloudflare-temp-email-utils.test.js

@@ -0,0 +1,107 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const {
+  buildCloudflareTempEmailHeaders,
+  getCloudflareTempEmailAddressFromResponse,
+  normalizeCloudflareTempEmailBaseUrl,
+  normalizeCloudflareTempEmailDomain,
+  normalizeCloudflareTempEmailDomains,
+  normalizeCloudflareTempEmailMailApiMessages,
+} = require('../cloudflare-temp-email-utils.js');
+
+test('normalizeCloudflareTempEmailBaseUrl normalizes host and preserves path', () => {
+  assert.equal(
+    normalizeCloudflareTempEmailBaseUrl('temp.example.com/api/'),
+    'https://temp.example.com/api'
+  );
+  assert.equal(
+    normalizeCloudflareTempEmailBaseUrl('http://127.0.0.1:8787'),
+    'http://127.0.0.1:8787'
+  );
+  assert.equal(normalizeCloudflareTempEmailBaseUrl('::::'), '');
+});
+
+test('normalizeCloudflareTempEmailDomain and domains de-duplicate valid entries', () => {
+  assert.equal(normalizeCloudflareTempEmailDomain('@Mail.Example.com'), 'mail.example.com');
+  assert.equal(normalizeCloudflareTempEmailDomain('not-a-domain'), '');
+  assert.deepEqual(
+    normalizeCloudflareTempEmailDomains(['mail.example.com', 'MAIL.EXAMPLE.COM', 'bad-value']),
+    ['mail.example.com']
+  );
+});
+
+test('buildCloudflareTempEmailHeaders includes auth headers and content type when needed', () => {
+  assert.deepEqual(
+    buildCloudflareTempEmailHeaders(
+      {
+        adminAuth: 'admin-secret',
+        customAuth: 'site-secret',
+      },
+      { json: true }
+    ),
+    {
+      'x-admin-auth': 'admin-secret',
+      'x-custom-auth': 'site-secret',
+      'Content-Type': 'application/json',
+      Accept: 'application/json',
+    }
+  );
+});
+
+test('normalizeCloudflareTempEmailMailApiMessages extracts sender, subject, code, and address from raw mime', () => {
+  const messages = normalizeCloudflareTempEmailMailApiMessages({
+    data: [
+      {
+        id: 'mail-1',
+        address: 'user@example.com',
+        created_at: '2026-04-13T09:15:00.000Z',
+        raw: [
+          'From: OpenAI <noreply@tm.openai.com>',
+          'Subject: =?UTF-8?B?T3BlbkFJIHZlcmlmaWNhdGlvbiBjb2Rl?=',
+          'Content-Type: text/plain; charset=UTF-8',
+          '',
+          'Your verification code is 654321.',
+        ].join('\r\n'),
+      },
+    ],
+  });
+
+  assert.equal(messages.length, 1);
+  assert.equal(messages[0].id, 'mail-1');
+  assert.equal(messages[0].address, 'user@example.com');
+  assert.equal(messages[0].subject, 'OpenAI verification code');
+  assert.equal(messages[0].from.emailAddress.address, 'OpenAI <noreply@tm.openai.com>');
+  assert.match(messages[0].bodyPreview, /654321/);
+});
+
+test('normalizeCloudflareTempEmailMailApiMessages decodes multipart quoted printable html bodies', () => {
+  const messages = normalizeCloudflareTempEmailMailApiMessages([
+    {
+      id: 'mail-2',
+      address: 'user@example.com',
+      received_at: '2026-04-13T09:20:00.000Z',
+      source: [
+        'From: ChatGPT <noreply@tm.openai.com>',
+        'Subject: Login code',
+        'Content-Type: multipart/alternative; boundary="abc123"',
+        '',
+        '--abc123',
+        'Content-Type: text/html; charset=UTF-8',
+        'Content-Transfer-Encoding: quoted-printable',
+        '',
+        '<p>Your login code is <strong>112233</strong>.</p>',
+        '--abc123--',
+      ].join('\r\n'),
+    },
+  ]);
+
+  assert.equal(messages.length, 1);
+  assert.match(messages[0].bodyPreview, /112233/);
+  assert.equal(messages[0].subject, 'Login code');
+});
+
+test('getCloudflareTempEmailAddressFromResponse supports direct and nested response shapes', () => {
+  assert.equal(getCloudflareTempEmailAddressFromResponse({ address: 'one@example.com' }), 'one@example.com');
+  assert.equal(getCloudflareTempEmailAddressFromResponse({ data: { address: 'two@example.com' } }), 'two@example.com');
+});

+ 121 - 0
tests/icloud-utils.test.js

@@ -0,0 +1,121 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const {
+  findIcloudAliasArray,
+  findIcloudAliasByEmail,
+  getConfiguredIcloudHostPreference,
+  getIcloudHostHintFromMessage,
+  getIcloudLoginUrlForHost,
+  getIcloudSetupUrlForHost,
+  normalizeBooleanMap,
+  normalizeIcloudAliasList,
+  normalizeIcloudAliasRecord,
+  normalizeIcloudHost,
+  pickReusableIcloudAlias,
+  toNormalizedEmailSet,
+} = require('../icloud-utils.js');
+
+test('normalizeIcloudHost and host preference helpers resolve supported hosts', () => {
+  assert.equal(normalizeIcloudHost('www.icloud.com'), 'icloud.com');
+  assert.equal(normalizeIcloudHost('setup.icloud.com.cn'), 'icloud.com.cn');
+  assert.equal(normalizeIcloudHost('example.com'), '');
+
+  assert.equal(getConfiguredIcloudHostPreference({ icloudHostPreference: 'icloud.com' }), 'icloud.com');
+  assert.equal(getConfiguredIcloudHostPreference({ icloudHostPreference: 'auto' }), '');
+  assert.equal(getIcloudLoginUrlForHost('icloud.com.cn'), 'https://www.icloud.com.cn/');
+  assert.equal(getIcloudSetupUrlForHost('icloud.com'), 'https://setup.icloud.com/setup/ws/1');
+});
+
+test('getIcloudHostHintFromMessage can infer host from error text', () => {
+  assert.equal(getIcloudHostHintFromMessage('status 401 from setup.icloud.com.cn/setup/ws/1'), 'icloud.com.cn');
+  assert.equal(getIcloudHostHintFromMessage('request failed at https://www.icloud.com/'), 'icloud.com');
+  assert.equal(getIcloudHostHintFromMessage('unknown host'), '');
+});
+
+test('findIcloudAliasArray finds nested alias collections', () => {
+  const payload = {
+    result: {
+      data: {
+        items: [
+          { hme: 'first@icloud.com', anonymousId: 'a1' },
+          { hme: 'second@icloud.com', anonymousId: 'a2' },
+        ],
+      },
+    },
+  };
+
+  assert.deepEqual(findIcloudAliasArray(payload), payload.result.data.items);
+});
+
+test('normalizeIcloudAliasRecord merges used and preserved state', () => {
+  const alias = normalizeIcloudAliasRecord({
+    anonymousId: 'alias-1',
+    hme: 'Demo@iCloud.com',
+    label: 'Test',
+    note: 'Created by test',
+    state: 'active',
+  }, {
+    usedEmails: ['demo@icloud.com'],
+    preservedEmails: ['demo@icloud.com'],
+  });
+
+  assert.deepEqual(alias, {
+    anonymousId: 'alias-1',
+    email: 'demo@icloud.com',
+    label: 'Test',
+    note: 'Created by test',
+    state: 'active',
+    active: true,
+    used: true,
+    preserved: true,
+    createdAt: null,
+  });
+});
+
+test('normalizeIcloudAliasList orders active unused aliases before used aliases', () => {
+  const aliases = normalizeIcloudAliasList({
+    hmeEmails: [
+      { hme: 'used@icloud.com', anonymousId: 'u1', active: true },
+      { hme: 'fresh@icloud.com', anonymousId: 'f1', active: true },
+      { hme: 'inactive@icloud.com', anonymousId: 'i1', active: false },
+    ],
+  }, {
+    usedEmails: ['used@icloud.com'],
+    preservedEmails: ['inactive@icloud.com'],
+  });
+
+  assert.deepEqual(aliases.map((alias) => alias.email), [
+    'fresh@icloud.com',
+    'used@icloud.com',
+    'inactive@icloud.com',
+  ]);
+  assert.equal(aliases[2].preserved, true);
+});
+
+test('pickReusableIcloudAlias and findIcloudAliasByEmail select expected aliases', () => {
+  const aliases = normalizeIcloudAliasList({
+    hmeEmails: [
+      { hme: 'used@icloud.com', anonymousId: 'u1', active: true },
+      { hme: 'fresh@icloud.com', anonymousId: 'f1', active: true },
+    ],
+  }, {
+    usedEmails: ['used@icloud.com'],
+  });
+
+  assert.equal(pickReusableIcloudAlias(aliases)?.email, 'fresh@icloud.com');
+  assert.equal(findIcloudAliasByEmail(aliases, 'FRESH@ICLOUD.COM')?.anonymousId, 'f1');
+});
+
+test('normalizeBooleanMap and toNormalizedEmailSet normalize keys and truthy entries', () => {
+  const normalized = normalizeBooleanMap({
+    ' Demo@icloud.com ': 1,
+    'skip@icloud.com': 0,
+  });
+
+  assert.deepEqual(normalized, {
+    'demo@icloud.com': true,
+    'skip@icloud.com': false,
+  });
+  assert.deepEqual([...toNormalizedEmailSet(normalized)], ['demo@icloud.com']);
+});

+ 15 - 2
tests/step9-localhost-cleanup-scope.test.js

@@ -52,7 +52,12 @@ function extractFunction(name) {
 
 const bundle = [
   extractFunction('getTabRegistry'),
+  extractFunction('normalizeEmailGenerator'),
   extractFunction('parseUrlSafely'),
+  extractFunction('isHotmailProvider'),
+  extractFunction('isCustomMailProvider'),
+  extractFunction('isGeneratedAliasProvider'),
+  extractFunction('shouldUseCustomRegistrationEmail'),
   extractFunction('isLocalhostOAuthCallbackUrl'),
   extractFunction('isLocalhostOAuthCallbackTabMatch'),
   extractFunction('closeLocalhostCallbackTabs'),
@@ -62,6 +67,9 @@ const bundle = [
 ].join('\n');
 
 const api = new Function(`
+const HOTMAIL_PROVIDER = 'hotmail-api';
+const CLOUDFLARE_TEMP_EMAIL_PROVIDER = 'cloudflare-temp-email';
+const CLOUDFLARE_TEMP_EMAIL_GENERATOR = 'cloudflare-temp-email';
 let currentState = {
   tabRegistry: {
     'signup-page': { tabId: 1, ready: true },
@@ -122,6 +130,11 @@ async function addLog(message) {
   logMessages.push(message);
 }
 
+async function finalizeIcloudAliasAfterSuccessfulFlow() {}
+function shouldUseCustomRegistrationEmail() {
+  return false;
+}
+
 ${bundle}
 
 return {
@@ -183,8 +196,8 @@ return {
   let snapshot = api.snapshot();
   assert.deepStrictEqual(
     snapshot.removedBatches,
-    [[1], [2, 3]],
-    'handleStepData(9) 应先关闭当前 callback 页,再按同前缀路径清理残留页'
+    [[1], [2]],
+    'handleStepData(9) 应先关闭当前 callback 页,再按同源首段路径清理残留页'
   );
   assert.strictEqual(
     snapshot.currentState.tabRegistry['signup-page'],

+ 4 - 2
tests/verification-stop-propagation.test.js

@@ -57,11 +57,12 @@ async function testPollFreshVerificationCodeRethrowsStop() {
     extractFunction('pollFreshVerificationCode'),
   ].join('\n');
 
-  const api = new Function(`
+const api = new Function(`
 let stopRequested = false;
 const STOP_ERROR_MESSAGE = '流程已被用户停止。';
 const HOTMAIL_PROVIDER = 'hotmail-api';
 const LUCKMAIL_PROVIDER = 'luckmail-api';
+const CLOUDFLARE_TEMP_EMAIL_PROVIDER = 'cloudflare-temp-email';
 const VERIFICATION_POLL_MAX_ROUNDS = 5;
 const logs = [];
 let resendCalls = 0;
@@ -123,10 +124,11 @@ async function testResolveVerificationStepRethrowsStopFromFreshRequest() {
     extractFunction('resolveVerificationStep'),
   ].join('\n');
 
-  const api = new Function(`
+const api = new Function(`
 const STOP_ERROR_MESSAGE = '流程已被用户停止。';
 const HOTMAIL_PROVIDER = 'hotmail-api';
 const LUCKMAIL_PROVIDER = 'luckmail-api';
+const CLOUDFLARE_TEMP_EMAIL_PROVIDER = 'cloudflare-temp-email';
 const logs = [];
 let pollCalls = 0;
 

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff