Quellcode durchsuchen

Merge branch 'hotmail邮箱支持' into master

QLHazyCoder vor 4 Monaten
Ursprung
Commit
56527273d2

+ 431 - 0
Hotmail双模式适配开发方案及开发记录.md

@@ -0,0 +1,431 @@
+# Hotmail 鍙屾ā寮忛€傞厤寮€鍙戞柟妗?
+## 1. 鑳屾櫙涓庨棶棰樺垎鏋?
+褰撳墠浠撳簱鐨?Hotmail 鎺ョ爜璺緞宸茬粡鍥為€€鍒扳€滅涓夋柟閰嶇疆鈥濈増鏈紝鏍稿績鐗圭偣鏄細
+
+- 璐﹀彿姹犵户缁娇鐢?`閭 / 瀹㈡埛绔?ID / 鍒锋柊浠ょ墝` 浣滀负鎵归噺瀵煎叆鏍煎紡
+- 鎵╁睍褰撳墠娲昏穬閫昏緫鐩存帴璇锋眰绗笁鏂?HTTP 鎺ュ彛璇诲彇閭欢
+- 渚ц竟鏍忛澶栨毚闇蹭簡 `API 鍦板潃 / 鍝嶅簲绫诲瀷 / 鏀朵欢绠卞弬鏁?/ 鍨冨溇绠卞弬鏁癭 杩欑粍鍗忚缁嗚妭閰嶇疆
+
+杩欑増铏界劧鑳藉伐浣滐紝浣嗗凡缁忔毚闇插嚭涓や釜缁撴瀯鎬ч棶棰橈細
+
+1. **杩滅▼鍗忚鍜屾湰鍦板崗璁苟涓嶄竴鑷?*
+   - `#10` 鏄繙绋嬬涓夋柟鏈嶅姟妯″紡锛屾牳蹇冩槸涓€涓浐瀹氭枃妗f帴鍙?`https://apple.882263.xyz/api.html`
+   - `#42` 鏄湰鍦?helper 妯″紡锛屾牳蹇冩槸鏈湴 Python 鑴氭湰鏆撮湶 `/messages`銆乣/code` 绛夋帴鍙?   - 涓よ€呭彧鏄骇鍝佸叆鍙g浉浼硷紝涓嶆槸鍚屼竴鍗忚锛屼笉鑳界‖缁熶竴鎴愪竴濂椻€滈€氱敤 API 鍙傛暟閰嶇疆鍣ㄢ€?
+2. **褰撳墠 UI 鏆撮湶浜嗚繃澶氬崗璁粏鑺?*
+   - `鍝嶅簲绫诲瀷`
+   - `鏀朵欢绠卞弬鏁癭
+   - `鍨冨溇绠卞弬鏁癭
+   - 杩欎簺瀛楁瀵硅繙绋嬫彁渚涘晢鍗忚鏉ヨ鏄浐瀹氱殑锛屽鏈湴 helper 鍗忚鏉ヨ鍙堟牴鏈笉閫傜敤
+   - 鎶婅繖浜涚粏鑺傛毚闇茬粰鏈€缁堢敤鎴凤紝浼氭妸鐣岄潰鍙樻垚鈥滄帴鍙h皟璇曞櫒鈥濓紝澧炲姞閿欒閰嶇疆椋庨櫓
+
+3. **鐢ㄦ埛鐪熷疄闇€姹備笉鏄氦浜掓巿鏉冿紝鑰屾槸鎵归噺璐﹀彿姹?*
+   - 闇€瑕佺户缁敮鎸?`璐﹀彿----瀵嗙爜----瀹㈡埛绔疘D----鍒锋柊浠ょ墝`
+   - 闇€瑕佹敮鎸佸嚑鍗佷釜璐﹀彿鐨勬壒閲忓鍏ヤ笌鑷姩杞
+   - 鍥犳褰撳墠闃舵涓嶅簲鍒囧埌鈥滃井杞氦浜掓巿鏉冣€濅富璺嚎
+
+## 2. 宸查獙璇佸弬鑰冩潵婧?
+### 2.1 PR #10锛氳繙绋嬬涓夋柟鏈嶅姟妯″紡
+
+宸茬‘璁ょ壒寰侊細
+
+- 鎵╁睍鐩存帴璇锋眰杩滅▼鎺ュ彛
+- 鎺ュ彛鏂囨。宸插叕寮€锛歚https://apple.882263.xyz/api.html`
+- 鏍稿績鎺ュ彛鏄?`/api/mail-new`
+- 鎵╁睍浼犲叆锛?  - `client_id`
+  - `refresh_token`
+  - `email`
+  - `mailbox`
+  - `response_type`
+
+璁捐鐗圭偣锛?
+- 鍗忚鐢辨彁渚涘晢鍥哄畾锛屼笉鍙兘涓哄綋鍓嶉」鐩崟鐙慨鏀?- 鎵╁睍搴旇鍋氣€滃崗璁€傞厤鈥濓紝鑰屼笉鏄鐢ㄦ埛鎵嬪~涓€鍫嗗簳灞傚弬鏁?
+### 2.2 PR #42锛氭湰鍦?helper 妯″紡
+
+宸茬‘璁ょ壒寰侊細
+
+- 鎵╁睍璇锋眰鏈湴 helper锛歚http://127.0.0.1:17373`
+- helper 鑴氭湰浣嶄簬锛?  - `scripts/hotmail_helper.py`
+  - `start-hotmail-helper.bat`
+- 鎵╁睍浣跨敤鐨勬牳蹇冩帴鍙f槸锛?  - `/messages`
+  - `/code`
+
+璁捐鐗圭偣锛?
+- 鎵╁睍鍜岄偖绠卞崗璁В鑰?- 鏈湴鑴氭湰鎵挎媴 token 鍒锋柊銆佸彇淇°€侀獙璇佺爜绛涢€?- 鏇撮€傚悎鏈湴鐢ㄦ埛鑷帶鐜
+
+## 3. 鏈€缁堣璁″彇鑸?
+### 3.1 鐩爣鏂规
+
+Hotmail 鍖烘敼鎴?*鍙屾ā寮忛€傞厤**锛?
+- `杩滅▼鏈嶅姟`
+- `鏈湴鍔╂墜`
+
+涓ょ妯″紡缁х画鍏辩敤鍚屼竴濂楄处鍙锋睜涓庢壒閲忓鍏ユ牸寮忥細
+
+```text
+璐﹀彿----瀵嗙爜----瀹㈡埛绔疘D----鍒锋柊浠ょ墝
+```
+
+### 3.2 涓嶉噰鐢ㄧ殑鏂规
+
+#### 涓嶉噰鐢ㄢ€滃井杞氦浜掓巿鏉冣€濅富鏂规
+
+鍘熷洜锛?
+- 涓嶉€傚悎鍑犲崄涓处鍙风殑鎵归噺浣跨敤鍦烘櫙
+- 浼氱牬鍧忕幇鏈夋壒閲忓鍏ラ摼璺?- 涓庡綋鍓嶇敤鎴烽渶姹傚啿绐?
+#### 涓嶉噰鐢ㄢ€滈€氱敤 API 鍙傛暟閰嶇疆鍣ㄢ€?
+鍘熷洜锛?
+- 杩滅▼鍗忚鍜屾湰鍦板崗璁湰灏变笉鍚?- 缁х画淇濈暀 `鍝嶅簲绫诲瀷 / 鏀朵欢绠卞弬鏁?/ 鍨冨溇绠卞弬鏁癭 浼氬鍔犻厤缃鏉傚害
+- 杩欎簺瀛楁涓嶅簲鎴愪负鏈€缁堢敤鎴风殑甯歌閰嶇疆鍏ュ彛
+
+## 4. 鐩爣浜や簰璁捐
+
+### 4.1 Hotmail 鍖虹晫闈㈢粨鏋?
+鍦ㄧ幇鏈?`Hotmail 璐﹀彿姹燻 鍖哄潡涓柊澧烇細
+
+1. `Hotmail 妯″紡` 鎸夐挳缁?   - `杩滅▼鏈嶅姟`
+   - `鏈湴鍔╂墜`
+
+2. `鏈嶅姟鍦板潃` 杈撳叆妗?   - 褰撴ā寮忎负 `杩滅▼鏈嶅姟` 鏃舵樉绀鸿繙绋嬫湇鍔″湴鍧€
+   - 褰撴ā寮忎负 `鏈湴鍔╂墜` 鏃舵樉绀烘湰鍦板姪鎵嬪湴鍧€
+
+3. 淇濈暀璐﹀彿姹犺〃鍗?   - 閭
+   - 瀹㈡埛绔?ID
+   - 閭瀵嗙爜澶囨敞
+   - 鍒锋柊浠ょ墝
+   - 娣诲姞璐﹀彿
+
+4. 淇濈暀鎵归噺瀵煎叆
+   - 浠嶇劧鏀寔 `璐﹀彿----瀵嗙爜----瀹㈡埛绔疘D----鍒锋柊浠ょ墝`
+
+5. 淇濈暀璐﹀彿鎿嶄綔
+   - 浣跨敤姝よ处鍙?   - 鏍囪宸茬敤 / 鏈敤
+   - 鏍¢獙
+   - 澶嶅埗鏈€鏂伴獙璇佺爜
+   - 鍒犻櫎
+
+### 4.2 瑕佸垹闄ょ殑鏃?UI
+
+鍒犻櫎褰撳墠绗笁鏂归厤缃噷杩欏嚑涓粏椤癸細
+
+- `Hotmail API 鍦板潃`
+- `鍝嶅簲绫诲瀷`
+- `鏀朵欢绠卞弬鏁癭
+- `鍨冨溇绠卞弬鏁癭
+
+鍘熷洜锛?
+- 杩欎簺灞炰簬鍗忚缁嗚妭锛屼笉閫傚悎鏅€氫娇鐢ㄨ€?- 鍙屾ā寮忔柟妗堥噷鍙渶瑕侀厤缃€滄湇鍔″湴鍧€鈥?
+## 5. 鍚庡彴璁捐
+
+### 5.1 鎸佷箙鍖栭厤缃」
+
+鏂板骞朵娇鐢ㄤ互涓嬫寔涔呭寲瀛楁锛?
+- `hotmailServiceMode`
+  - 鍊硷細`remote` / `local`
+- `hotmailRemoteBaseUrl`
+  - 榛樿锛氳繙绋嬫湇鍔℃枃妗f墍鍦ㄥ煙鍚?- `hotmailLocalBaseUrl`
+  - 榛樿锛歚http://127.0.0.1:17373`
+
+鍒犻櫎褰撳墠浠呮湇鍔′簬绗笁鏂归€氱敤閰嶇疆鍣ㄧ殑瀛楁锛?
+- `hotmailApiUrl`
+- `hotmailApiResponseType`
+- `hotmailApiInboxMailbox`
+- `hotmailApiJunkMailbox`
+
+### 5.2 璇锋眰閫傞厤灞?
+鏂板缁熶竴鐨勬ā寮忓垎鍙戝眰锛屼絾**涓嶇粺涓€搴曞眰鍗忚**銆?
+#### 杩滅▼妯″紡
+
+鐩存帴鍏煎 PR #10 鏂囨。鍗忚锛?
+- 璋冪敤杩滅▼ `baseUrl + /api/mail-new`
+- 鎵╁睍绔繚鎸佺幇鏈夆€滄媺娑堟伅鍚庢湰鍦扮瓫楠岃瘉鐮佲€濈殑妯″紡
+
+#### 鏈湴妯″紡
+
+鐩存帴鍏煎 PR #42 helper 鍗忚锛?
+- `/messages`
+- `/code`
+
+鎵╁睍绔€昏緫锛?
+- `鏍¢獙` / `娴嬭瘯鏀朵俊` 璧?`/messages`
+- 楠岃瘉鐮佽疆璇紭鍏堣蛋 `/code`
+
+### 5.3 闇€瑕佹柊澧炴垨璋冩暣鐨勫悗鍙板嚱鏁?
+1. 閰嶇疆褰掍竴鍖?   - `normalizeHotmailServiceMode`
+   - `normalizeHotmailRemoteBaseUrl`
+   - `normalizeHotmailLocalBaseUrl`
+   - `getHotmailServiceSettings`
+
+2. 杩滅▼妯″紡閫傞厤
+   - `requestHotmailRemoteMailbox`
+   - `fetchHotmailRemoteMessages`
+
+3. 鏈湴妯″紡閫傞厤
+   - `requestHotmailLocalMessages`
+   - `requestHotmailLocalCode`
+   - `fetchHotmailLocalMessages`
+
+4. 缁熶竴鍒嗗彂鍏ュ彛
+   - `fetchHotmailMailboxMessages`
+   - `pollHotmailVerificationCode`
+   - 鏍规嵁妯″紡鍒嗘祦
+
+## 6. 鏈湴 helper 鏂囦欢绛栫暐
+
+鐩存帴寮曞叆骞堕€傞厤 PR #42 宸查獙璇佺殑鏈湴鑴氭湰鏂规锛?
+- `scripts/hotmail_helper.py`
+- `start-hotmail-helper.bat`
+
+寮曞叆鍘熷垯锛?
+- 浼樺厛淇濇寔鍗忚鍏煎锛屼笉鍋氭棤蹇呰閲嶆瀯
+- 鍙帴鍏ュ綋鍓嶆墿灞曢渶瑕佺敤鍒扮殑鎺ュ彛涓庡惎鍔ㄦ柟寮?- 涓嶅湪鏈疆鎵╁睍寮€鍙戜腑缁х画寮曞叆涓?Hotmail 妯″紡鍒囨崲鏃犲叧鐨勫ぇ瑙勬ā鐘舵€侀€昏緫
+
+## 7. 涓庣幇鏈夐€昏緫鐨勫吋瀹硅姹?
+### 7.1 蹇呴』淇濇寔涓嶅彉鐨勮涓?
+- `Mail = Hotmail` 鏃朵粛鐒剁敱璐﹀彿姹犲垎閰嶉偖绠?- `璐﹀彿----瀵嗙爜----瀹㈡埛绔疘D----鍒锋柊浠ょ墝` 鎵归噺瀵煎叆鏍煎紡蹇呴』缁х画鍙敤
+- `鏍¢獙` / `澶嶅埗鏈€鏂伴獙璇佺爜` / `Auto` 鐨勭敤鎴峰叆鍙d笉鍙?- Hotmail 璐﹀彿鐨?`used / status / lastError / lastAuthAt / lastUsedAt` 閫昏緫淇濇寔鐜版湁璇箟
+
+### 7.2 蹇呴』閬垮厤鐨勯棶棰?
+- 涓嶈兘鎶婅繙绋嬪拰鏈湴寮鸿鎶芥垚涓€濂楀亣缁熶竴鍗忚
+- 涓嶈兘鎶婄涓夋柟鏈嶅姟瑕佹眰鏀规垚 `/messages` `/code`
+- 涓嶈兘鍒犻櫎鎵归噺瀵煎叆 refresh token 鐨勮兘鍔?- 涓嶈兘鍙敼 UI锛屼笉鎺ュ悗鍙板垎鍙?- 涓嶈兘鍙帴鍚庡彴锛屼笉鍚屾 README 涓庤鏄?
+## 8. 鏂规鑷涓庡畬鏁存€у垎鏋?
+### 8.1 鏂规鏄惁绗﹀悎闇€姹?
+绗﹀悎銆?
+鍘熷洜锛?
+- 淇濈暀鎵归噺瀵煎叆
+- 涓嶅己杩敤鎴疯蛋寰蒋浜や簰鎺堟潈
+- 鍚屾椂鏀寔宸查獙璇佺殑杩滅▼涓庢湰鍦颁袱绉嶈矾寰?
+### 8.2 鏂规鏄惁瀹屾暣
+
+瀹屾暣銆?
+宸茬粡瑕嗙洊锛?
+- 閰嶇疆妯″瀷
+- 鐣岄潰鍙樻洿
+- 鍚庡彴鍒嗗彂
+- helper 寮曞叆
+- 鏂囨。鏇存柊
+- 鑷椤?
+### 8.3 鏂规鏄惁瀛樺湪鍐呴儴鍐茬獊
+
+褰撳墠鏃犳槑鏄惧啿绐併€?
+鍞竴闇€瑕佷弗鏍兼帶鍒剁殑鏄細
+
+- 杩滅▼妯″紡涓庢湰鍦版ā寮忚櫧鐒跺叆鍙g粺涓€锛屼絾鍗忚涓嶈兘娣风敤
+- 瀹炵幇鏃跺繀椤绘槑纭垎灞傦紝涓嶈鍋氭垚鈥滄牴鎹厤缃嫾涓嶅悓 query 鍙傛暟鈥濈殑鏉傜硡閫昏緫
+
+### 8.4 鏂规娼滃湪缂洪櫡
+
+1. 鏈湴 helper 鍙兘渚濊禆 Python 鐜
+   - 闇€瑕佸湪 README 涓槑纭鏄庡浣曞惎鍔?
+2. 杩滅▼鏈嶅姟鏂囨。鑻ユ湭鏉ュ彉鏇?   - 褰撳墠浠嶄緷璧栨湇鍔″晢鎺ュ彛绋冲畾鎬?
+3. 褰撳墠浠撳簱鏄棫椤圭洰
+   - Hotmail 閫昏緫鍛ㄨ竟宸叉湁杈冨鐘舵€佸瓧娈?   - 瀹炵幇鏃跺繀椤昏皑鎱庢鏌?`verify/test/poll/auto-run` 鍥涙潯閾炬槸鍚︾粺涓€鎸夋ā寮忓垎鍙?
+## 9. 寮€鍙戞竻鍗?
+### 闃舵 1锛氶厤缃ā鍨嬩笌鏂规钀界洏
+
+- 鏂板缓鏈柟妗堟枃妗?- 鏂板 Hotmail 鍙屾ā寮忔寔涔呭寲瀛楁
+- 鍒犻櫎鏃х殑绗笁鏂瑰崗璁粏椤瑰瓧娈?
+鑷锛?
+- 閰嶇疆椤瑰懡鍚嶆槸鍚︾粺涓€
+- 榛樿鍊兼槸鍚﹀畬鏁?- import/export 鏄惁浠嶅彲宸ヤ綔
+
+### 闃舵 2锛欻otmail UI 鏀归€?
+- 鏂板 `杩滅▼鏈嶅姟 / 鏈湴鍔╂墜` 妯″紡鎸夐挳
+- 鏂板 `鏈嶅姟鍦板潃` 杈撳叆妗?- 鍒犻櫎鏃х殑 `鍝嶅簲绫诲瀷 / 鏀朵欢绠卞弬鏁?/ 鍨冨溇绠卞弬鏁癭 杈撳叆妗?- 淇濈暀璐﹀彿姹犺〃鍗曚笌鎵归噺瀵煎叆
+
+鑷锛?
+- 鍒囨崲妯″紡鍚庢樉绀烘槸鍚︽纭?- 鑷姩淇濆瓨鏄惁浠嶆甯?- 涓嶅悓妯″紡鐨勫湴鍧€鏄惁涓嶄細浜掔浉瑕嗙洊
+
+### 闃舵 3锛氬悗鍙板弻妯″紡閫傞厤
+
+- 瀹炵幇杩滅▼妯″紡璇锋眰閫傞厤
+- 瀹炵幇鏈湴妯″紡 helper 璇锋眰閫傞厤
+- 淇敼 `fetchHotmailMailboxMessages`
+- 淇敼 `pollHotmailVerificationCode`
+- 鏍¢獙 `verify / test / auto-run` 涓夋潯閾?
+鑷锛?
+- 杩滅▼妯″紡鏄惁浠嶅吋瀹?`#10`
+- 鏈湴妯″紡鏄惁鍏煎 `#42`
+- 閿欒淇℃伅鏄惁浠嶄細鍐欏洖 `lastError`
+
+### 闃舵 4锛氬紩鍏ユ湰鍦?helper
+
+- 寮曞叆 `scripts/hotmail_helper.py`
+- 寮曞叆 `start-hotmail-helper.bat`
+- 妫€鏌ユ湰鍦板崗璁笌鎵╁睍璋冪敤涓€鑷?
+鑷锛?
+- helper 璺緞鏄惁姝g‘
+- 绔彛涓庢枃妗f槸鍚︿竴鑷?- 鎵╁睍璇锋眰璺緞鏄惁鍖归厤 helper
+
+### 闃舵 5锛氭枃妗f敹灏?
+- 鏇存柊 README 涓?Hotmail 浣跨敤璇存槑
+- 鏄庣‘杩滅▼妯″紡涓庢湰鍦版ā寮忕殑鍖哄埆
+- 鏄庣‘鏈湴 helper 鍚姩鏂瑰紡
+
+鑷锛?
+- README 鏄惁鍜岀湡瀹?UI 涓€鑷?- 鐢ㄦ埛鏄惁鑳界湅鏂囨。瀹屾垚閰嶇疆
+
+## 10. 瀹炴柦椤哄簭
+
+鏈疆寮€鍙戜弗鏍兼寜浠ヤ笅椤哄簭鎵ц锛?
+1. 鍏堝啓鏈柟妗堟枃妗?2. 鍐嶆敼閰嶇疆妯″瀷
+3. 鍐嶆敼 UI
+4. 鍐嶆敼鍚庡彴鍙屾ā寮忓垎鍙?5. 鍐嶅紩鍏ユ湰鍦?helper
+6. 鏈€鍚庣粺涓€鍋氶潤鎬佽嚜妫€骞舵彁閱掔敤鎴锋墜娴?
+## 11. 瀹為檯寮€鍙戣褰?
+### 11.1 宸插畬鎴愭敼鍔?
+鏈疆宸插疄闄呭畬鎴愪互涓嬪紑鍙戝唴瀹癸細
+
+1. Hotmail 閰嶇疆妯″瀷浠庘€滅涓夋柟閫氱敤缁嗛」閰嶇疆鈥濆垏鎹负鈥滃弻妯″紡 + 鍦板潃閰嶇疆鈥?   - 鍒犻櫎鍘熸湰闈㈠悜绗笁鏂圭粏椤圭殑鎬濊矾锛?     - `hotmailApiUrl`
+     - `hotmailApiResponseType`
+     - `hotmailApiInboxMailbox`
+     - `hotmailApiJunkMailbox`
+   - 鏀逛负锛?     - `hotmailServiceMode`
+     - `hotmailRemoteBaseUrl`
+     - `hotmailLocalBaseUrl`
+
+2. Sidepanel Hotmail 鍖哄凡鏀归€犳垚鍙屾ā寮?UI
+   - 鏂板 `杩滅▼鏈嶅姟 / 鏈湴鍔╂墜` 鎸夐挳缁?   - 鏂板杩滅▼鏈嶅姟鍦板潃杈撳叆妗?   - 鏂板鏈湴鍔╂墜鍦板潃杈撳叆妗?   - 淇濈暀鍘熸湁璐﹀彿姹犺〃鍗曞拰鎵归噺瀵煎叆鏍煎紡
+
+3. 鍚庡彴 Hotmail 璇锋眰閾惧凡鎸夋ā寮忓垎娴?   - 杩滅▼妯″紡锛?     - 鐩存帴鍏煎 `#10`
+     - 浣跨敤 `baseUrl + /api/mail-new`
+   - 鏈湴妯″紡锛?     - 鐩存帴鍏煎 `#42`
+     - 浣跨敤 `/messages`
+     - 浣跨敤 `/code`
+
+4. 鏈湴 helper 鏂囦欢宸茶惤鍦?   - `scripts/hotmail_helper.py`
+   - `start-hotmail-helper.bat`
+
+5. README 宸插悓姝ユ洿鏂颁负鍙屾ā寮忚鏄?
+### 11.2 瀹為檯淇敼鏂囦欢
+
+鏈疆瀹為檯鏀瑰姩鏂囦欢濡備笅锛?
+- `background.js`
+- `sidepanel/sidepanel.html`
+- `sidepanel/sidepanel.js`
+- `README.md`
+- `scripts/hotmail_helper.py`
+- `start-hotmail-helper.bat`
+
+鏈疆鏂板浣嗕笉涓€瀹氳繘鍏?Git 鐨勬枃妗o細
+
+- `docs/md/Hotmail鍙屾ā寮忛€傞厤寮€鍙戞柟妗?md`
+
+### 11.3 瀹為檯瀹炵幇涓庡師鏂规鐨勫樊寮?
+褰撳墠瀹炵幇涓庡師鏂规鐩告瘮锛屽瓨鍦ㄨ繖浜涘疄闄呭樊寮傦細
+
+1. 鏈湴 helper 娌℃湁鏁翠唤鐓ф惉 `#42`
+   - 褰撳墠鏄寜 `#42` 鐨勫崗璁€濊矾钀戒簡涓€涓畝鍖栫増
+   - 鐩爣鏄厛婊¤冻鎵╁睍鐜伴樁娈电殑 `/messages` 涓?`/code`
+   - 娌℃湁缁х画寮曞叆 `#42` 閲屾洿閲嶇殑澶辫触鐘舵€併€佹寔涔呭凡鐢ㄩ偖绠便€佸苟鍙戜繚鎶ょ瓑鏁村鎵╁睍閫昏緫
+
+2. 杩滅▼妯″紡鐩墠浠嶆部鐢ㄦ墿灞曠鏈湴绛涢獙璇佺爜
+   - 鍏煎 `#10` 鐨勫崟鎺ュ彛 `/api/mail-new`
+   - 鏈澶栧鎺?`mail-all`銆乣process-inbox`銆乣process-junk`
+
+3. 娴嬭瘯鏂囦欢鏈疆娌℃湁缁х画琛ラ綈
+   - 褰撳墠涓昏瀹屾垚鐨勬槸鏂规钀藉湴鍜屼富閾炬敼閫?   - 灏氭湭琛ラ拡瀵瑰弻妯″紡鐨勬渶灏忓洖褰掓祴璇曟々
+
+### 11.4 褰撳墠宸茬煡鏈畬鎴愰」
+
+浠ヤ笅鍐呭褰撳墠浠嶆湭瀹屾垚鎴栨湭楠岃瘉锛?
+1. 鏈ˉ Hotmail 鍙屾ā寮忓搴旂殑娴嬭瘯妗?   - 杩滅▼妯″紡楠岃瘉閫昏緫
+   - 鏈湴妯″紡 `/messages` 涓?`/code` 鍒嗗彂閫昏緫
+   - 妯″紡鍒囨崲鍚庣殑 UI 鐘舵€佸洖濉?
+2. 鏈 helper 鍋氳繍琛岀骇瀹炴祴
+   - 褰撳墠鍙畬鎴愪唬鐮佹帴鍏?   - 灏氭湭纭鏈満 Python 鐜涓?`imaplib + XOAUTH2 + login.live.com/oauth20_token.srf` 鏄惁鑳界ǔ瀹氬伐浣?
+3. 鏈獙璇佽繙绋嬫湇鍔″湴鍧€鐨勪笉鍚岃緭鍏ュ舰寮?   - 渚嬪鐢ㄦ埛濉細
+     - 鏍瑰煙鍚?     - `/api.html`
+     - `/api/mail-new`
+   - 鐩墠浠g爜鍋氫簡璺緞褰掍竴鍖栵紝浣嗛渶瑕佷汉宸ユ墜娴嬬‘璁?
+4. 鏈ˉ README 涓洿缁嗙殑鏈湴 helper 鍚姩鎺掗敊璇存槑
+   - 褰撳墠鍙啓浜嗗叆鍙h鏄?   - 杩樻病鍐欌€滃惎鍔ㄥけ璐ユ€庝箞鐪嬨€佺鍙e崰鐢ㄦ€庝箞鍔炪€丳ython 涓嶅瓨鍦ㄦ€庝箞鍔炩€?
+### 11.5 褰撳墠鎵嬫祴閲嶇偣
+
+鍚庣画浼樺厛鎵嬫祴浠ヤ笅鍐呭锛?
+1. 杩滅▼妯″紡
+   - `Mail = Hotmail`
+   - 妯″紡鍒囧埌 `杩滅▼鏈嶅姟`
+   - 鍦板潃濉?`https://apple.882263.xyz`
+   - 娴嬶細
+     - `鏍¢獙`
+     - `澶嶅埗鏈€鏂伴獙璇佺爜`
+     - `Auto`
+
+2. 鏈湴妯″紡
+   - 鍏堣繍琛?`start-hotmail-helper.bat`
+   - 妯″紡鍒囧埌 `鏈湴鍔╂墜`
+   - 鍦板潃淇濇寔 `http://127.0.0.1:17373`
+   - 娴嬶細
+     - `鏍¢獙`
+     - `澶嶅埗鏈€鏂伴獙璇佺爜`
+     - `Auto`
+
+3. 妯″紡鍒囨崲
+   - 浠?`杩滅▼鏈嶅姟` 鍒囧埌 `鏈湴鍔╂墜`
+   - 浠?`鏈湴鍔╂墜` 鍒囧埌 `杩滅▼鏈嶅姟`
+   - 鐪嬪湴鍧€鏄惁鍒嗗埆淇濈暀
+   - 鐪?Hotmail 璐﹀彿姹犳槸鍚︿笉鍙楀奖鍝?
+4. 鎵归噺瀵煎叆
+   - 瀵煎叆鏍煎紡锛?     - `璐﹀彿----瀵嗙爜----瀹㈡埛绔疘D----鍒锋柊浠ょ墝`
+   - 妫€鏌ユā寮忓垏鎹㈠悗瀵煎叆鑳藉姏鏄惁浠嶆甯?
+### 11.6 褰撳墠椋庨櫓缁撹
+
+褰撳墠鏈€澶ч闄╀笉鍦?UI锛岃€屽湪杩欎袱涓偣锛?
+1. 鏈湴 helper 鐨勭湡瀹炲彲杩愯鎬?   - 浠g爜宸茬粡鎺ヤ笂
+   - 浣嗘湭缁忓疄闄呰繍琛岄獙璇?
+2. 杩滅▼涓庢湰鍦扮殑杩斿洖缁撴瀯鍏煎鎬?   - 杩滅▼妯″紡璧?`#10` 椋庢牸
+   - 鏈湴妯″紡璧?`#42` 椋庢牸
+   - 铏界劧鍚庡彴宸茬粡鍋氫簡閫傞厤锛屼絾浠嶉渶瑕佸疄闄呰繑鍥炴牱鏈獙璇?
+### 11.7 褰撳墠闃舵缁撹
+
+褰撳墠鐘舵€佸彲浠ョ悊瑙d负锛?
+- 鏂规宸茶惤鐩?- 鍙屾ā寮忎富缁撴瀯宸插紑鍙戝畬鎴?- 杩滅▼ / 鏈湴涓ゆ潯鍗忚宸叉帴鍏?- 璐﹀彿姹犱笌鎵归噺瀵煎叆浠嶄繚鐣?- 浣嗗皻鏈粡杩囩湡瀹炶繍琛岄獙璇?
+鍥犳鏈疆寮€鍙戠粨鏋滃睘浜庯細
+
+- **浠g爜缁撴瀯宸插埌浣?*
+- **杩愯绋冲畾鎬у緟浜哄伐楠岃瘉**
+
+## 12. 本地 helper 运行说明补充
+
+### 12.1 启动命令
+
+Windows:
+
+```powershell
+.\start-hotmail-helper.bat
+```
+
+macOS:
+
+```bash
+chmod +x ./start-hotmail-helper.command
+./start-hotmail-helper.command
+```
+
+如果不使用启动脚本,也可以直接运行:
+
+```bash
+python scripts/hotmail_helper.py
+```
+
+或:
+
+```bash
+python3 scripts/hotmail_helper.py
+```
+
+### 12.2 启动成功标志
+
+启动成功后,终端应输出:
+
+```text
+Hotmail helper listening on http://127.0.0.1:17373
+```
+
+### 12.3 最小排错说明
+
+- 若提示 `Python 3 not found`,说明当前机器没有可用的 Python 3.10+。
+- 若 helper 已启动但扩展仍连不上,先确认 Hotmail 模式切到了 `本地助手`。
+- 再确认侧边栏中的本地助手地址与终端输出一致,默认应为 `http://127.0.0.1:17373`。
+- 若地址一致仍失败,再检查终端中是否已经打印异常,或是否有端口占用。

+ 60 - 4
README.md

@@ -52,7 +52,7 @@
 - 支持自定义密码;留空时自动生成强密码
 - 自动显示当前使用中的密码,便于后续保存
 - 自动获取注册验证码与登录验证码
-- 支持 `Hotmail`:直接使用 `邮箱 + 客户端 ID + 刷新令牌(refresh token)` 刷新微软令牌,并通过 Microsoft Graph 读取最新邮件
+- 支持 `Hotmail`:继续使用 `邮箱 + 客户端 ID + 刷新令牌(refresh token)`,并可在远程服务与本地助手两种模式间切换
 - 支持 `QQ Mail`、`163 Mail`、`Inbucket mailbox`
 - 支持从 DuckDuckGo Email Protection 自动生成新的 `@duck.com` 地址
 - 支持基于 Cloudflare 自定义域名自动生成随机邮箱前缀
@@ -137,7 +137,7 @@ Step 1 和 Step 9 都依赖这个地址。
 
 说明:
 
-- `Hotmail` 通过侧边栏里的 Hotmail 账号池选择账号,并直接访问 Microsoft Graph 邮件接口
+- `Hotmail` 通过侧边栏里的 Hotmail 账号池选择账号,可切换为远程服务模式或本地助手模式
 - `QQ`、`163`、`163 VIP` 用于直接轮询网页邮箱
 - `Inbucket` 通过你在侧边栏里配置的 host 访问 `mailbox` 页面:`https://<your-inbucket-host>/m/<mailbox>/`
 
@@ -145,6 +145,12 @@ Step 1 和 Step 9 都依赖这个地址。
 
 仅当 `Mail = Hotmail` 时使用。
 
+可配置项:
+
+- `接码模式`
+- `远程服务地址`
+- `本地助手地址`
+
 每条账号支持保存:
 
 - `email`
@@ -154,11 +160,61 @@ Step 1 和 Step 9 都依赖这个地址。
 
 使用方式:
 
-- 先新增账号
+- 先选择 Hotmail 接码模式
+- 远程模式下填写你自己的远程服务地址
+- 本地模式下填写本地助手地址(默认 `http://127.0.0.1:17373`)
+- Windows 运行仓库根目录的 `start-hotmail-helper.bat`
+- macOS 运行仓库根目录的 `start-hotmail-helper.command`
+- 本地 helper 当前仅依赖 Python 标准库,无需额外安装第三方 Python 包
+- 再新增账号
 - 点击 `校验`
 - 校验通过后,可点击 `测试收信`
 - Auto 模式每轮会自动选用一个可用账号
 
+#### 本地 helper 启动命令
+
+Windows:
+
+```powershell
+.\start-hotmail-helper.bat
+```
+
+macOS:
+
+```bash
+chmod +x ./start-hotmail-helper.command
+./start-hotmail-helper.command
+```
+
+如果你不想走启动脚本,也可以直接运行 Python 程序本体:
+
+```bash
+python scripts/hotmail_helper.py
+```
+
+如果你的环境里命令是 `python3`:
+
+```bash
+python3 scripts/hotmail_helper.py
+```
+
+#### 启动成功标志
+
+本地 helper 启动成功后,终端会输出:
+
+```text
+Hotmail helper listening on http://127.0.0.1:17373
+```
+
+看到这行再回到扩展里点 `校验` 或 `复制最新验证码`。
+
+#### 最小排错说明
+
+- 如果提示 `Python 3 not found`,先安装 Python 3.10+
+- 如果 helper 已启动但扩展仍报连接失败,先确认模式切到了 `本地助手`
+- 确认本地助手地址与终端输出一致,默认应为 `http://127.0.0.1:17373`
+- 如果地址一致仍失败,再检查是否有端口占用或终端里是否已经抛出异常
+
 ### `Mailbox`
 
 仅当 `Mail = Inbucket` 时显示。
@@ -411,7 +467,7 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。
 
 支持:
 
-- `Hotmail`(Microsoft Graph 邮件接口
+- `Hotmail`(远程服务 / 本地助手
 - `content/qq-mail.js`
 - `content/mail-163.js`
 - `content/inbucket-mail.js`

+ 258 - 143
background.js

@@ -3,11 +3,11 @@
 importScripts('data/names.js', 'hotmail-utils.js', 'content/activation-utils.js');
 
 const {
-  buildHotmailGraphMessagesUrl,
+  buildHotmailMailApiLatestUrl,
   extractVerificationCodeFromMessage,
   filterHotmailAccountsByUsage,
   getLatestHotmailMessage,
-  getHotmailGraphRequestConfig,
+  getHotmailMailApiRequestConfig,
   getHotmailVerificationPollConfig,
   getHotmailVerificationRequestTimestamp,
   normalizeHotmailMailApiMessages,
@@ -20,8 +20,6 @@ const {
 const {
   isRecoverableStep9AuthFailure,
 } = self.MultiPageActivationUtils;
-const buildHotmailMailApiLatestUrl = buildHotmailGraphMessagesUrl;
-const getHotmailMailApiRequestConfig = getHotmailGraphRequestConfig;
 
 const LOG_PREFIX = '[MultiPage:bg]';
 const DUCK_AUTOFILL_URL = 'https://duckduckgo.com/email/settings/autofill';
@@ -43,6 +41,11 @@ const AUTO_STEP_DELAY_MIN_ALLOWED_SECONDS = 0;
 const AUTO_STEP_DELAY_MAX_ALLOWED_SECONDS = 600;
 const LEGACY_AUTO_STEP_DELAY_KEYS = ['autoStepRandomDelayMinSeconds', 'autoStepRandomDelayMaxSeconds'];
 const DEFAULT_LOCAL_CPA_STEP9_MODE = 'submit';
+const HOTMAIL_SERVICE_MODE_REMOTE = 'remote';
+const HOTMAIL_SERVICE_MODE_LOCAL = 'local';
+const DEFAULT_HOTMAIL_REMOTE_BASE_URL = '';
+const DEFAULT_HOTMAIL_LOCAL_BASE_URL = 'http://127.0.0.1:17373';
+const HOTMAIL_LOCAL_HELPER_TIMEOUT_MS = 45000;
 
 initializeSessionStorageAccess();
 
@@ -70,6 +73,9 @@ const PERSISTED_SETTING_DEFAULTS = {
   emailPrefix: '',
   inbucketHost: '',
   inbucketMailbox: '',
+  hotmailServiceMode: HOTMAIL_SERVICE_MODE_LOCAL,
+  hotmailRemoteBaseUrl: DEFAULT_HOTMAIL_REMOTE_BASE_URL,
+  hotmailLocalBaseUrl: DEFAULT_HOTMAIL_LOCAL_BASE_URL,
   cloudflareDomain: '',
   cloudflareDomains: [],
   hotmailAccounts: [],
@@ -253,6 +259,62 @@ function normalizeCloudflareDomains(values) {
   return normalizedDomains;
 }
 
+function normalizeHotmailServiceMode(rawValue = '') {
+  return HOTMAIL_SERVICE_MODE_LOCAL;
+}
+
+function normalizeHotmailRemoteBaseUrl(rawValue = '') {
+  const value = String(rawValue || '').trim();
+  if (!value) return DEFAULT_HOTMAIL_REMOTE_BASE_URL;
+
+  try {
+    const parsed = new URL(value);
+    if (!['http:', 'https:'].includes(parsed.protocol)) {
+      return DEFAULT_HOTMAIL_REMOTE_BASE_URL;
+    }
+
+    if (parsed.pathname.endsWith('/api/mail-new') || parsed.pathname.endsWith('/api/mail-all') || parsed.pathname === '/api.html') {
+      parsed.pathname = '';
+      parsed.search = '';
+      parsed.hash = '';
+    }
+
+    return parsed.toString().replace(/\/$/, '');
+  } catch {
+    return DEFAULT_HOTMAIL_REMOTE_BASE_URL;
+  }
+}
+
+function normalizeHotmailLocalBaseUrl(rawValue = '') {
+  const value = String(rawValue || '').trim();
+  if (!value) return DEFAULT_HOTMAIL_LOCAL_BASE_URL;
+
+  try {
+    const parsed = new URL(value);
+    if (!['http:', 'https:'].includes(parsed.protocol)) {
+      return DEFAULT_HOTMAIL_LOCAL_BASE_URL;
+    }
+
+    if (['/messages', '/code', '/clear', '/token'].includes(parsed.pathname)) {
+      parsed.pathname = '';
+      parsed.search = '';
+      parsed.hash = '';
+    }
+
+    return parsed.toString().replace(/\/$/, '');
+  } catch {
+    return DEFAULT_HOTMAIL_LOCAL_BASE_URL;
+  }
+}
+
+function getHotmailServiceSettings(state = {}) {
+  return {
+    mode: normalizeHotmailServiceMode(state.hotmailServiceMode),
+    remoteBaseUrl: normalizeHotmailRemoteBaseUrl(state.hotmailRemoteBaseUrl),
+    localBaseUrl: normalizeHotmailLocalBaseUrl(state.hotmailLocalBaseUrl),
+  };
+}
+
 function normalizePersistentSettingValue(key, value) {
   switch (key) {
     case 'panelMode':
@@ -292,6 +354,12 @@ function normalizePersistentSettingValue(key, value) {
       return String(value || '').trim();
     case 'inbucketMailbox':
       return String(value || '').trim();
+    case 'hotmailServiceMode':
+      return normalizeHotmailServiceMode(value);
+    case 'hotmailRemoteBaseUrl':
+      return normalizeHotmailRemoteBaseUrl(value);
+    case 'hotmailLocalBaseUrl':
+      return normalizeHotmailLocalBaseUrl(value);
     case 'cloudflareDomain':
       return normalizeCloudflareDomain(value);
     case 'cloudflareDomains':
@@ -523,16 +591,14 @@ function normalizeHotmailAccount(account = {}) {
   const normalizedLastAuthAt = Number.isFinite(Number(account.lastAuthAt)) ? Number(account.lastAuthAt) : 0;
   const normalizedStatus = String(
     account.status
-    || (normalizedLastAuthAt > 0 || account.accessToken ? 'authorized' : 'pending')
+    || (normalizedLastAuthAt > 0 ? 'authorized' : 'pending')
   );
   return {
     id: String(account.id || crypto.randomUUID()),
     email: String(account.email || '').trim(),
     password: String(account.password || ''),
     clientId: String(account.clientId || '').trim(),
-    accessToken: String(account.accessToken || ''),
     refreshToken: String(account.refreshToken || ''),
-    expiresAt: Number.isFinite(Number(account.expiresAt)) ? Number(account.expiresAt) : 0,
     status: normalizedStatus,
     enabled: account.enabled !== undefined ? Boolean(account.enabled) : true,
     used: Boolean(account.used),
@@ -587,8 +653,6 @@ async function upsertHotmailAccount(input) {
   const normalized = normalizeHotmailAccount({
     ...(existing || {}),
     ...(credentialsChanged ? {
-      accessToken: '',
-      expiresAt: 0,
       status: 'pending',
       lastAuthAt: 0,
       lastError: '',
@@ -724,7 +788,17 @@ async function ensureHotmailAccountForFlow(options = {}) {
   return setCurrentHotmailAccount(account.id, { markUsed, syncEmail: true });
 }
 
-async function requestHotmailMailApiLegacy(account, mailbox = 'INBOX') {
+function buildHotmailRemoteEndpoint(baseUrl, path) {
+  const normalizedBaseUrl = normalizeHotmailRemoteBaseUrl(baseUrl);
+  return new URL(path, `${normalizedBaseUrl}/`).toString();
+}
+
+function buildHotmailLocalEndpoint(baseUrl, path) {
+  const normalizedBaseUrl = normalizeHotmailLocalBaseUrl(baseUrl);
+  return new URL(path, `${normalizedBaseUrl}/`).toString();
+}
+
+async function requestHotmailRemoteMailbox(account, mailbox = 'INBOX') {
   if (!account?.email) {
     throw new Error('Hotmail 账号缺少邮箱地址。');
   }
@@ -735,7 +809,9 @@ async function requestHotmailMailApiLegacy(account, mailbox = 'INBOX') {
     throw new Error(`Hotmail 账号 ${account.email || account.id} 缺少刷新令牌(refresh token)。`);
   }
 
+  const serviceSettings = getHotmailServiceSettings(await getState());
   const url = buildHotmailMailApiLatestUrl({
+    apiUrl: buildHotmailRemoteEndpoint(serviceSettings.remoteBaseUrl, '/api/mail-new'),
     clientId: account.clientId,
     email: account.email,
     refreshToken: account.refreshToken,
@@ -784,12 +860,10 @@ async function requestHotmailMailApiLegacy(account, mailbox = 'INBOX') {
   };
 }
 
-function applyHotmailApiResultToAccountLegacy(account, apiResult) {
+function applyHotmailApiResultToAccount(account, apiResult) {
   const nextRefreshToken = String(apiResult?.nextRefreshToken || '').trim();
   return {
     ...account,
-    accessToken: '',
-    expiresAt: 0,
     refreshToken: nextRefreshToken || account.refreshToken,
     status: 'authorized',
     lastAuthAt: Date.now(),
@@ -797,18 +871,32 @@ function applyHotmailApiResultToAccountLegacy(account, apiResult) {
   };
 }
 
-async function fetchHotmailMailboxMessagesLegacy(account, mailboxes = HOTMAIL_MAILBOXES) {
+function buildHotmailMailApiFailureAccount(account, errorMessage) {
+  return normalizeHotmailAccount({
+    ...account,
+    status: 'error',
+    lastError: String(errorMessage || ''),
+  });
+}
+
+async function fetchHotmailMailboxMessagesFromRemoteService(account, mailboxes = HOTMAIL_MAILBOXES) {
   let workingAccount = normalizeHotmailAccount(account);
   const mailboxResults = [];
 
-  for (const mailbox of mailboxes) {
-    const result = await requestHotmailMailApiLegacy(workingAccount, mailbox);
-    workingAccount = applyHotmailApiResultToAccountLegacy(workingAccount, result);
-    mailboxResults.push({
-      mailbox,
-      count: result.messages.length,
-      messages: result.messages.map((message) => ({ ...message, mailbox })),
-    });
+  try {
+    for (const mailbox of mailboxes) {
+      const result = await requestHotmailRemoteMailbox(workingAccount, mailbox);
+      workingAccount = applyHotmailApiResultToAccount(workingAccount, result);
+      mailboxResults.push({
+        mailbox,
+        count: result.messages.length,
+        messages: result.messages.map((message) => ({ ...message, mailbox })),
+      });
+    }
+  } catch (err) {
+    const failedAccount = buildHotmailMailApiFailureAccount(workingAccount, err.message);
+    await upsertHotmailAccount(failedAccount);
+    throw err;
   }
 
   const savedAccount = await upsertHotmailAccount(workingAccount);
@@ -819,12 +907,7 @@ async function fetchHotmailMailboxMessagesLegacy(account, mailboxes = HOTMAIL_MA
   };
 }
 
-function isHotmailAccessTokenUsable(account, now = Date.now()) {
-  return Boolean(account?.accessToken)
-    && Number(account?.expiresAt || 0) > now + 60_000;
-}
-
-async function refreshHotmailAccessToken(account) {
+async function requestHotmailLocalMessages(account, mailboxes = HOTMAIL_MAILBOXES) {
   if (!account?.email) {
     throw new Error('Hotmail 账号缺少邮箱地址。');
   }
@@ -835,34 +918,34 @@ async function refreshHotmailAccessToken(account) {
     throw new Error(`Hotmail 账号 ${account.email || account.id} 缺少刷新令牌(refresh token)。`);
   }
 
-  const { timeoutMs, scopes, tokenUrl } = getHotmailGraphRequestConfig();
+  const serviceSettings = getHotmailServiceSettings(await getState());
+  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')), timeoutMs);
-  const formData = new URLSearchParams();
-  formData.set('client_id', account.clientId);
-  formData.set('grant_type', 'refresh_token');
-  formData.set('refresh_token', account.refreshToken);
-  formData.set('scope', scopes.join(' '));
-  formData.set('redirect_uri', 'https://login.microsoftonline.com/common/oauth2/nativeclient');
+  const timeoutId = setTimeout(() => controller.abort(new Error('timeout')), requestTimeoutMs);
 
   let response;
   try {
-    response = await fetch(tokenUrl, {
+    response = await fetch(buildHotmailLocalEndpoint(serviceSettings.localBaseUrl, '/messages'), {
       method: 'POST',
       headers: {
-        'Content-Type': 'application/x-www-form-urlencoded',
+        'Content-Type': 'application/json',
+        Accept: 'application/json',
       },
-      body: formData.toString(),
+      body: JSON.stringify({
+        email: account.email,
+        clientId: account.clientId,
+        refreshToken: account.refreshToken,
+        mailboxes,
+        top: 5,
+      }),
       signal: controller.signal,
     });
   } catch (err) {
-    const error = new Error(
-      err?.name === 'AbortError'
-        ? `Hotmail 令牌刷新超时(>${Math.round(timeoutMs / 1000)} 秒)`
-        : `Hotmail 令牌刷新失败:${err.message}`
-    );
-    error.code = 'HOTMAIL_TOKEN_REFRESH_FAILED';
-    throw error;
+    if (err?.name === 'AbortError') {
+      throw new Error(`Hotmail 本地助手请求超时(>${Math.round(requestTimeoutMs / 1000)} 秒)`);
+    }
+    throw new Error(`Hotmail 本地助手请求失败:${err.message}`);
   } finally {
     clearTimeout(timeoutId);
   }
@@ -875,57 +958,83 @@ async function refreshHotmailAccessToken(account) {
     payload = { raw: text };
   }
 
-  if (!response.ok || !payload?.access_token) {
-    const rawErrorText = payload?.error_description || payload?.error?.message || payload?.error || payload?.message || text || `HTTP ${response.status}`;
-    const isCrossOriginError = typeof rawErrorText === 'string' && rawErrorText.includes('AADSTS90023');
-    const errorText = isCrossOriginError
-      ? `Azure AD 拒绝了跨域令牌请求(AADSTS90023)。请在 Azure AD 应用注册中将应用平台改为"单页应用程序(SPA)",并将重定向 URI 设置为 https://login.microsoftonline.com/common/oauth2/nativeclient,或将应用类型改为"移动和桌面应用程序(Native)"。`
-      : rawErrorText;
-    const error = new Error(`Hotmail 令牌刷新失败:${errorText}`);
-    error.code = 'HOTMAIL_TOKEN_REFRESH_FAILED';
-    throw error;
+  if (!response.ok || payload?.ok === false) {
+    const errorText = payload?.error || payload?.message || text || `HTTP ${response.status}`;
+    throw new Error(`Hotmail 本地助手返回失败:${errorText}`);
   }
 
-  const expiresInSeconds = Math.max(60, Number(payload.expires_in || payload.expiresIn || 0) || 3600);
-  return normalizeHotmailAccount({
-    ...account,
-    accessToken: String(payload.access_token || ''),
-    refreshToken: String(payload.refresh_token || '').trim() || account.refreshToken,
-    expiresAt: Date.now() + expiresInSeconds * 1000,
-    status: 'authorized',
-    lastAuthAt: Date.now(),
-    lastError: '',
+  const rawMessages = Array.isArray(payload?.messages) ? payload.messages : [];
+  const normalizedMessages = normalizeHotmailMailApiMessages(rawMessages).map((message, index) => ({
+    ...message,
+    mailbox: rawMessages[index]?.mailbox || 'INBOX',
+    receivedTimestamp: Number(rawMessages[index]?.receivedTimestamp || 0) || 0,
+  }));
+  const mailboxResults = Array.isArray(payload?.mailboxResults)
+    ? payload.mailboxResults.map((item) => ({
+      mailbox: String(item?.mailbox || 'INBOX'),
+      count: Number(item?.count || 0),
+      messages: normalizedMessages.filter((message) => String(message.mailbox || 'INBOX') === String(item?.mailbox || 'INBOX')),
+    }))
+    : mailboxes.map((mailbox) => ({
+      mailbox,
+      count: normalizedMessages.filter((message) => String(message.mailbox || 'INBOX') === mailbox).length,
+      messages: normalizedMessages.filter((message) => String(message.mailbox || 'INBOX') === mailbox),
+    }));
+
+  const nextAccount = applyHotmailApiResultToAccount(account, {
+    nextRefreshToken: String(payload?.nextRefreshToken || '').trim(),
   });
+  const savedAccount = await upsertHotmailAccount(nextAccount);
+  return {
+    account: savedAccount,
+    mailboxResults,
+    messages: normalizedMessages,
+  };
 }
 
-async function requestHotmailGraphMessages(account, mailbox = 'INBOX') {
-  const { timeoutMs, pageSize, messageFields } = getHotmailGraphRequestConfig();
+async function requestHotmailLocalCode(account, pollPayload = {}) {
+  if (!account?.email) {
+    throw new Error('Hotmail 账号缺少邮箱地址。');
+  }
+  if (!account?.clientId) {
+    throw new Error(`Hotmail 账号 ${account.email || account.id} 缺少客户端 ID。`);
+  }
+  if (!account?.refreshToken) {
+    throw new Error(`Hotmail 账号 ${account.email || account.id} 缺少刷新令牌(refresh token)。`);
+  }
+
+  const serviceSettings = getHotmailServiceSettings(await getState());
+  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')), timeoutMs);
-  const url = buildHotmailGraphMessagesUrl({
-    mailbox,
-    top: pageSize,
-    selectFields: messageFields,
-  });
+  const timeoutId = setTimeout(() => controller.abort(new Error('timeout')), requestTimeoutMs);
 
   let response;
   try {
-    response = await fetch(url, {
-      method: 'GET',
+    response = await fetch(buildHotmailLocalEndpoint(serviceSettings.localBaseUrl, '/code'), {
+      method: 'POST',
       headers: {
+        'Content-Type': 'application/json',
         Accept: 'application/json',
-        Authorization: `Bearer ${account.accessToken}`,
       },
+      body: JSON.stringify({
+        email: account.email,
+        clientId: account.clientId,
+        refreshToken: account.refreshToken,
+        mailboxes: HOTMAIL_MAILBOXES,
+        top: 5,
+        senderFilters: pollPayload.senderFilters || [],
+        subjectFilters: pollPayload.subjectFilters || [],
+        excludeCodes: pollPayload.excludeCodes || [],
+        filterAfterTimestamp: Number(pollPayload.filterAfterTimestamp || 0) || 0,
+      }),
       signal: controller.signal,
     });
   } catch (err) {
-    const error = new Error(
-      err?.name === 'AbortError'
-        ? `Hotmail 邮件请求超时(>${Math.round(timeoutMs / 1000)} 秒):${mailbox}`
-        : `Hotmail 邮件请求失败:${err.message}`
-    );
-    error.code = 'HOTMAIL_GRAPH_REQUEST_FAILED';
-    throw error;
+    if (err?.name === 'AbortError') {
+      throw new Error(`Hotmail 本地助手请求超时(>${Math.round(requestTimeoutMs / 1000)} 秒)`);
+    }
+    throw new Error(`Hotmail 本地助手请求失败:${err.message}`);
   } finally {
     clearTimeout(timeoutId);
   }
@@ -938,78 +1047,79 @@ async function requestHotmailGraphMessages(account, mailbox = 'INBOX') {
     payload = { raw: text };
   }
 
-  if (!response.ok) {
-    const errorText = payload?.error?.message || payload?.error_description || payload?.message || text || `HTTP ${response.status}`;
-    const error = new Error(`Hotmail 邮件请求失败:${errorText}`);
-    error.code = response.status === 401 || response.status === 403
-      ? 'HOTMAIL_GRAPH_AUTH_FAILED'
-      : 'HOTMAIL_GRAPH_REQUEST_FAILED';
-    throw error;
+  if (!response.ok || payload?.ok === false) {
+    const errorText = payload?.error || payload?.message || text || `HTTP ${response.status}`;
+    throw new Error(`Hotmail 本地助手返回失败:${errorText}`);
   }
 
+  const normalizedMessage = payload?.message
+    ? {
+      ...normalizeHotmailMailApiMessages([payload.message])[0],
+      mailbox: payload?.message?.mailbox || 'INBOX',
+      receivedTimestamp: Number(payload?.message?.receivedTimestamp || 0) || 0,
+    }
+    : null;
+  const nextAccount = applyHotmailApiResultToAccount(account, {
+    nextRefreshToken: String(payload?.nextRefreshToken || '').trim(),
+  });
+  const savedAccount = await upsertHotmailAccount(nextAccount);
   return {
-    mailbox,
-    payload,
-    messages: normalizeHotmailMailApiMessages(payload?.value),
+    account: savedAccount,
+    code: String(payload?.code || ''),
+    message: normalizedMessage,
+    usedTimeFallback: Boolean(payload?.usedTimeFallback),
+    selectionSource: String(payload?.selectionSource || ''),
   };
 }
 
-function buildHotmailAuthFailureAccount(account, errorMessage) {
-  return normalizeHotmailAccount({
-    ...account,
-    accessToken: '',
-    expiresAt: 0,
-    status: 'error',
-    lastError: String(errorMessage || ''),
-  });
-}
-
-async function fetchHotmailMailboxMessages(account, mailboxes = HOTMAIL_MAILBOXES) {
-  let workingAccount = normalizeHotmailAccount(account);
-  const mailboxResults = [];
-
-  try {
-    if (!isHotmailAccessTokenUsable(workingAccount)) {
-      workingAccount = await refreshHotmailAccessToken(workingAccount);
-    }
+async function pollHotmailVerificationCodeViaLocalHelper(step, account, pollPayload = {}) {
+  const maxAttempts = Number(pollPayload.maxAttempts) || 5;
+  const intervalMs = Number(pollPayload.intervalMs) || 3000;
+  let workingAccount = account;
+  let lastError = null;
 
-    for (const mailbox of mailboxes) {
-      let result;
-      try {
-        result = await requestHotmailGraphMessages(workingAccount, mailbox);
-      } catch (err) {
-        if (err?.code !== 'HOTMAIL_GRAPH_AUTH_FAILED') {
-          throw err;
+  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+    throwIfStopped();
+    try {
+      await addLog(`步骤 ${step}:正在通过本地助手轮询 Hotmail 验证码(${attempt}/${maxAttempts})...`, 'info');
+      const fetchResult = await requestHotmailLocalCode(workingAccount, pollPayload);
+      workingAccount = fetchResult.account;
+
+      if (fetchResult.code) {
+        const mailboxLabel = fetchResult.message?.mailbox || 'INBOX';
+        if (fetchResult.usedTimeFallback) {
+          await addLog(`步骤 ${step}:本地助手使用时间回退后命中 Hotmail ${mailboxLabel} 验证码。`, 'warn');
         }
-
-        workingAccount = await refreshHotmailAccessToken({
-          ...workingAccount,
-          accessToken: '',
-          expiresAt: 0,
-        });
-        result = await requestHotmailGraphMessages(workingAccount, mailbox);
+        await addLog(`步骤 ${step}:已通过本地助手在 Hotmail ${mailboxLabel} 中找到验证码:${fetchResult.code}`, 'ok');
+        return {
+          ok: true,
+          code: fetchResult.code,
+          emailTimestamp: fetchResult.message?.receivedTimestamp || Date.now(),
+          mailId: fetchResult.message?.id || '',
+        };
       }
 
-      mailboxResults.push({
-        mailbox,
-        count: result.messages.length,
-        messages: result.messages.map((message) => ({ ...message, mailbox })),
-      });
+      lastError = new Error(`步骤 ${step}:本地助手暂未返回匹配验证码(${attempt}/${maxAttempts})。`);
+      await addLog(lastError.message, attempt === maxAttempts ? 'warn' : 'info');
+    } catch (err) {
+      lastError = err;
+      await addLog(`步骤 ${step}:本地助手轮询 Hotmail 失败:${err.message}`, 'warn');
     }
-  } catch (err) {
-    if (err?.code === 'HOTMAIL_TOKEN_REFRESH_FAILED' || err?.code === 'HOTMAIL_GRAPH_AUTH_FAILED') {
-      const failedAccount = buildHotmailAuthFailureAccount(workingAccount, err.message);
-      await upsertHotmailAccount(failedAccount);
+
+    if (attempt < maxAttempts) {
+      await sleepWithStop(intervalMs);
     }
-    throw err;
   }
 
-  const savedAccount = await upsertHotmailAccount(workingAccount);
-  return {
-    account: savedAccount,
-    mailboxResults,
-    messages: mailboxResults.flatMap((item) => item.messages),
-  };
+  throw lastError || new Error(`步骤 ${step}:本地助手未返回新的匹配验证码。`);
+}
+
+async function fetchHotmailMailboxMessages(account, mailboxes = HOTMAIL_MAILBOXES) {
+  const serviceSettings = getHotmailServiceSettings(await getState());
+  if (serviceSettings.mode === HOTMAIL_SERVICE_MODE_LOCAL) {
+    return requestHotmailLocalMessages(account, mailboxes);
+  }
+  return fetchHotmailMailboxMessagesFromRemoteService(account, mailboxes);
 }
 
 async function verifyHotmailAccount(accountId) {
@@ -1059,6 +1169,11 @@ async function pollHotmailVerificationCode(step, state, pollPayload = {}) {
   });
   await addLog(`步骤 ${step}:当前使用 Hotmail 账号 ${account.email} 轮询收件箱。`, 'info');
 
+  const serviceSettings = getHotmailServiceSettings(state);
+  if (serviceSettings.mode === HOTMAIL_SERVICE_MODE_LOCAL) {
+    return pollHotmailVerificationCodeViaLocalHelper(step, account, pollPayload);
+  }
+
   const maxAttempts = Number(pollPayload.maxAttempts) || 5;
   const intervalMs = Number(pollPayload.intervalMs) || 3000;
   let lastError = null;
@@ -1979,7 +2094,7 @@ function getSourceLabel(source) {
     'mail-2925': '2925 邮箱',
     'inbucket-mail': 'Inbucket 邮箱',
     'duck-mail': 'Duck 邮箱',
-    'hotmail-api': 'Hotmail(微软 Graph)',
+    'hotmail-api': 'Hotmail(远程/本地)',
   };
   return labels[source] || source || '未知来源';
 }
@@ -2759,7 +2874,7 @@ async function handleMessage(message, sender) {
       const updates = buildPersistentSettingsPayload(message.payload || {});
       await setPersistentSettings(updates);
       await setState(updates);
-      return { ok: true };
+      return { ok: true, state: await getState() };
     }
 
     case 'EXPORT_SETTINGS': {
@@ -3881,7 +3996,7 @@ async function executeStep3(state) {
 function getMailConfig(state) {
   const provider = state.mailProvider || 'qq';
   if (provider === HOTMAIL_PROVIDER) {
-    return { provider: HOTMAIL_PROVIDER, label: 'Hotmail(微软 Graph)' };
+    return { provider: HOTMAIL_PROVIDER, label: 'Hotmail(远程/本地)' };
   }
   if (provider === '163') {
     return { source: 'mail-163', url: 'https://mail.163.com/js6/main.jsp?df=mail163_letter#module=mbox.ListModule%7C%7B%22fid%22%3A1%2C%22order%22%3A%22date%22%2C%22desc%22%3Atrue%7D', label: '163 邮箱' };

+ 16 - 37
hotmail-utils.js

@@ -6,22 +6,7 @@
 
   root.HotmailUtils = factory();
 })(typeof self !== 'undefined' ? self : globalThis, function createHotmailUtils() {
-  const HOTMAIL_MICROSOFT_TOKEN_URL = 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token';
-  const HOTMAIL_GRAPH_API_ORIGIN = 'https://graph.microsoft.com';
-  const HOTMAIL_GRAPH_PAGE_SIZE = 10;
-  const HOTMAIL_GRAPH_MESSAGE_FIELDS = [
-    'id',
-    'internetMessageId',
-    'subject',
-    'from',
-    'bodyPreview',
-    'receivedDateTime',
-  ];
-  const HOTMAIL_GRAPH_SCOPES = [
-    'offline_access',
-    'https://graph.microsoft.com/Mail.Read',
-    'https://graph.microsoft.com/User.Read',
-  ];
+  const HOTMAIL_MAIL_API_URL = 'https://apple.882263.xyz/api/mail-new';
 
   function normalizeText(value) {
     return String(value || '')
@@ -291,20 +276,19 @@
     return list.map((message) => normalizeHotmailMailApiMessage(message));
   }
 
-  function normalizeHotmailMailboxId(mailbox = 'INBOX') {
-    const normalized = normalizeText(mailbox);
-    if (normalized === 'junk' || normalized === 'junk email' || normalized === 'junkemail') {
-      return 'junkemail';
+  function buildHotmailMailApiLatestUrl(options) {
+    const apiUrl = String(options?.apiUrl || '').trim() || HOTMAIL_MAIL_API_URL;
+    const url = new URL(apiUrl);
+    url.searchParams.set('refresh_token', String(options?.refreshToken || ''));
+    url.searchParams.set('client_id', String(options?.clientId || ''));
+    url.searchParams.set('email', String(options?.email || ''));
+    url.searchParams.set('mailbox', String(options?.mailbox || 'INBOX'));
+    const responseType = options?.responseType === undefined || options?.responseType === null
+      ? 'json'
+      : String(options.responseType).trim();
+    if (responseType) {
+      url.searchParams.set('response_type', responseType);
     }
-    return 'inbox';
-  }
-
-  function buildHotmailGraphMessagesUrl(options) {
-    const folderId = normalizeHotmailMailboxId(options?.mailbox);
-    const url = new URL(`${HOTMAIL_GRAPH_API_ORIGIN}/v1.0/me/mailFolders/${folderId}/messages`);
-    url.searchParams.set('$top', String(options?.top || HOTMAIL_GRAPH_PAGE_SIZE));
-    url.searchParams.set('$select', (options?.selectFields || HOTMAIL_GRAPH_MESSAGE_FIELDS).join(','));
-    url.searchParams.set('$orderby', String(options?.orderBy || 'receivedDateTime desc'));
     return url.toString();
   }
 
@@ -348,13 +332,9 @@
       : (flowStartTime || 0);
   }
 
-  function getHotmailGraphRequestConfig() {
+  function getHotmailMailApiRequestConfig() {
     return {
       timeoutMs: 15000,
-      pageSize: HOTMAIL_GRAPH_PAGE_SIZE,
-      scopes: HOTMAIL_GRAPH_SCOPES.slice(),
-      tokenUrl: HOTMAIL_MICROSOFT_TOKEN_URL,
-      messageFields: HOTMAIL_GRAPH_MESSAGE_FIELDS.slice(),
     };
   }
 
@@ -377,17 +357,16 @@
   }
 
   return {
-    buildHotmailGraphMessagesUrl,
+    buildHotmailMailApiLatestUrl,
     extractVerificationCodeFromMessage,
     filterHotmailAccountsByUsage,
     extractVerificationCode,
     getLatestHotmailMessage,
     getHotmailBulkActionLabel,
     getHotmailListToggleLabel,
-    getHotmailGraphRequestConfig,
+    getHotmailMailApiRequestConfig,
     getHotmailVerificationPollConfig,
     getHotmailVerificationRequestTimestamp,
-    normalizeHotmailMailboxId,
     isAuthorizedHotmailAccount,
     normalizeHotmailMailApiMessages,
     normalizeTimestamp,

+ 661 - 0
scripts/hotmail_helper.py

@@ -0,0 +1,661 @@
+import email
+import html
+import imaplib
+import json
+import re
+import time
+from datetime import datetime, timezone
+from email.header import decode_header
+from email.utils import parseaddr, parsedate_to_datetime
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from urllib.error import HTTPError, URLError
+from urllib.parse import urlencode
+from urllib.request import Request, urlopen
+
+
+HOST = "127.0.0.1"
+PORT = 17373
+LIVE_TOKEN_URL = "https://login.live.com/oauth20_token.srf"
+ENTRA_COMMON_TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
+ENTRA_CONSUMERS_TOKEN_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"
+GRAPH_API_ORIGIN = "https://graph.microsoft.com"
+OUTLOOK_API_ORIGIN = "https://outlook.office.com"
+GRAPH_SCOPES = "offline_access https://graph.microsoft.com/Mail.Read https://graph.microsoft.com/User.Read"
+GRAPH_DEFAULT_SCOPE = "https://graph.microsoft.com/.default"
+TOKEN_ENDPOINTS = {
+    "live": {
+        "name": "live",
+        "url": LIVE_TOKEN_URL,
+        "extra_data": {},
+    },
+    "entra-consumers-delegated": {
+        "name": "entra-consumers-delegated",
+        "url": ENTRA_CONSUMERS_TOKEN_URL,
+        "extra_data": {
+            "scope": GRAPH_SCOPES,
+        },
+    },
+    "entra-common-delegated": {
+        "name": "entra-common-delegated",
+        "url": ENTRA_COMMON_TOKEN_URL,
+        "extra_data": {
+            "scope": GRAPH_SCOPES,
+        },
+    },
+    "entra-common-default": {
+        "name": "entra-common-default",
+        "url": ENTRA_COMMON_TOKEN_URL,
+        "extra_data": {
+            "scope": GRAPH_DEFAULT_SCOPE,
+        },
+    },
+    "entra-common-outlook": {
+        "name": "entra-common-outlook",
+        "url": ENTRA_COMMON_TOKEN_URL,
+        "extra_data": {},
+    },
+}
+IMAP_HOST = "outlook.office365.com"
+IMAP_PORT = 993
+REQUEST_TIMEOUT_SECONDS = 45
+FETCH_LIMIT_DEFAULT = 5
+
+
+def json_response(handler, status, payload):
+    body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+    handler.send_response(status)
+    handler.send_header("Content-Type", "application/json; charset=utf-8")
+    handler.send_header("Content-Length", str(len(body)))
+    handler.send_header("Access-Control-Allow-Origin", "*")
+    handler.send_header("Access-Control-Allow-Headers", "Content-Type")
+    handler.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
+    handler.end_headers()
+    handler.wfile.write(body)
+
+
+def read_json_payload(handler):
+    length = int(handler.headers.get("Content-Length", "0") or 0)
+    raw = handler.rfile.read(length) if length > 0 else b"{}"
+    try:
+        return json.loads(raw.decode("utf-8"))
+    except Exception as exc:
+        raise RuntimeError(f"Invalid JSON payload: {exc}") from exc
+
+
+def post_form(url, data):
+    encoded = urlencode(data).encode("utf-8")
+    request = Request(url, data=encoded, headers={"Content-Type": "application/x-www-form-urlencoded"})
+    with urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
+        return json.loads(response.read().decode("utf-8"))
+
+
+def get_json(url, headers=None):
+    request = Request(url, headers=headers or {})
+    with urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
+        return response.getcode(), json.loads(response.read().decode("utf-8"))
+
+
+def mask_secret(value, keep=6):
+    raw = str(value or "")
+    if not raw:
+        return ""
+    if len(raw) <= keep:
+        return "*" * len(raw)
+    return raw[:keep] + "..." + raw[-keep:]
+
+
+def compact_text(value, limit=400):
+    text = str(value or "").replace("\r", " ").replace("\n", " ").strip()
+    return text[:limit]
+
+
+def log_info(message):
+    print(f"[HotmailHelper] {message}", flush=True)
+
+
+def try_refresh_access_token(endpoint, client_id, refresh_token):
+    request_data = {
+        "client_id": client_id,
+        "refresh_token": refresh_token,
+        "grant_type": "refresh_token",
+        **(endpoint.get("extra_data") or {}),
+    }
+    started_at = time.monotonic()
+    try:
+        payload = post_form(endpoint["url"], request_data)
+    except HTTPError as exc:
+        detail = exc.read().decode("utf-8", errors="ignore")
+        return {
+            "ok": False,
+            "endpoint": endpoint["name"],
+            "url": endpoint["url"],
+            "status": getattr(exc, "code", None),
+            "error": compact_text(detail or str(exc)),
+            "elapsed_ms": int((time.monotonic() - started_at) * 1000),
+        }
+    except URLError as exc:
+        return {
+            "ok": False,
+            "endpoint": endpoint["name"],
+            "url": endpoint["url"],
+            "status": None,
+            "error": compact_text(f"Token request failed: {exc}"),
+            "elapsed_ms": int((time.monotonic() - started_at) * 1000),
+        }
+
+    access_token = str(payload.get("access_token") or "").strip()
+    if not access_token:
+        return {
+            "ok": False,
+            "endpoint": endpoint["name"],
+            "url": endpoint["url"],
+            "status": 200,
+            "error": compact_text(payload.get("error_description") or payload.get("error") or json.dumps(payload, ensure_ascii=False)),
+            "elapsed_ms": int((time.monotonic() - started_at) * 1000),
+        }
+
+    return {
+        "ok": True,
+        "endpoint": endpoint["name"],
+        "url": endpoint["url"],
+        "elapsed_ms": int((time.monotonic() - started_at) * 1000),
+        "payload": {
+            "access_token": access_token,
+            "next_refresh_token": str(payload.get("refresh_token") or "").strip(),
+        },
+    }
+
+
+def refresh_access_token(client_id, refresh_token, strategy_names=None):
+    errors = []
+    selected_endpoints = [
+        TOKEN_ENDPOINTS[name]
+        for name in (strategy_names or ["live", "entra-consumers-delegated", "entra-common-delegated"])
+        if name in TOKEN_ENDPOINTS
+    ]
+    log_info(
+        "token refresh start "
+        f"clientId={mask_secret(client_id)} "
+        f"refreshToken={mask_secret(refresh_token)} "
+        f"strategies={[item['name'] for item in selected_endpoints]}"
+    )
+
+    for endpoint in selected_endpoints:
+        result = try_refresh_access_token(endpoint, client_id, refresh_token)
+        if result["ok"]:
+            log_info(
+                "token refresh success "
+                f"endpoint={result['endpoint']} "
+                f"elapsedMs={result['elapsed_ms']}"
+            )
+            return {
+                "access_token": result["payload"]["access_token"],
+                "next_refresh_token": result["payload"]["next_refresh_token"],
+                "token_endpoint": result["endpoint"],
+                "token_url": result["url"],
+            }
+
+        errors.append(result)
+        log_info(
+            "token refresh failed "
+            f"endpoint={result['endpoint']} "
+            f"status={result['status']} "
+            f"elapsedMs={result['elapsed_ms']} "
+            f"detail={result['error']}"
+        )
+
+    details = " | ".join(
+        f"{item['endpoint']}({item['status']}): {item['error']}"
+        for item in errors
+    )
+    raise RuntimeError(f"Token refresh failed on all endpoints: {details}")
+
+
+def build_xoauth2(email_addr, access_token):
+    return f"user={email_addr}\x01auth=Bearer {access_token}\x01\x01".encode("utf-8")
+
+
+def open_mailbox(email_addr, access_token):
+    client = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT, timeout=REQUEST_TIMEOUT_SECONDS)
+    client.authenticate("XOAUTH2", lambda _: build_xoauth2(email_addr, access_token))
+    return client
+
+
+def decode_mime_header(value):
+    if not value:
+        return ""
+    parts = []
+    for chunk, charset in decode_header(value):
+        if isinstance(chunk, bytes):
+            parts.append(chunk.decode(charset or "utf-8", errors="ignore"))
+        else:
+            parts.append(str(chunk))
+    return "".join(parts).strip()
+
+
+def extract_text_part(message):
+    if message.is_multipart():
+        for part in message.walk():
+            if part.get_content_maintype() == "multipart":
+                continue
+            if "attachment" in str(part.get("Content-Disposition") or "").lower():
+                continue
+            payload = part.get_payload(decode=True) or b""
+            charset = part.get_content_charset() or "utf-8"
+            text = payload.decode(charset, errors="ignore").strip()
+            if part.get_content_type() == "text/plain" and text:
+                return text
+            if part.get_content_type() == "text/html" and text:
+                return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", html.unescape(text))).strip()
+        return ""
+
+    payload = message.get_payload(decode=True) or b""
+    charset = message.get_content_charset() or "utf-8"
+    text = payload.decode(charset, errors="ignore").strip()
+    if message.get_content_type() == "text/html":
+        return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", html.unescape(text))).strip()
+    return text
+
+
+def mailbox_candidates(mailbox):
+    normalized = str(mailbox or "INBOX").strip().lower()
+    if normalized in {"junk", "junk email", "junk e-mail", "junkemail"}:
+        return ["Junk", "Junk Email", "Junk E-Mail"]
+    return ["INBOX"]
+
+
+def normalize_mailbox_label(mailbox):
+    normalized = str(mailbox or "INBOX").strip().lower()
+    if normalized in {"junk", "junk email", "junk e-mail", "junkemail"}:
+        return "Junk"
+    return "INBOX"
+
+
+def normalize_mailbox_id(mailbox):
+    normalized = str(mailbox or "INBOX").strip().lower()
+    if normalized in {"junk", "junk email", "junk e-mail", "junkemail"}:
+        return "junkemail"
+    return "inbox"
+
+
+def select_mailbox(client, mailbox):
+    for candidate in mailbox_candidates(mailbox):
+        status, _ = client.select(candidate)
+        if status == "OK":
+            return candidate
+    raise RuntimeError(f"Mailbox not found: {mailbox}")
+
+
+def to_timestamp_ms(raw_date):
+    if not raw_date:
+        return 0
+    try:
+        parsed = parsedate_to_datetime(raw_date)
+        if parsed.tzinfo is None:
+            parsed = parsed.replace(tzinfo=timezone.utc)
+        return int(parsed.timestamp() * 1000)
+    except Exception:
+        return 0
+
+
+def to_iso_string(timestamp_ms):
+    if not timestamp_ms:
+        return ""
+    return datetime.fromtimestamp(timestamp_ms / 1000, tz=timezone.utc).isoformat().replace("+00:00", "Z")
+
+
+def normalize_message(message_id, raw_bytes, mailbox):
+    parsed = email.message_from_bytes(raw_bytes)
+    sender_name, sender_addr = parseaddr(parsed.get("From", ""))
+    subject = decode_mime_header(parsed.get("Subject", ""))
+    body = extract_text_part(parsed)
+    timestamp_ms = to_timestamp_ms(parsed.get("Date"))
+    return {
+        "id": str(message_id),
+        "mailbox": mailbox,
+        "subject": subject,
+        "from": {
+            "emailAddress": {
+                "address": sender_addr.strip(),
+                "name": sender_name.strip(),
+            }
+        },
+        "bodyPreview": body[:500],
+        "receivedDateTime": to_iso_string(timestamp_ms),
+        "receivedTimestamp": timestamp_ms,
+    }
+
+
+def fetch_messages(email_addr, access_token, mailbox="INBOX", top=FETCH_LIMIT_DEFAULT):
+    client = None
+    logical_mailbox = normalize_mailbox_label(mailbox)
+    try:
+        client = open_mailbox(email_addr, access_token)
+        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_messages_for_mailboxes(email_addr, access_token, mailboxes, top):
+    mailbox_results = []
+    all_messages = []
+    for mailbox in mailboxes or ["INBOX"]:
+        result = fetch_messages(email_addr, access_token, 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 normalize_graph_message(message, mailbox):
+    sender = message.get("from", {}) or {}
+    email_addr = sender.get("emailAddress", {}) if isinstance(sender, dict) else {}
+    received = str(message.get("receivedDateTime") or "").strip()
+    return {
+        "id": str(message.get("id") or message.get("internetMessageId") or "").strip(),
+        "mailbox": mailbox,
+        "subject": str(message.get("subject") or "").strip(),
+        "from": {
+            "emailAddress": {
+                "address": str(email_addr.get("address") or "").strip(),
+                "name": str(email_addr.get("name") or "").strip(),
+            }
+        },
+        "bodyPreview": str(message.get("bodyPreview") or "").strip(),
+        "receivedDateTime": received,
+        "receivedTimestamp": int(datetime.fromisoformat(received.replace("Z", "+00:00")).timestamp() * 1000) if received else 0,
+    }
+
+
+def normalize_outlook_message(message, mailbox):
+    sender = message.get("From", {}) or message.get("from", {}) or {}
+    email_addr = sender.get("EmailAddress", {}) if isinstance(sender, dict) else {}
+    if isinstance(sender, dict) and not email_addr:
+        email_addr = sender.get("emailAddress", {}) if isinstance(sender, dict) else {}
+    received = str(message.get("ReceivedDateTime") or message.get("receivedDateTime") or "").strip()
+    return {
+        "id": str(message.get("Id") or message.get("id") or "").strip(),
+        "mailbox": mailbox,
+        "subject": str(message.get("Subject") or message.get("subject") or "").strip(),
+        "from": {
+            "emailAddress": {
+                "address": str(email_addr.get("Address") or email_addr.get("address") or "").strip(),
+                "name": str(email_addr.get("Name") or email_addr.get("name") or "").strip(),
+            }
+        },
+        "bodyPreview": str(message.get("BodyPreview") or message.get("bodyPreview") or "").strip(),
+        "receivedDateTime": received,
+        "receivedTimestamp": int(datetime.fromisoformat(received.replace("Z", "+00:00")).timestamp() * 1000) if received else 0,
+    }
+
+
+def fetch_graph_messages(access_token, mailbox="INBOX", top=FETCH_LIMIT_DEFAULT):
+    mailbox_id = normalize_mailbox_id(mailbox)
+    url = (
+        f"{GRAPH_API_ORIGIN}/v1.0/me/mailFolders/{mailbox_id}/messages"
+        f"?$top={max(1, min(int(top or FETCH_LIMIT_DEFAULT), 30))}"
+        f"&$select=id,internetMessageId,subject,from,bodyPreview,receivedDateTime"
+        f"&$orderby=receivedDateTime desc"
+    )
+    try:
+        _, payload = get_json(url, headers={
+            "Accept": "application/json",
+            "Authorization": f"Bearer {access_token}",
+        })
+    except HTTPError as exc:
+        detail = exc.read().decode("utf-8", errors="ignore")
+        raise RuntimeError(f"Graph request failed: {detail or exc}") from exc
+    except URLError as exc:
+        raise RuntimeError(f"Graph request failed: {exc}") from exc
+
+    messages = [normalize_graph_message(item, normalize_mailbox_label(mailbox)) for item in (payload.get("value") or [])]
+    return {"mailbox": normalize_mailbox_label(mailbox), "messages": messages, "count": len(messages)}
+
+
+def fetch_outlook_api_messages(access_token, mailbox="INBOX", top=FETCH_LIMIT_DEFAULT):
+    mailbox_id = normalize_mailbox_id(mailbox)
+    url = (
+        f"{OUTLOOK_API_ORIGIN}/api/v2.0/me/mailfolders/{mailbox_id}/messages"
+        f"?$top={max(1, min(int(top or FETCH_LIMIT_DEFAULT), 30))}"
+        f"&$select=Id,Subject,From,BodyPreview,ReceivedDateTime"
+        f"&$orderby=ReceivedDateTime desc"
+    )
+    try:
+        _, payload = get_json(url, headers={
+            "Accept": "application/json",
+            "Authorization": f"Bearer {access_token}",
+        })
+    except HTTPError as exc:
+        detail = exc.read().decode("utf-8", errors="ignore")
+        raise RuntimeError(f"Outlook API request failed: {detail or exc}") from exc
+    except URLError as exc:
+        raise RuntimeError(f"Outlook API request failed: {exc}") from exc
+
+    messages = [normalize_outlook_message(item, normalize_mailbox_label(mailbox)) for item in (payload.get("value") or [])]
+    return {"mailbox": normalize_mailbox_label(mailbox), "messages": messages, "count": len(messages)}
+
+
+def collect_imap_messages(email_addr, client_id, refresh_token, mailboxes, top):
+    token_payload = refresh_access_token(client_id, refresh_token, [
+        "live",
+        "entra-consumers-delegated",
+        "entra-common-delegated",
+    ])
+    result = fetch_messages_for_mailboxes(email_addr, token_payload["access_token"], mailboxes, top)
+    result["transport"] = "imap"
+    result["token_payload"] = token_payload
+    return result
+
+
+def collect_graph_messages(email_addr, client_id, refresh_token, mailboxes, top):
+    token_payload = refresh_access_token(client_id, refresh_token, [
+        "entra-common-delegated",
+        "entra-consumers-delegated",
+        "entra-common-default",
+    ])
+    mailbox_results = [fetch_graph_messages(token_payload["access_token"], mailbox=mailbox, top=top) for mailbox in mailboxes]
+    messages = []
+    for item in mailbox_results:
+        messages.extend(item["messages"])
+    messages.sort(key=lambda item: int(item.get("receivedTimestamp") or 0), reverse=True)
+    return {
+        "transport": "graph",
+        "token_payload": token_payload,
+        "mailboxResults": mailbox_results,
+        "messages": messages,
+    }
+
+
+def collect_outlook_messages(email_addr, client_id, refresh_token, mailboxes, top):
+    token_payload = refresh_access_token(client_id, refresh_token, [
+        "entra-common-outlook",
+        "entra-common-delegated",
+    ])
+    mailbox_results = [fetch_outlook_api_messages(token_payload["access_token"], mailbox=mailbox, top=top) for mailbox in mailboxes]
+    messages = []
+    for item in mailbox_results:
+        messages.extend(item["messages"])
+    messages.sort(key=lambda item: int(item.get("receivedTimestamp") or 0), reverse=True)
+    return {
+        "transport": "outlook",
+        "token_payload": token_payload,
+        "mailboxResults": mailbox_results,
+        "messages": messages,
+    }
+
+
+def collect_messages(email_addr, client_id, refresh_token, mailboxes, top):
+    errors = []
+    collectors = [
+        ("imap", collect_imap_messages),
+        ("graph", collect_graph_messages),
+        ("outlook", collect_outlook_messages),
+    ]
+
+    for transport_name, collector in collectors:
+        try:
+            log_info(f"message collection start transport={transport_name}")
+            result = collector(email_addr, client_id, refresh_token, mailboxes, top)
+            log_info(
+                f"message collection success transport={transport_name} "
+                f"tokenEndpoint={result['token_payload'].get('token_endpoint', '')}"
+            )
+            return result
+        except Exception as exc:
+            message = compact_text(str(exc), 600)
+            errors.append(f"{transport_name}: {message}")
+            log_info(f"message collection failed transport={transport_name} detail={message}")
+
+    raise RuntimeError(f"Message collection failed on all transports: {' | '.join(errors)}")
+
+
+def extract_code(text):
+    source = str(text or "")
+    patterns = [
+        r"(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})",
+        r"code(?:\s+is|[\s:])+(\d{6})",
+        r"\b(\d{6})\b",
+    ]
+    for pattern in patterns:
+        match = re.search(pattern, source, flags=re.IGNORECASE)
+        if match:
+            return match.group(1)
+    return ""
+
+
+def select_latest_code(messages, sender_filters, subject_filters, exclude_codes, filter_after_timestamp):
+    sender_keywords = [str(item).strip().lower() for item in sender_filters or [] if str(item).strip()]
+    subject_keywords = [str(item).strip().lower() for item in subject_filters or [] if str(item).strip()]
+    excluded = {str(item).strip() for item in exclude_codes or [] if str(item).strip()}
+
+    def match_message(message, apply_time_filter):
+        timestamp = int(message.get("receivedTimestamp") or 0)
+        if apply_time_filter and filter_after_timestamp and timestamp and timestamp < int(filter_after_timestamp):
+            return None
+
+        sender = str(message.get("from", {}).get("emailAddress", {}).get("address", "")).lower()
+        subject = str(message.get("subject", ""))
+        preview = str(message.get("bodyPreview", ""))
+        combined = " ".join([sender, subject.lower(), preview.lower()])
+        code = extract_code(" ".join([subject, preview, sender]))
+        if not code or code in excluded:
+            return None
+
+        sender_ok = not sender_keywords or any(keyword in combined for keyword in sender_keywords)
+        subject_ok = not subject_keywords or any(keyword in combined for keyword in subject_keywords)
+        if not sender_ok and not subject_ok:
+            return None
+
+        return {"code": code, "message": message}
+
+    for use_time_fallback in [False, True]:
+        matched = []
+        for message in messages:
+            result = match_message(message, apply_time_filter=not use_time_fallback)
+            if result:
+                matched.append(result)
+        if matched:
+            matched.sort(key=lambda item: int(item["message"].get("receivedTimestamp") or 0), reverse=True)
+            best = matched[0]
+            return {
+                "code": best["code"],
+                "message": best["message"],
+                "usedTimeFallback": use_time_fallback,
+            }
+    return {"code": "", "message": None, "usedTimeFallback": False}
+
+
+class HotmailHelperHandler(BaseHTTPRequestHandler):
+    def do_OPTIONS(self):
+        self.send_response(204)
+        self.send_header("Access-Control-Allow-Origin", "*")
+        self.send_header("Access-Control-Allow-Headers", "Content-Type")
+        self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
+        self.end_headers()
+
+    def do_POST(self):
+        try:
+            payload = read_json_payload(self)
+            email_addr = str(payload.get("email") or "").strip()
+            client_id = str(payload.get("clientId") or "").strip()
+            refresh_token = str(payload.get("refreshToken") or "").strip()
+            if not email_addr or not client_id or not refresh_token:
+                raise RuntimeError("Missing email/clientId/refreshToken")
+
+            top = max(1, min(int(payload.get("top") or FETCH_LIMIT_DEFAULT), 30))
+            mailboxes = payload.get("mailboxes") if isinstance(payload.get("mailboxes"), list) else [payload.get("mailbox") or "INBOX"]
+
+            if self.path == "/messages":
+                result = collect_messages(email_addr, client_id, refresh_token, mailboxes, top)
+                json_response(self, 200, {
+                    "ok": True,
+                    "messages": result["messages"],
+                    "mailboxResults": result["mailboxResults"],
+                    "nextRefreshToken": result["token_payload"].get("next_refresh_token") or "",
+                    "tokenEndpoint": result["token_payload"].get("token_endpoint") or "",
+                    "transport": result.get("transport") or "",
+                })
+                return
+
+            if self.path == "/code":
+                result = collect_messages(email_addr, client_id, refresh_token, 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),
+                )
+                json_response(self, 200, {
+                    "ok": True,
+                    "code": selected["code"],
+                    "message": selected["message"],
+                    "usedTimeFallback": selected["usedTimeFallback"],
+                    "nextRefreshToken": result["token_payload"].get("next_refresh_token") or "",
+                    "tokenEndpoint": result["token_payload"].get("token_endpoint") or "",
+                    "transport": result.get("transport") or "",
+                })
+                return
+
+            json_response(self, 404, {"ok": False, "error": f"Unsupported path: {self.path}"})
+        except Exception as exc:
+            json_response(self, 500, {"ok": False, "error": str(exc)})
+
+
+def main():
+    server = ThreadingHTTPServer((HOST, PORT), HotmailHelperHandler)
+    print(f"Hotmail helper listening on http://{HOST}:{PORT}", flush=True)
+    try:
+        server.serve_forever()
+    except KeyboardInterrupt:
+        pass
+    finally:
+        server.server_close()
+
+
+if __name__ == "__main__":
+    main()

+ 25 - 5
sidepanel/sidepanel.css

@@ -499,6 +499,11 @@ header {
   gap: 2px;
 }
 
+.section-mini-copy .section-label {
+  font-size: 14px;
+  letter-spacing: 0.04em;
+}
+
 .section-mini-actions {
   display: flex;
   align-items: center;
@@ -507,11 +512,6 @@ header {
   justify-content: flex-end;
 }
 
-.section-hint {
-  color: var(--text-muted);
-  font-size: 12px;
-}
-
 .data-row {
   display: flex;
   align-items: center;
@@ -668,6 +668,26 @@ header {
   box-shadow: 0 0 0 1px var(--blue-glow);
 }
 
+.choice-btn:disabled {
+  border-color: var(--border);
+  background: var(--bg-elevated);
+  color: var(--text-muted);
+  cursor: not-allowed;
+  box-shadow: none;
+}
+
+.choice-btn:disabled:hover {
+  border-color: var(--border);
+  background: var(--bg-elevated);
+  color: var(--text-muted);
+}
+
+#btn-fetch-email,
+#btn-save-settings {
+  padding-inline: 10px;
+  flex-shrink: 0;
+}
+
 .data-inline-btn {
   flex-shrink: 0;
   min-width: 56px;

+ 17 - 2
sidepanel/sidepanel.html

@@ -152,7 +152,7 @@
         <span class="data-label">邮箱服务</span>
         <div class="data-inline">
           <select id="select-mail-provider" class="data-select">
-            <option value="hotmail-api">Hotmail(微软 Graph)</option>
+            <option value="hotmail-api">Hotmail(本地助手)</option>
             <option value="163">163 邮箱 (mail.163.com)</option>
             <option value="163-vip">163 VIP 邮箱 (vip.163.com)</option>
             <option value="qq">QQ 邮箱 (wx.mail.qq.com)</option>
@@ -255,15 +255,30 @@
       <div class="section-mini-header">
         <div class="section-mini-copy">
           <span class="section-label">Hotmail 账号池</span>
-          <span class="section-hint">每轮自动分配一个可用账号</span>
         </div>
         <div class="section-mini-actions">
+          <button id="btn-hotmail-usage-guide" class="btn btn-ghost btn-xs" type="button">使用教程</button>
           <button id="btn-clear-used-hotmail-accounts" class="btn btn-ghost btn-xs" type="button">清空已用</button>
           <button id="btn-delete-all-hotmail-accounts" class="btn btn-ghost btn-xs" type="button">全部删除</button>
           <button id="btn-toggle-hotmail-list" class="btn btn-ghost btn-xs" type="button"
             aria-expanded="false">展开列表</button>
         </div>
       </div>
+      <div class="data-row" id="row-hotmail-service-mode">
+        <span class="data-label">接码模式</span>
+        <div id="hotmail-service-mode-group" class="choice-group" role="group" aria-label="Hotmail 接码模式">
+          <button type="button" class="choice-btn" data-hotmail-service-mode="remote" disabled title="远程服务暂时禁用">远程服务</button>
+          <button type="button" class="choice-btn" data-hotmail-service-mode="local">本地助手</button>
+        </div>
+      </div>
+      <div class="data-row" id="row-hotmail-remote-base-url">
+        <span class="data-label">远程服务</span>
+        <input type="text" id="input-hotmail-remote-base-url" class="data-input mono" placeholder="请输入远程服务地址" />
+      </div>
+      <div class="data-row" id="row-hotmail-local-base-url" style="display:none;">
+        <span class="data-label">本地助手</span>
+        <input type="text" id="input-hotmail-local-base-url" class="data-input mono" placeholder="http://127.0.0.1:17373" />
+      </div>
       <div class="data-row">
         <span class="data-label">邮箱</span>
         <input type="text" id="input-hotmail-email" class="data-input" placeholder="name@hotmail.com" />

+ 90 - 5
sidepanel/sidepanel.js

@@ -69,6 +69,12 @@ const btnMailLogin = document.getElementById('btn-mail-login');
 const rowEmailGenerator = document.getElementById('row-email-generator');
 const selectEmailGenerator = document.getElementById('select-email-generator');
 const hotmailSection = document.getElementById('hotmail-section');
+const rowHotmailServiceMode = document.getElementById('row-hotmail-service-mode');
+const hotmailServiceModeButtons = Array.from(document.querySelectorAll('[data-hotmail-service-mode]'));
+const rowHotmailRemoteBaseUrl = document.getElementById('row-hotmail-remote-base-url');
+const inputHotmailRemoteBaseUrl = document.getElementById('input-hotmail-remote-base-url');
+const rowHotmailLocalBaseUrl = document.getElementById('row-hotmail-local-base-url');
+const inputHotmailLocalBaseUrl = document.getElementById('input-hotmail-local-base-url');
 const inputHotmailEmail = document.getElementById('input-hotmail-email');
 const inputHotmailClientId = document.getElementById('input-hotmail-client-id');
 const inputHotmailPassword = document.getElementById('input-hotmail-password');
@@ -76,6 +82,7 @@ const inputHotmailRefreshToken = document.getElementById('input-hotmail-refresh-
 const inputHotmailImport = document.getElementById('input-hotmail-import');
 const btnAddHotmailAccount = document.getElementById('btn-add-hotmail-account');
 const btnImportHotmailAccounts = document.getElementById('btn-import-hotmail-accounts');
+const btnHotmailUsageGuide = document.getElementById('btn-hotmail-usage-guide');
 const btnClearUsedHotmailAccounts = document.getElementById('btn-clear-used-hotmail-accounts');
 const btnDeleteAllHotmailAccounts = document.getElementById('btn-delete-all-hotmail-accounts');
 const btnToggleHotmailList = document.getElementById('btn-toggle-hotmail-list');
@@ -134,6 +141,8 @@ const AUTO_SKIP_FAILURES_PROMPT_DISMISSED_STORAGE_KEY = 'multipage-auto-skip-fai
 const AUTO_RUN_FALLBACK_RISK_PROMPT_DISMISSED_STORAGE_KEY = 'multipage-auto-run-fallback-risk-prompt-dismissed';
 const AUTO_RUN_FALLBACK_RISK_WARNING_MIN_RUNS = 15;
 const AUTO_RUN_FALLBACK_RISK_RECOMMENDED_THREAD_INTERVAL_MINUTES = 5;
+const HOTMAIL_SERVICE_MODE_REMOTE = 'remote';
+const HOTMAIL_SERVICE_MODE_LOCAL = 'local';
 
 let latestState = null;
 let currentAutoRun = {
@@ -832,6 +841,9 @@ function collectSettingsPayload() {
     emailPrefix: inputEmailPrefix.value.trim(),
     inbucketHost: inputInbucketHost.value.trim(),
     inbucketMailbox: inputInbucketMailbox.value.trim(),
+    hotmailServiceMode: getSelectedHotmailServiceMode(),
+    hotmailRemoteBaseUrl: inputHotmailRemoteBaseUrl.value.trim(),
+    hotmailLocalBaseUrl: inputHotmailLocalBaseUrl.value.trim(),
     cloudflareDomain: selectedCloudflareDomain,
     cloudflareDomains: domains,
     autoRunSkipFailures: inputAutoSkipFailures.checked,
@@ -848,6 +860,10 @@ function normalizeLocalCpaStep9Mode(value = '') {
     : DEFAULT_LOCAL_CPA_STEP9_MODE;
 }
 
+function normalizeHotmailServiceMode(value = '') {
+  return HOTMAIL_SERVICE_MODE_LOCAL;
+}
+
 function getSelectedLocalCpaStep9Mode() {
   const activeButton = localCpaStep9ModeButtons.find((button) => button.classList.contains('is-active'));
   return normalizeLocalCpaStep9Mode(activeButton?.dataset.localCpaStep9Mode);
@@ -862,6 +878,23 @@ function setLocalCpaStep9Mode(mode) {
   });
 }
 
+function getSelectedHotmailServiceMode() {
+  const activeButton = hotmailServiceModeButtons.find((button) => button.classList.contains('is-active'));
+  return normalizeHotmailServiceMode(activeButton?.dataset.hotmailServiceMode);
+}
+
+function setHotmailServiceMode(mode) {
+  const resolvedMode = normalizeHotmailServiceMode(mode);
+  hotmailServiceModeButtons.forEach((button) => {
+    const isRemoteMode = button.dataset.hotmailServiceMode === HOTMAIL_SERVICE_MODE_REMOTE;
+    const active = button.dataset.hotmailServiceMode === resolvedMode;
+    button.disabled = isRemoteMode;
+    button.setAttribute('aria-disabled', String(isRemoteMode));
+    button.classList.toggle('is-active', active);
+    button.setAttribute('aria-pressed', String(active));
+  });
+}
+
 function setSettingsCardLocked(locked) {
   if (!settingsCard) {
     return;
@@ -945,11 +978,15 @@ async function saveSettings(options = {}) {
       throw new Error(response.error);
     }
 
-    syncLatestState(payload);
-    markSettingsDirty(false);
-    updatePanelModeUI();
-    updateMailProviderUI();
-    updateButtonStates();
+    if (response?.state) {
+      applySettingsState(response.state);
+    } else {
+      syncLatestState(payload);
+      markSettingsDirty(false);
+      updatePanelModeUI();
+      updateMailProviderUI();
+      updateButtonStates();
+    }
     if (!silent) {
       showToast('配置已保存', 'success', 1800);
     }
@@ -1077,6 +1114,9 @@ function applySettingsState(state) {
   inputEmailPrefix.value = state?.emailPrefix || '';
   inputInbucketHost.value = state?.inbucketHost || '';
   inputInbucketMailbox.value = state?.inbucketMailbox || '';
+  setHotmailServiceMode(state?.hotmailServiceMode);
+  inputHotmailRemoteBaseUrl.value = state?.hotmailRemoteBaseUrl || '';
+  inputHotmailLocalBaseUrl.value = state?.hotmailLocalBaseUrl || '';
   renderCloudflareDomainOptions(state?.cloudflareDomain || '');
   setCloudflareDomainEditMode(false, { clearInput: true });
   inputAutoSkipFailures.checked = Boolean(state?.autoRunSkipFailures);
@@ -1627,6 +1667,7 @@ function updateMailProviderUI() {
   const useEmailGenerator = !useHotmail && !useGeneratedAlias;
   updateMailLoginButtonState();
   rowEmailPrefix.style.display = useGeneratedAlias ? '' : 'none';
+  const hotmailServiceMode = getSelectedHotmailServiceMode();
   rowInbucketHost.style.display = useInbucket ? '' : 'none';
   rowInbucketMailbox.style.display = useInbucket ? '' : 'none';
   const useCloudflare = selectEmailGenerator.value === 'cloudflare';
@@ -1648,6 +1689,15 @@ function updateMailProviderUI() {
   labelEmailPrefix.textContent = '邮箱前缀';
   inputEmailPrefix.placeholder = '例如 abc';
   selectEmailGenerator.disabled = useHotmail || useGeneratedAlias;
+  if (rowHotmailServiceMode) {
+    rowHotmailServiceMode.style.display = useHotmail ? '' : 'none';
+  }
+  if (rowHotmailRemoteBaseUrl) {
+    rowHotmailRemoteBaseUrl.style.display = useHotmail && hotmailServiceMode === HOTMAIL_SERVICE_MODE_REMOTE ? '' : 'none';
+  }
+  if (rowHotmailLocalBaseUrl) {
+    rowHotmailLocalBaseUrl.style.display = useHotmail && hotmailServiceMode === HOTMAIL_SERVICE_MODE_LOCAL ? '' : 'none';
+  }
   btnFetchEmail.hidden = useHotmail;
   inputEmail.readOnly = useHotmail || useGeneratedAlias;
   const uiCopy = getEmailGeneratorUiCopy();
@@ -2239,6 +2289,15 @@ btnToggleHotmailList?.addEventListener('click', () => {
   setHotmailListExpanded(!hotmailListExpanded);
 });
 
+btnHotmailUsageGuide?.addEventListener('click', async () => {
+  await openConfirmModal({
+    title: '使用教程',
+    message: '由于第三方服务接口结构不同,所以暂时无法使用,如果您的token可以直连微软,请测试本地功能,详细功能请看项目根目录的readme文件',
+    confirmLabel: '确定',
+    confirmVariant: 'btn-primary',
+  });
+});
+
 btnClearUsedHotmailAccounts?.addEventListener('click', async () => {
   if (hotmailActionInFlight) return;
   hotmailActionInFlight = true;
@@ -2497,6 +2556,22 @@ localCpaStep9ModeButtons.forEach((button) => {
   });
 });
 
+hotmailServiceModeButtons.forEach((button) => {
+  button.addEventListener('click', () => {
+    if (button.disabled) {
+      return;
+    }
+    const nextMode = button.dataset.hotmailServiceMode;
+    if (getSelectedHotmailServiceMode() === normalizeHotmailServiceMode(nextMode)) {
+      return;
+    }
+    setHotmailServiceMode(nextMode);
+    updateMailProviderUI();
+    markSettingsDirty(true);
+    saveSettings({ silent: true }).catch(() => { });
+  });
+});
+
 btnSaveSettings.addEventListener('click', async () => {
   if (!settingsDirty) {
     showToast('配置已是最新', 'info', 1400);
@@ -2729,6 +2804,16 @@ inputVpsPassword.addEventListener('blur', () => {
   saveSettings({ silent: true }).catch(() => { });
 });
 
+[inputHotmailRemoteBaseUrl, inputHotmailLocalBaseUrl].forEach((input) => {
+  input?.addEventListener('input', () => {
+    markSettingsDirty(true);
+    scheduleSettingsAutoSave();
+  });
+  input?.addEventListener('blur', () => {
+    saveSettings({ silent: true }).catch(() => { });
+  });
+});
+
 inputPassword.addEventListener('input', () => {
   markSettingsDirty(true);
   updateButtonStates();

+ 19 - 0
start-hotmail-helper.bat

@@ -0,0 +1,19 @@
+@echo off
+setlocal
+
+cd /d "%~dp0"
+
+where py >nul 2>nul
+if %errorlevel%==0 (
+  py -3 scripts\hotmail_helper.py
+  goto :eof
+)
+
+where python >nul 2>nul
+if %errorlevel%==0 (
+  python scripts\hotmail_helper.py
+  goto :eof
+)
+
+echo Python 3 not found. Please install Python 3.10+ and try again.
+pause

+ 16 - 0
start-hotmail-helper.command

@@ -0,0 +1,16 @@
+#!/bin/bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+cd "$SCRIPT_DIR"
+
+if command -v python3 >/dev/null 2>&1; then
+  exec python3 scripts/hotmail_helper.py
+fi
+
+if command -v python >/dev/null 2>&1; then
+  exec python scripts/hotmail_helper.py
+fi
+
+echo "Python 3 not found. Please install Python 3.10+ and try again."
+read -r -p "Press Enter to exit..."

+ 31 - 31
tests/hotmail-utils.test.js

@@ -2,17 +2,16 @@ const test = require('node:test');
 const assert = require('node:assert/strict');
 
 const {
-  buildHotmailGraphMessagesUrl,
+  buildHotmailMailApiLatestUrl,
   extractVerificationCodeFromMessage,
   filterHotmailAccountsByUsage,
   extractVerificationCode,
   getLatestHotmailMessage,
   getHotmailBulkActionLabel,
   getHotmailListToggleLabel,
-  getHotmailGraphRequestConfig,
+  getHotmailMailApiRequestConfig,
   getHotmailVerificationPollConfig,
   getHotmailVerificationRequestTimestamp,
-  normalizeHotmailMailboxId,
   normalizeHotmailMailApiMessages,
   parseHotmailImportText,
   pickHotmailAccountForRun,
@@ -346,22 +345,38 @@ test('pickVerificationMessageWithTimeFallback can ignore afterTimestamp while ke
   assert.equal(result.usedTimeFallback, true);
 });
 
-test('buildHotmailGraphMessagesUrl targets the official Microsoft Graph mailbox endpoint', () => {
-  const url = new URL(buildHotmailGraphMessagesUrl({
+test('buildHotmailMailApiLatestUrl includes email, client id, refresh token, and mailbox', () => {
+  const url = new URL(buildHotmailMailApiLatestUrl({
+    clientId: 'client-123',
+    email: 'user@hotmail.com',
+    refreshToken: 'refresh-token-xyz',
     mailbox: 'Junk',
   }));
 
-  assert.equal(url.origin + url.pathname, 'https://graph.microsoft.com/v1.0/me/mailFolders/junkemail/messages');
-  assert.equal(url.searchParams.get('$top'), '10');
-  assert.equal(url.searchParams.get('$orderby'), 'receivedDateTime desc');
-  assert.match(url.searchParams.get('$select'), /subject/);
-  assert.match(url.searchParams.get('$select'), /receivedDateTime/);
+  assert.equal(url.origin + url.pathname, 'https://apple.882263.xyz/api/mail-new');
+  assert.equal(url.searchParams.get('client_id'), 'client-123');
+  assert.equal(url.searchParams.get('email'), 'user@hotmail.com');
+  assert.equal(url.searchParams.get('refresh_token'), 'refresh-token-xyz');
+  assert.equal(url.searchParams.get('mailbox'), 'Junk');
+  assert.equal(url.searchParams.get('response_type'), 'json');
 });
 
-test('normalizeHotmailMailboxId maps supported mailbox labels to Graph folder ids', () => {
-  assert.equal(normalizeHotmailMailboxId('INBOX'), 'inbox');
-  assert.equal(normalizeHotmailMailboxId('Junk'), 'junkemail');
-  assert.equal(normalizeHotmailMailboxId('junkemail'), 'junkemail');
+test('buildHotmailMailApiLatestUrl supports custom api url and can omit response_type', () => {
+  const url = new URL(buildHotmailMailApiLatestUrl({
+    apiUrl: 'https://example.com/custom-mail-api',
+    clientId: 'client-789',
+    email: 'custom@hotmail.com',
+    refreshToken: 'refresh-token-custom',
+    mailbox: 'Spam',
+    responseType: '',
+  }));
+
+  assert.equal(url.origin + url.pathname, 'https://example.com/custom-mail-api');
+  assert.equal(url.searchParams.get('client_id'), 'client-789');
+  assert.equal(url.searchParams.get('email'), 'custom@hotmail.com');
+  assert.equal(url.searchParams.get('refresh_token'), 'refresh-token-custom');
+  assert.equal(url.searchParams.get('mailbox'), 'Spam');
+  assert.equal(url.searchParams.has('response_type'), false);
 });
 
 test('normalizeHotmailMailApiMessages maps third-party payload fields into verification message shape', () => {
@@ -440,24 +455,9 @@ test('getHotmailVerificationRequestTimestamp prefers actual request timestamps w
   );
 });
 
-test('getHotmailGraphRequestConfig defines Microsoft Graph request defaults', () => {
-  assert.deepEqual(getHotmailGraphRequestConfig(), {
+test('getHotmailMailApiRequestConfig defines third-party mail API request defaults', () => {
+  assert.deepEqual(getHotmailMailApiRequestConfig(), {
     timeoutMs: 15000,
-    pageSize: 10,
-    scopes: [
-      'offline_access',
-      'https://graph.microsoft.com/Mail.Read',
-      'https://graph.microsoft.com/User.Read',
-    ],
-    tokenUrl: 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token',
-    messageFields: [
-      'id',
-      'internetMessageId',
-      'subject',
-      'from',
-      'bodyPreview',
-      'receivedDateTime',
-    ],
   });
 });