phplife-mail.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577
  1. // content/phplife-mail.js — Content script for A4Sky mailbox on mail.phplife.net
  2. // Injected dynamically on: mail.phplife.net
  3. const PHPLIFE_MAIL_PREFIX = '[MultiPage:mail-phplife]';
  4. const isTopFrame = window === window.top;
  5. console.log(PHPLIFE_MAIL_PREFIX, 'Content script loaded on', location.href, 'frame:', isTopFrame ? 'top' : 'child');
  6. if (!isTopFrame) {
  7. console.log(PHPLIFE_MAIL_PREFIX, 'Skipping child frame');
  8. } else {
  9. let seenCodes = new Set();
  10. let waitingLoginLogged = false;
  11. async function loadSeenCodes() {
  12. try {
  13. const data = await chrome.storage.session.get('seenPhplifeCodes');
  14. if (Array.isArray(data.seenPhplifeCodes)) {
  15. seenCodes = new Set(data.seenPhplifeCodes.filter(Boolean));
  16. }
  17. } catch (err) {
  18. console.warn(PHPLIFE_MAIL_PREFIX, 'Could not load seen codes:', err?.message || err);
  19. }
  20. }
  21. loadSeenCodes();
  22. async function persistSeenCodes() {
  23. try {
  24. await chrome.storage.session.set({ seenPhplifeCodes: [...seenCodes] });
  25. } catch (err) {
  26. console.warn(PHPLIFE_MAIL_PREFIX, 'Could not persist seen codes:', err?.message || err);
  27. }
  28. }
  29. chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  30. if (message.type === 'POLL_EMAIL') {
  31. resetStopState();
  32. handlePollEmail(message.step, message.payload).then((result) => {
  33. sendResponse(result);
  34. }).catch((err) => {
  35. if (isStopError(err)) {
  36. log(`步骤 ${message.step}:已被用户停止。`, 'warn');
  37. sendResponse({ stopped: true, error: err.message });
  38. return;
  39. }
  40. log(`步骤 ${message.step}:A4Sky 邮箱轮询失败:${err.message}`, 'warn');
  41. sendResponse({ error: err.message });
  42. });
  43. return true;
  44. }
  45. });
  46. function normalizeText(value) {
  47. return String(value || '').replace(/\s+/g, ' ').trim();
  48. }
  49. function sleep(ms) {
  50. return new Promise((resolve, reject) => {
  51. if (flowStopped) {
  52. reject(new Error(STOP_ERROR_MESSAGE));
  53. return;
  54. }
  55. setTimeout(() => {
  56. if (flowStopped) {
  57. reject(new Error(STOP_ERROR_MESSAGE));
  58. return;
  59. }
  60. resolve();
  61. }, ms);
  62. });
  63. }
  64. function isVisibleElement(element) {
  65. if (!element) return false;
  66. const style = window.getComputedStyle(element);
  67. if (style.display === 'none' || style.visibility === 'hidden') return false;
  68. const rect = element.getBoundingClientRect();
  69. return rect.width > 0 && rect.height > 0;
  70. }
  71. function getRcmailEnv() {
  72. try {
  73. return window.rcmail?.env || null;
  74. } catch {
  75. return null;
  76. }
  77. }
  78. function isLoginPageLikely() {
  79. const url = location.href.toLowerCase();
  80. if (/[_-]task=login|[?&]_action=login\b|\/login\b/.test(url)) {
  81. return true;
  82. }
  83. const passwordInput = document.querySelector('input[type="password"]');
  84. const loginForm = document.querySelector('form[action*="login"], form[name*="login"], #login-form, .login-form');
  85. const loginButton = Array.from(document.querySelectorAll('button, input[type="submit"], a')).find((element) => {
  86. const text = normalizeText(
  87. element?.textContent
  88. || element?.value
  89. || element?.getAttribute?.('title')
  90. || element?.getAttribute?.('aria-label')
  91. || ''
  92. );
  93. return /登录|登入|sign in|log in/i.test(text);
  94. });
  95. const mailboxList = document.querySelector('#mailboxlist');
  96. const messageList = document.querySelector('#messagelist');
  97. const hasMailUi = mailboxList || messageList || getRcmailEnv()?.task === 'mail';
  98. if ((passwordInput || loginForm || loginButton) && !hasMailUi) {
  99. return true;
  100. }
  101. const pageText = normalizeText(document.body?.innerText || document.body?.textContent || '');
  102. return /(企业邮箱登录|请输入密码|登录邮箱|sign in)/i.test(pageText) && !hasMailUi;
  103. }
  104. async function waitUntilLoggedIn(step) {
  105. let loggedWaitMessage = false;
  106. while (isLoginPageLikely()) {
  107. throwIfStopped();
  108. if (!loggedWaitMessage && !waitingLoginLogged) {
  109. waitingLoginLogged = true;
  110. loggedWaitMessage = true;
  111. log(`步骤 ${step}:检测到 mail.phplife.net 未登录,正在等待你手动登录...`, 'warn');
  112. }
  113. await sleep(1000);
  114. }
  115. if (loggedWaitMessage || waitingLoginLogged) {
  116. log(`步骤 ${step}:已检测到 mail.phplife.net 登录完成,继续读取验证码...`, 'ok');
  117. }
  118. waitingLoginLogged = false;
  119. }
  120. function normalizeMinuteTimestamp(timestamp) {
  121. if (!Number.isFinite(timestamp) || timestamp <= 0) return 0;
  122. const date = new Date(timestamp);
  123. date.setSeconds(0, 0);
  124. return date.getTime();
  125. }
  126. function parseRoundcubeTimestamp(rawText) {
  127. const text = normalizeText(rawText);
  128. if (!text) return null;
  129. let match = text.match(/今天\s*(\d{1,2}):(\d{2})/);
  130. if (match) {
  131. const now = new Date();
  132. return new Date(now.getFullYear(), now.getMonth(), now.getDate(), Number(match[1]), Number(match[2]), 0, 0).getTime();
  133. }
  134. match = text.match(/昨天\s*(\d{1,2}):(\d{2})/);
  135. if (match) {
  136. const now = new Date();
  137. now.setDate(now.getDate() - 1);
  138. return new Date(now.getFullYear(), now.getMonth(), now.getDate(), Number(match[1]), Number(match[2]), 0, 0).getTime();
  139. }
  140. match = text.match(/(\d{4})[-/年](\d{1,2})[-/月](\d{1,2})日?\s*(\d{1,2}):(\d{2})/);
  141. if (match) {
  142. return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]), Number(match[4]), Number(match[5]), 0, 0).getTime();
  143. }
  144. match = text.match(/(\d{1,2})[-/](\d{1,2})\s*(\d{1,2}):(\d{2})/);
  145. if (match) {
  146. const now = new Date();
  147. return new Date(now.getFullYear(), Number(match[1]) - 1, Number(match[2]), Number(match[3]), Number(match[4]), 0, 0).getTime();
  148. }
  149. match = text.match(/^(\d{1,2}):(\d{2})$/);
  150. if (match) {
  151. const now = new Date();
  152. return new Date(now.getFullYear(), now.getMonth(), now.getDate(), Number(match[1]), Number(match[2]), 0, 0).getTime();
  153. }
  154. const parsed = Date.parse(text);
  155. return Number.isFinite(parsed) ? parsed : null;
  156. }
  157. function extractVerificationCodes(text) {
  158. const source = String(text || '');
  159. const codes = [];
  160. const patterns = [
  161. /(?:验证码|代码)[^0-9]{0,24}(\d{6})/ig,
  162. /(?:chatgpt\s+log-?in\s+code|your\s+chatgpt\s+code\s+is|verification\s+code|temporary\s+verification\s+code|enter\s+this\s+code)[^0-9]{0,24}(\d{6})/ig,
  163. /\b(\d{6})\b/g,
  164. ];
  165. for (const pattern of patterns) {
  166. let match = null;
  167. while ((match = pattern.exec(source))) {
  168. if (match[1] && !codes.includes(match[1])) {
  169. codes.push(match[1]);
  170. }
  171. }
  172. if (codes.length) {
  173. return codes;
  174. }
  175. }
  176. return codes;
  177. }
  178. function extractEmails(text) {
  179. const matches = String(text || '').match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/ig) || [];
  180. return [...new Set(matches.map((item) => item.toLowerCase()))];
  181. }
  182. function getTargetEmailMatchState(text, targetEmail) {
  183. const normalizedTarget = String(targetEmail || '').trim().toLowerCase();
  184. if (!normalizedTarget) {
  185. return { matches: true, hasExplicitEmail: false };
  186. }
  187. const normalizedText = String(text || '').toLowerCase();
  188. if (normalizedText.includes(normalizedTarget)) {
  189. return { matches: true, hasExplicitEmail: true };
  190. }
  191. const emails = extractEmails(text);
  192. if (!emails.length) {
  193. return { matches: false, hasExplicitEmail: false };
  194. }
  195. return {
  196. matches: emails.includes(normalizedTarget),
  197. hasExplicitEmail: true,
  198. };
  199. }
  200. function matchesMailFilters(text, senderFilters = [], subjectFilters = []) {
  201. const normalizedText = String(text || '').toLowerCase();
  202. const senderMatched = senderFilters.some((filter) => normalizedText.includes(String(filter || '').toLowerCase()));
  203. const subjectMatched = subjectFilters.some((filter) => normalizedText.includes(String(filter || '').toLowerCase()));
  204. return senderMatched || subjectMatched;
  205. }
  206. function getPreviewDocument() {
  207. const frame = document.getElementById('messagecontframe');
  208. if (!frame) return null;
  209. try {
  210. return frame.contentDocument || frame.contentWindow?.document || null;
  211. } catch {
  212. return null;
  213. }
  214. }
  215. function getMessageDocument() {
  216. if (document.querySelector('#messagebody, #messageheader, .headers-table')) {
  217. return document;
  218. }
  219. const previewDocument = getPreviewDocument();
  220. if (previewDocument?.querySelector?.('#messagebody, #messageheader, .headers-table')) {
  221. return previewDocument;
  222. }
  223. return null;
  224. }
  225. function getMessageDetailsFromDocument(doc = null) {
  226. const sourceDocument = doc || getMessageDocument();
  227. if (!sourceDocument) return null;
  228. const subject = normalizeText(sourceDocument.querySelector('h2.subject')?.textContent || '');
  229. const from = normalizeText(sourceDocument.querySelector('.headers-table .header.from')?.textContent || '');
  230. const to = normalizeText(sourceDocument.querySelector('.headers-table .header.to')?.textContent || '');
  231. const dateText = normalizeText(sourceDocument.querySelector('.headers-table .header.date')?.textContent || '');
  232. const bodyText = normalizeText(
  233. sourceDocument.querySelector('#messagebody')?.innerText
  234. || sourceDocument.querySelector('#messagebody')?.textContent
  235. || sourceDocument.body?.innerText
  236. || sourceDocument.body?.textContent
  237. || ''
  238. );
  239. const combinedText = normalizeText([subject, from, to, dateText, bodyText].join(' '));
  240. const codes = extractVerificationCodes(combinedText);
  241. return {
  242. subject,
  243. from,
  244. to,
  245. dateText,
  246. emailTimestamp: parseRoundcubeTimestamp(dateText),
  247. bodyText,
  248. combinedText,
  249. codes,
  250. };
  251. }
  252. function getMessageListRows() {
  253. return Array.from(document.querySelectorAll('#messagelist tbody tr')).filter(isVisibleElement);
  254. }
  255. function getRowText(row, selector) {
  256. const node = row?.querySelector(selector);
  257. return normalizeText(
  258. node?.getAttribute?.('title')
  259. || node?.getAttribute?.('aria-label')
  260. || node?.textContent
  261. || ''
  262. );
  263. }
  264. function getRowDetails(row) {
  265. const subject = getRowText(row, 'td.subject');
  266. const from = getRowText(row, 'td.fromto');
  267. const to = getRowText(row, 'td.to');
  268. const dateText = getRowText(row, 'td.date');
  269. const combinedText = normalizeText([subject, from, to, dateText, row?.textContent || ''].join(' '));
  270. return {
  271. row,
  272. subject,
  273. from,
  274. to,
  275. dateText,
  276. emailTimestamp: parseRoundcubeTimestamp(dateText),
  277. codes: extractVerificationCodes(combinedText),
  278. combinedText,
  279. };
  280. }
  281. function scoreRowCandidate(details, payload = {}) {
  282. const { senderFilters = [], subjectFilters = [], targetEmail = '' } = payload;
  283. let score = 0;
  284. const combinedText = details?.combinedText || '';
  285. if (matchesMailFilters(combinedText, senderFilters, subjectFilters)) score += 4;
  286. if (/openai|chatgpt|verification|verify|验证码/i.test(combinedText)) score += 3;
  287. const targetMatch = getTargetEmailMatchState(combinedText, targetEmail);
  288. if (targetMatch.matches) score += targetMatch.hasExplicitEmail ? 4 : 1;
  289. if (details?.emailTimestamp) score += 1;
  290. return score;
  291. }
  292. function shouldOpenRowForCodeDetection(details = {}) {
  293. const subject = normalizeText(details?.subject || '');
  294. if (!subject) {
  295. return false;
  296. }
  297. return /your\s+temporary\s+chatgpt\s+login\s+code|(?:你(?:的)?|您的)?\s*临时\s*chatgpt\s*登录代?码/i.test(subject);
  298. }
  299. function getCurrentMessageUid() {
  300. const envUid = getRcmailEnv()?.uid;
  301. if (envUid) return String(envUid);
  302. const doc = getMessageDocument();
  303. const permaLink = doc?.querySelector?.('a[href*="_uid="]')?.getAttribute?.('href') || location.href;
  304. const match = String(permaLink || '').match(/[?&]_uid=(\d+)/);
  305. return match ? match[1] : '';
  306. }
  307. async function waitForPreviewLoaded(previousUid = '', timeoutMs = 15000) {
  308. const start = Date.now();
  309. while (Date.now() - start < timeoutMs) {
  310. throwIfStopped();
  311. const doc = getMessageDocument();
  312. const details = getMessageDetailsFromDocument(doc);
  313. const currentUid = getCurrentMessageUid();
  314. if (details?.combinedText && (!previousUid || !currentUid || currentUid !== previousUid)) {
  315. return details;
  316. }
  317. await sleep(250);
  318. }
  319. return getMessageDetailsFromDocument();
  320. }
  321. function openMessageRow(row) {
  322. if (!row) {
  323. return;
  324. }
  325. if (typeof row.click === 'function') {
  326. row.click();
  327. return;
  328. }
  329. row.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
  330. }
  331. function findRefreshButton() {
  332. const selectors = [
  333. '#rcmbtn105',
  334. 'a.button.checkmail',
  335. 'a[title*="检查新邮件"]',
  336. 'a[title*="刷新"]',
  337. ];
  338. for (const selector of selectors) {
  339. const node = document.querySelector(selector);
  340. if (node) return node;
  341. }
  342. return Array.from(document.querySelectorAll('a, button')).find((element) => /刷新|检查新邮件|check mail/i.test(normalizeText(element.textContent || element.getAttribute('title') || ''))) || null;
  343. }
  344. function findInboxLink() {
  345. return document.querySelector('#mailboxlist .mailbox.inbox a[rel="INBOX"], #mailboxlist a[rel="INBOX"]');
  346. }
  347. async function ensureInboxActive(options = {}) {
  348. const { forceRefresh = false } = options;
  349. const inboxLink = findInboxLink();
  350. if (!inboxLink) return;
  351. const inboxItem = inboxLink.closest('.mailbox');
  352. if (!forceRefresh && inboxItem?.classList?.contains('selected')) {
  353. return;
  354. }
  355. inboxLink.click();
  356. await sleep(800);
  357. }
  358. async function refreshMessageList() {
  359. await ensureInboxActive({ forceRefresh: true });
  360. const refreshButton = findRefreshButton();
  361. if (!refreshButton) return true;
  362. refreshButton.click();
  363. await sleep(1200);
  364. return true;
  365. }
  366. function selectCandidateCode(codes = [], excludedCodeSet = new Set()) {
  367. for (const code of codes) {
  368. if (!excludedCodeSet.has(code) && !seenCodes.has(code)) {
  369. return code;
  370. }
  371. }
  372. return null;
  373. }
  374. function matchesCurrentMessage(details, payload = {}, excludedCodeSet = new Set(), filterAfterMinute = 0) {
  375. if (!details?.combinedText) {
  376. return { matched: false };
  377. }
  378. const targetMatch = getTargetEmailMatchState(details.combinedText, payload.targetEmail);
  379. if (targetMatch.hasExplicitEmail && !targetMatch.matches) {
  380. return { matched: false };
  381. }
  382. if (!matchesMailFilters(details.combinedText, payload.senderFilters, payload.subjectFilters)) {
  383. return { matched: false };
  384. }
  385. const normalizedTimestamp = normalizeMinuteTimestamp(details.emailTimestamp || 0);
  386. if (filterAfterMinute && normalizedTimestamp && normalizedTimestamp < filterAfterMinute) {
  387. return { matched: false };
  388. }
  389. const code = selectCandidateCode(details.codes, excludedCodeSet);
  390. if (!code) {
  391. return { matched: false };
  392. }
  393. return {
  394. matched: true,
  395. code,
  396. emailTimestamp: details.emailTimestamp || Date.now(),
  397. };
  398. }
  399. async function tryReadCurrentMessage(payload, excludedCodeSet, filterAfterMinute) {
  400. const details = getMessageDetailsFromDocument();
  401. const result = matchesCurrentMessage(details, payload, excludedCodeSet, filterAfterMinute);
  402. return result.matched ? result : null;
  403. }
  404. async function tryOpenRowsAndRead(step, payload, excludedCodeSet, filterAfterMinute) {
  405. const rows = getMessageListRows()
  406. .map((row) => getRowDetails(row))
  407. .map((details) => ({ ...details, score: scoreRowCandidate(details, payload) }))
  408. .filter((details) => details.score > 0)
  409. .sort((left, right) => right.score - left.score);
  410. for (const details of rows.slice(0, 8)) {
  411. throwIfStopped();
  412. const rowMinuteTimestamp = normalizeMinuteTimestamp(details.emailTimestamp || 0);
  413. if (filterAfterMinute && rowMinuteTimestamp && rowMinuteTimestamp < filterAfterMinute) {
  414. continue;
  415. }
  416. const directResult = matchesCurrentMessage(details, payload, excludedCodeSet, filterAfterMinute);
  417. const shouldOpenDetail = shouldOpenRowForCodeDetection(details);
  418. if (directResult.matched && !shouldOpenDetail) {
  419. log(`步骤 ${step}:已直接从 mail.phplife.net 列表命中验证码邮件。`, 'ok');
  420. return directResult;
  421. }
  422. if (!shouldOpenDetail) {
  423. continue;
  424. }
  425. log(`步骤 ${step}:检测到临时登录验证码邮件标题,正在打开详情读取验证码...`, 'info');
  426. const previousUid = getCurrentMessageUid();
  427. openMessageRow(details.row);
  428. await sleep(500);
  429. const openedDetails = await waitForPreviewLoaded(previousUid);
  430. const detailResult = matchesCurrentMessage(openedDetails, payload, excludedCodeSet, filterAfterMinute);
  431. if (detailResult.matched) {
  432. log(`步骤 ${step}:已在 mail.phplife.net 邮件详情中命中验证码。`, 'ok');
  433. return detailResult;
  434. }
  435. if (directResult.matched) {
  436. log(`步骤 ${step}:邮件详情未提取到验证码,已回退使用列表中的匹配结果。`, 'warn');
  437. return directResult;
  438. }
  439. }
  440. return null;
  441. }
  442. async function handlePollEmail(step, payload) {
  443. const {
  444. maxAttempts = 5,
  445. intervalMs = 3000,
  446. excludeCodes = [],
  447. filterAfterTimestamp = 0,
  448. } = payload || {};
  449. const excludedCodeSet = new Set((excludeCodes || []).filter(Boolean));
  450. const filterAfterMinute = normalizeMinuteTimestamp(Number(filterAfterTimestamp) || 0);
  451. await waitUntilLoggedIn(step);
  452. await ensureInboxActive();
  453. log(`步骤 ${step}:开始轮询 A4Sky 邮箱(最多 ${maxAttempts} 次)`);
  454. if (filterAfterMinute) {
  455. log(`步骤 ${step}:仅尝试 ${new Date(filterAfterMinute).toLocaleString('zh-CN', { hour12: false })} 及之后时间的邮件。`);
  456. }
  457. let lastError = null;
  458. for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
  459. throwIfStopped();
  460. await waitUntilLoggedIn(step);
  461. log(`步骤 ${step}:正在检查 mail.phplife.net 邮件(${attempt}/${maxAttempts})...`);
  462. await refreshMessageList();
  463. const currentMessageResult = await tryReadCurrentMessage(payload, excludedCodeSet, filterAfterMinute);
  464. if (currentMessageResult?.matched) {
  465. seenCodes.add(currentMessageResult.code);
  466. await persistSeenCodes();
  467. return {
  468. code: currentMessageResult.code,
  469. emailTimestamp: currentMessageResult.emailTimestamp,
  470. };
  471. }
  472. const openedRowResult = await tryOpenRowsAndRead(step, payload, excludedCodeSet, filterAfterMinute);
  473. if (openedRowResult?.matched) {
  474. seenCodes.add(openedRowResult.code);
  475. await persistSeenCodes();
  476. return {
  477. code: openedRowResult.code,
  478. emailTimestamp: openedRowResult.emailTimestamp,
  479. };
  480. }
  481. lastError = new Error(`步骤 ${step}:暂未在 A4Sky 邮箱中找到新的匹配验证码(${attempt}/${maxAttempts})。`);
  482. if (attempt < maxAttempts) {
  483. await sleep(intervalMs);
  484. }
  485. }
  486. throw lastError || new Error(`步骤 ${step}:未在 A4Sky 邮箱中找到新的匹配验证码。`);
  487. }
  488. }