This commit is contained in:
QLHazyCoder
2026-04-14 01:51:51 +08:00
13 changed files with 2143 additions and 283 deletions
@@ -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
View File
@@ -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`
+375 -198
View File
@@ -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';
@@ -45,6 +43,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();
@@ -72,6 +75,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: [],
@@ -256,6 +262,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':
@@ -295,6 +357,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':
@@ -526,16 +594,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),
@@ -590,8 +656,6 @@ async function upsertHotmailAccount(input) {
const normalized = normalizeHotmailAccount({
...(existing || {}),
...(credentialsChanged ? {
accessToken: '',
expiresAt: 0,
status: 'pending',
lastAuthAt: 0,
lastError: '',
@@ -727,7 +791,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 账号缺少邮箱地址。');
}
@@ -738,7 +812,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,
@@ -787,12 +863,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(),
@@ -800,18 +874,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);
@@ -822,12 +910,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 账号缺少邮箱地址。');
}
@@ -838,97 +921,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',
},
body: formData.toString(),
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;
} finally {
clearTimeout(timeoutId);
}
const text = await response.text();
let payload = {};
try {
payload = text ? JSON.parse(text) : {};
} catch {
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;
}
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: '',
});
}
async function requestHotmailGraphMessages(account, mailbox = 'INBOX') {
const { timeoutMs, pageSize, messageFields } = getHotmailGraphRequestConfig();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(new Error('timeout')), timeoutMs);
const url = buildHotmailGraphMessagesUrl({
mailbox,
top: pageSize,
selectFields: messageFields,
});
let response;
try {
response = await fetch(url, {
method: 'GET',
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,
top: 5,
}),
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);
}
@@ -941,80 +961,170 @@ 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}`);
}
return {
mailbox,
payload,
messages: normalizeHotmailMailApiMessages(payload?.value),
};
}
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),
}));
function buildHotmailAuthFailureAccount(account, errorMessage) {
return normalizeHotmailAccount({
...account,
accessToken: '',
expiresAt: 0,
status: 'error',
lastError: String(errorMessage || ''),
const nextAccount = applyHotmailApiResultToAccount(account, {
nextRefreshToken: String(payload?.nextRefreshToken || '').trim(),
});
}
async function fetchHotmailMailboxMessages(account, mailboxes = HOTMAIL_MAILBOXES) {
let workingAccount = normalizeHotmailAccount(account);
const mailboxResults = [];
try {
if (!isHotmailAccessTokenUsable(workingAccount)) {
workingAccount = await refreshHotmailAccessToken(workingAccount);
}
for (const mailbox of mailboxes) {
let result;
try {
result = await requestHotmailGraphMessages(workingAccount, mailbox);
} catch (err) {
if (err?.code !== 'HOTMAIL_GRAPH_AUTH_FAILED') {
throw err;
}
workingAccount = await refreshHotmailAccessToken({
...workingAccount,
accessToken: '',
expiresAt: 0,
});
result = await requestHotmailGraphMessages(workingAccount, mailbox);
}
mailboxResults.push({
mailbox,
count: result.messages.length,
messages: result.messages.map((message) => ({ ...message, mailbox })),
});
}
} catch (err) {
if (err?.code === 'HOTMAIL_TOKEN_REFRESH_FAILED' || err?.code === 'HOTMAIL_GRAPH_AUTH_FAILED') {
const failedAccount = buildHotmailAuthFailureAccount(workingAccount, err.message);
await upsertHotmailAccount(failedAccount);
}
throw err;
}
const savedAccount = await upsertHotmailAccount(workingAccount);
const savedAccount = await upsertHotmailAccount(nextAccount);
return {
account: savedAccount,
mailboxResults,
messages: mailboxResults.flatMap((item) => item.messages),
messages: normalizedMessages,
};
}
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')), requestTimeoutMs);
let response;
try {
response = await fetch(buildHotmailLocalEndpoint(serviceSettings.localBaseUrl, '/code'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
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) {
if (err?.name === 'AbortError') {
throw new Error(`Hotmail 本地助手请求超时(>${Math.round(requestTimeoutMs / 1000)} 秒)`);
}
throw new Error(`Hotmail 本地助手请求失败:${err.message}`);
} finally {
clearTimeout(timeoutId);
}
const text = await response.text();
let payload = {};
try {
payload = text ? JSON.parse(text) : {};
} catch {
payload = { raw: text };
}
if (!response.ok || payload?.ok === false) {
const errorText = payload?.error || payload?.message || text || `HTTP ${response.status}`;
throw new Error(`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 {
account: savedAccount,
code: String(payload?.code || ''),
message: normalizedMessage,
usedTimeFallback: Boolean(payload?.usedTimeFallback),
selectionSource: String(payload?.selectionSource || ''),
};
}
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 (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');
}
await addLog(`步骤 ${step}:已通过本地助手在 Hotmail ${mailboxLabel} 中找到验证码:${fetchResult.code}`, 'ok');
return {
ok: true,
code: fetchResult.code,
emailTimestamp: fetchResult.message?.receivedTimestamp || Date.now(),
mailId: fetchResult.message?.id || '',
};
}
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');
}
if (attempt < maxAttempts) {
await sleepWithStop(intervalMs);
}
}
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) {
const state = await getState();
const account = findHotmailAccount(state.hotmailAccounts, accountId);
@@ -1062,6 +1172,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;
@@ -1982,7 +2097,7 @@ function getSourceLabel(source) {
'mail-2925': '2925 邮箱',
'inbucket-mail': 'Inbucket 邮箱',
'duck-mail': 'Duck 邮箱',
'hotmail-api': 'Hotmail微软 Graph',
'hotmail-api': 'Hotmail远程/本地',
};
return labels[source] || source || '未知来源';
}
@@ -2693,7 +2808,11 @@ async function handleMessage(message, sender) {
await setPersistentSettings({ emailPrefix: message.payload.emailPrefix });
await setState({ emailPrefix: message.payload.emailPrefix });
}
await executeStep(step);
if (doesStepUseCompletionSignal(step)) {
await executeStepViaCompletionSignal(step);
} else {
await executeStep(step);
}
return { ok: true };
}
@@ -2766,7 +2885,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': {
@@ -2975,6 +3094,7 @@ const stepWaiters = new Map();
let resumeWaiter = null;
const AUTO_RUN_SIGNAL_COMPLETION_TIMEOUT_MS = 120000;
const AUTO_RUN_BACKGROUND_COMPLETED_STEPS = new Set([4, 7, 8]);
const STEP_COMPLETION_SIGNAL_STEPS = new Set([1, 2, 3, 5, 6, 9]);
function waitForStepComplete(step, timeoutMs = 120000) {
return new Promise((resolve, reject) => {
@@ -2996,6 +3116,10 @@ function waitForStepComplete(step, timeoutMs = 120000) {
});
}
function doesStepUseCompletionSignal(step) {
return STEP_COMPLETION_SIGNAL_STEPS.has(step);
}
function notifyStepComplete(step, payload) {
const waiter = stepWaiters.get(step);
console.log(LOG_PREFIX, `[notifyStepComplete] step ${step}, hasWaiter=${Boolean(waiter)}`);
@@ -3021,6 +3145,66 @@ async function completeStepFromBackground(step, payload = {}) {
notifyStepComplete(step, payload);
}
async function finalizeDeferredStepExecutionError(step, error) {
const latestState = await getState();
const currentStatus = latestState.stepStatuses?.[step];
if (currentStatus === 'completed' || currentStatus === 'failed' || currentStatus === 'stopped') {
return;
}
if (isStopError(error)) {
await setStepStatus(step, 'stopped');
await addLog(`步骤 ${step} 已被用户停止`, 'warn');
return;
}
await setStepStatus(step, 'failed');
await addLog(`步骤 ${step} 失败:${getErrorMessage(error)}`, 'error');
}
async function executeStepViaCompletionSignal(step, timeoutMs = AUTO_RUN_SIGNAL_COMPLETION_TIMEOUT_MS) {
const completionResultPromise = waitForStepComplete(step, timeoutMs).then(
payload => ({ ok: true, payload }),
error => ({ ok: false, error }),
);
let executeError = null;
try {
await executeStep(step, { deferRetryableTransportError: true });
} catch (err) {
executeError = err;
if (isStopError(err) || !isRetryableContentScriptTransportError(err)) {
notifyStepError(step, getErrorMessage(err));
}
}
const completionResult = await completionResultPromise;
if (completionResult.ok) {
if (executeError) {
console.warn(
LOG_PREFIX,
`[executeStepViaCompletionSignal] step ${step} completed after deferred execute error: ${getErrorMessage(executeError)}`
);
}
return completionResult.payload;
}
if (executeError && isRetryableContentScriptTransportError(executeError)) {
const completionMessage = getErrorMessage(completionResult.error);
if (/等待超时/.test(completionMessage)) {
await finalizeDeferredStepExecutionError(step, executeError);
throw executeError;
}
throw completionResult.error;
}
if (executeError) {
throw executeError;
}
throw completionResult.error;
}
async function waitForRunningStepsToFinish(payload = {}) {
let currentState = await getState();
let runningSteps = getRunningSteps(currentState.stepStatuses);
@@ -3096,7 +3280,8 @@ async function requestStop(options = {}) {
// Step Execution
// ============================================================
async function executeStep(step) {
async function executeStep(step, options = {}) {
const { deferRetryableTransportError = false } = options;
console.log(LOG_PREFIX, `Executing step ${step}`);
throwIfStopped();
await setStepStatus(step, 'running');
@@ -3130,8 +3315,15 @@ async function executeStep(step) {
await addLog(`步骤 ${step} 已被用户停止`, 'warn');
throw err;
}
await setStepStatus(step, 'failed');
await addLog(`步骤 ${step} 失败:${err.message}`, 'error');
if (!(deferRetryableTransportError && doesStepUseCompletionSignal(step) && isRetryableContentScriptTransportError(err))) {
await setStepStatus(step, 'failed');
await addLog(`步骤 ${step} 失败:${err.message}`, 'error');
} else {
console.warn(
LOG_PREFIX,
`[executeStep] deferring retryable transport error for step ${step}: ${getErrorMessage(err)}`
);
}
throw err;
}
}
@@ -3158,27 +3350,12 @@ async function executeStepAndWait(step, delayAfter = 2000) {
await executeStep(step);
const latestState = await getState();
await addLog(`自动运行:步骤 ${step} 已执行返回,当前状态为 ${latestState.stepStatuses?.[step] || 'pending'},准备继续后续步骤。`, 'info');
} else {
} else if (doesStepUseCompletionSignal(step)) {
await addLog(`自动运行:步骤 ${step} 已发起,正在等待完成信号(超时 ${AUTO_RUN_SIGNAL_COMPLETION_TIMEOUT_MS / 1000} 秒)。`, 'info');
const completionResultPromise = waitForStepComplete(step, AUTO_RUN_SIGNAL_COMPLETION_TIMEOUT_MS).then(
payload => ({ ok: true, payload }),
error => ({ ok: false, error }),
);
try {
await executeStep(step);
} catch (err) {
notifyStepError(step, getErrorMessage(err));
await completionResultPromise;
throw err;
}
const completionResult = await completionResultPromise;
if (!completionResult.ok) {
throw completionResult.error;
}
await executeStepViaCompletionSignal(step, AUTO_RUN_SIGNAL_COMPLETION_TIMEOUT_MS);
await addLog(`自动运行:步骤 ${step} 已收到完成信号,准备继续后续步骤。`, 'info');
} else {
await executeStep(step);
}
// Extra delay for page transitions / DOM updates
@@ -4309,7 +4486,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 邮箱' };
+2 -1
View File
@@ -26,6 +26,7 @@
console.log('[MultiPage:vps-panel] Content script loaded on', location.href);
const VPS_PANEL_LISTENER_SENTINEL = 'data-multipage-vps-panel-listener';
const STEP9_SUCCESS_BADGE_TIMEOUT_MS = 120000;
const {
isRecoverableStep9AuthFailure,
} = self.MultiPageActivationUtils || {};
@@ -205,7 +206,7 @@ function isOAuthCallbackTimeoutFailure(statusText) {
return /认证失败:\s*Timeout waiting for OAuth callback/i.test(statusText || '');
}
async function waitForExactSuccessBadge(timeout = 30000) {
async function waitForExactSuccessBadge(timeout = STEP9_SUCCESS_BADGE_TIMEOUT_MS) {
const start = Date.now();
while (Date.now() - start < timeout) {
+400
View File
@@ -0,0 +1,400 @@
# AI 版本发布标准流程
## 用途
本文用于给 AI 一个固定、完整、可重复执行的版本发布流程。
当用户在本仓库中引用本文,并明确说出类似下面的话时:
- `更新版本到 6.5.0`
- `按本文发布 6.5.0`
- `引用这个文件,把版本更新到 6.5.0 并发版`
AI 必须按本文执行,不能猜流程,不能跳步骤,不能只做一半。
## 目标
AI 在收到“更新版本到 x.x.x”后,必须完成下面两段流程。
1. 发布前流程。
修改 `manifest.json` 中的版本号。
提交本次准备发布的代码。
基于“上一个 GitHub Release 到当前 HEAD”的真实差异分析更新内容。
把发布文案先返回给用户看,且必须放在代码块里。
2. 用户确认后的发布流程。
把用户确认过的文案作为 GitHub Release 正文发布。
发布完成后,再次从 GitHub 线上读取正文。
审查正文是否乱码、是否异常、是否与用户确认稿一致。
如果有问题,必须立刻修正后再汇报完成。
## 仓库硬性规则
1. 任何结论都不能猜,必须基于真实命令输出、真实 diff、真实 GitHub 返回结果。
2. GitHub Releases 是版本发布真相源。分析“距离上一个版本更新了什么”时,默认以上一个正式 Release 为起点。
3. 不要先去检查 `gh` 是否存在、是否登录、是否在 PATH、是否可用,也不要先跑 `gh auth status`。直接使用固定路径命令执行。
4. `gh` 固定路径使用:
```powershell
$GH = 'C:\Program Files\GitHub CLI\gh.exe'
if (-not (Test-Path $GH)) {
throw 'gh.exe fixed path not found: C:\Program Files\GitHub CLI\gh.exe'
}
```
5. 默认 GitHub 仓库固定为:
```powershell
$Repo = 'QLHazyCoder/codex-oauth-automation-extension'
```
6. 默认不编译、不跑测试。完成后提醒用户自行测试。
7. 如果当前工作区里存在无法确认是否属于本次发布的改动,必须先停下来问用户,不能猜哪些该一起发、哪些不该一起发。
8. 发布完成后,必须做一次“线上正文复检”,确认不是乱码,不是空正文,不是错误版本,不是错误标签。
## 输入要求
AI 至少要从用户输入中拿到下面这些信息:
- 目标版本号,例如 `6.5.0`
- 是否已经确认可以发布
- 如果用户有特别强调的更新点,也要纳入发布文案
如果用户没有给版本号,就不能猜。
## 固定变量模板
每次执行本文时,先统一使用下面这组变量:
```powershell
$GH = 'C:\Program Files\GitHub CLI\gh.exe'
if (-not (Test-Path $GH)) {
throw 'gh.exe fixed path not found: C:\Program Files\GitHub CLI\gh.exe'
}
$Repo = 'QLHazyCoder/codex-oauth-automation-extension'
$TargetVersion = '<用户指定的版本号,例如 6.5.0>'
$TargetTag = "v$TargetVersion"
$ManifestPath = 'manifest.json'
```
## 阶段 1:发布前检查
### 1. 真实查看当前工作区
先执行:
```powershell
git status --short --branch
git remote -v
```
要求:
1. 必须看清当前分支和当前未提交改动。
2. 如果工作区中有改动,必须结合 diff 判断这些改动是不是本次要发布的真实内容。
3. 如果有无法确认归属的改动,必须先问用户,不准猜。
### 2. 读取当前版本号
执行:
```powershell
$Manifest = Get-Content -Path $ManifestPath -Raw | ConvertFrom-Json
$CurrentVersion = [string]$Manifest.version
$CurrentVersion
```
要求:
1. 当前版本必须从 `manifest.json` 真实读取。
2. 目标版本必须大于当前版本,不能相等,不能更小。
3. 版本比较必须按版本号比较,不能按字符串比较。
示例校验:
```powershell
if ([version]$TargetVersion -le [version]$CurrentVersion) {
throw "Target version $TargetVersion must be greater than current version $CurrentVersion"
}
```
### 3. 检查目标版本是否已经存在
先查 GitHub Releases
```powershell
$ExistingReleaseTag = ([string](& $GH api "repos/$Repo/releases?per_page=100" --jq ".[] | select(.tag_name == `"$TargetTag`") | .tag_name")).Trim()
```
再查本地 tag
```powershell
$ExistingLocalTag = ([string](git tag --list $TargetTag)).Trim()
```
再查最新一个正式 Release
```powershell
$LatestReleaseTag = ([string](& $GH api "repos/$Repo/releases?per_page=100" --jq "[.[] | select(.draft == false and .prerelease == false)][0].tag_name")).Trim()
$LatestReleaseVersion = if ($LatestReleaseTag) { $LatestReleaseTag.TrimStart('v') } else { '' }
```
规则:
1. 只要 GitHub Release 已存在这个 tag,就必须停止,不能重复发版。
2. 如果本地 tag 已存在,但 GitHub Release 不存在,也必须停止并告知用户先确认历史状态,不能直接覆盖。
3. 如果已经存在最新正式 Release,目标版本还必须大于这个正式 Release 的版本号,不能倒发版。
示例校验:
```powershell
if ($LatestReleaseVersion -and ([version]$TargetVersion -le [version]$LatestReleaseVersion)) {
throw "Target version $TargetVersion must be greater than latest released version $LatestReleaseVersion"
}
```
## 阶段 2:更新版本号并提交
### 1. 修改 `manifest.json`
AI 必须只把 `manifest.json` 中的 `version` 更新为目标版本。
### 2. 检查变更内容
执行:
```powershell
git diff --stat
git diff -- manifest.json
```
如果工作区里还有其他变更,也要继续真实查看它们的 diff,确认是否属于本次发布内容。
### 3. 提交本次发布
规则:
1. 如果当前工作区改动就是本次要发布的功能改动,就应与版本号一起提交。
2. 如果存在来源不明或明显无关的改动,必须先停下来问用户,不能偷偷一起提交,也不能擅自丢掉。
3. 提交信息统一使用:
```text
chore(release): bump version to vX.Y.Z
```
示例:
```powershell
git add -A
git commit -m "chore(release): bump version to $TargetTag"
```
提交后,必须记录真实提交哈希:
```powershell
git rev-parse HEAD
```
## 阶段 3:分析“距离上一个版本更新了什么”
### 1. 获取上一个正式 Release
执行:
```powershell
$PreviousReleaseTag = ([string](& $GH api "repos/$Repo/releases?per_page=100" --jq "[.[] | select(.draft == false and .prerelease == false)][0].tag_name")).Trim()
$PreviousReleaseTag
```
规则:
1. 默认取最新一个正式 Release 作为“上一个版本”。
2. 如果仓库还没有任何正式 Release,再退回到本地最新 tag
```powershell
if (-not $PreviousReleaseTag) {
$PreviousReleaseTag = ([string](git tag --sort=-creatordate | Select-Object -First 1)).Trim()
}
```
3. 如果仍然拿不到上一个版本,就按“首个版本发布”处理,并明确告诉用户这是首发版文案。
### 2. 拉真实变更范围
如果存在上一个版本,执行:
```powershell
git log --oneline "$PreviousReleaseTag"..HEAD
git diff --stat "$PreviousReleaseTag"..HEAD
git diff --name-only "$PreviousReleaseTag"..HEAD
```
然后根据变更文件继续往下读真实 diff,不能只看 commit 标题。
必要时继续执行:
```powershell
git diff "$PreviousReleaseTag"..HEAD -- <具体文件路径>
```
### 3. 文案分析要求
AI 必须基于真实 diff 和真实代码上下文,分析并提炼:
- 新增了什么功能
- 修复了什么问题
- 优化了什么逻辑
- 删除了什么无用或陈旧逻辑
- 哪些点最值得放进发布说明
禁止行为:
- 只看 commit message 就编文案
- 把没有真实证据的内容写进发布说明
- 把技术细节胡乱拔高成“重大更新”
## 阶段 4:先给用户看发布文案,不要直接发布
在真正发布前,AI 必须把文案返回给用户确认。
返回给用户时,必须同时说明:
1. `manifest.json` 已改到哪个版本
2. 是否已经提交
3. 本次提交哈希是什么
4. 上一个版本标签是什么
5. 发布文案草稿是什么
发布文案草稿必须放在代码块中,格式建议如下:
```markdown
## vX.Y.Z
### 更新内容
- ...
- ...
### 修复与优化
- ...
- ...
```
注意:
1. 这里必须先停下来等用户确认。
2. 用户没确认前,不准创建 GitHub Release。
3. 如果用户要求改文案,只改文案,不要乱改已经完成的代码与提交,除非用户明确要求。
## 阶段 5:用户确认后再发布 GitHub Release
### 1. 先推送当前提交
发布前先把当前分支 HEAD 推到远端:
```powershell
git push origin HEAD
```
### 2. 把用户确认过的文案写入 UTF-8 文件
不要直接把长正文硬塞进命令参数里,先写入临时文件。
示例:
```powershell
$ReleaseNotesFile = Join-Path $env:TEMP "codex-release-$TargetVersion.md"
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($ReleaseNotesFile, $ReleaseNotes, $Utf8NoBom)
```
### 3. 创建 Release
执行:
```powershell
& $GH release create $TargetTag `
--repo $Repo `
--target HEAD `
--title $TargetTag `
--notes-file $ReleaseNotesFile
```
要求:
1. 标题默认使用 `vX.Y.Z`
2. 正文必须使用用户确认过的最终文案。
3. 不要在用户没确认前抢先发版。
## 阶段 6:发布后线上正文复检
Release 创建完成后,必须立刻重新读取线上内容。
执行:
```powershell
& $GH api "repos/$Repo/releases/tags/$TargetTag"
& $GH api "repos/$Repo/releases/tags/$TargetTag" --jq '.html_url'
& $GH api "repos/$Repo/releases/tags/$TargetTag" --jq '.tag_name'
& $GH api "repos/$Repo/releases/tags/$TargetTag" --jq '.name'
& $GH api "repos/$Repo/releases/tags/$TargetTag" --jq '.body'
```
复检重点:
1. 线上 tag 是否真的是目标 tag
2. 线上标题是否真的是目标版本
3. 线上正文是否完整
4. 正文是否与用户确认稿一致
5. 是否存在乱码、异常字符、错误换行、空正文
尤其注意下面这些异常:
- 出现明显乱码字符
- 中文被破坏
- 正文为空
- 版本号不对
- 正文内容被截断
### 如果复检发现异常
必须立刻修复,不准带病汇报完成。
修复示例:
```powershell
& $GH release edit $TargetTag `
--repo $Repo `
--title $TargetTag `
--notes-file $ReleaseNotesFile
```
修复后,再重复执行一次“发布后线上正文复检”,直到线上内容正常为止。
## 用户侧最终反馈要求
AI 完成后,对用户的最终反馈至少要说明:
1. 已把 `manifest.json` 更新到哪个版本
2. 是否已经提交,提交哈希是什么
3. 上一个版本是什么
4. 发布文案是否经过用户确认
5. 是否已经创建 GitHub Release
6. 是否已经完成线上正文复检
7. 如果没跑测试,要明确提醒用户自行测试
## 一句话执行要求
当用户引用本文并说“更新版本到 x.x.x”时,AI 必须按下面顺序执行:
1. 真实检查工作区与当前版本
2. 真实校验目标版本是否合法且未发布
3. 修改 `manifest.json` 版本号
4. 提交本次待发布改动
5. 基于上一个正式 Release 到当前 HEAD 的真实差异分析更新内容
6. 把发布文案放进代码块返回给用户确认
7. 得到确认后再推送并创建 GitHub Release
8. 发布完成后再次读取线上正文并检查是否乱码
9. 如有异常,先修复再汇报
不能跳步骤,不能猜,不能偷懒。
+16 -37
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
@@ -256,15 +256,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
View File
@@ -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 = {
@@ -831,6 +840,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: true,
@@ -847,6 +859,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);
@@ -861,6 +877,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;
@@ -944,11 +977,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);
}
@@ -1076,6 +1113,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 = true;
@@ -1626,6 +1666,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';
@@ -1647,6 +1688,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();
@@ -2238,6 +2288,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;
@@ -2496,6 +2555,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);
@@ -2709,6 +2784,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
View File
@@ -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
View File
@@ -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
View File
@@ -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',
],
});
});