dsh-plugin-image-workbench 1.1.5 → 1.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/LICENSE +21 -21
  2. package/README.en.md +5 -3
  3. package/README.md +7 -3
  4. package/client.js +241 -241
  5. package/cordis.patch.yml +14 -14
  6. package/host.js +51 -0
  7. package/install.mjs +333 -387
  8. package/package.json +3 -2
  9. package/scripts/fetch-style-images.mjs +104 -0
  10. package/scripts/vendor-snapshot.py +154 -154
  11. package/server/access.py +26 -26
  12. package/server/app.py +1371 -1329
  13. package/server/catalog.py +147 -147
  14. package/server/composer.py +391 -391
  15. package/server/drafts.py +158 -158
  16. package/server/history.py +575 -575
  17. package/server/imaging.py +172 -172
  18. package/server/pool.py +1335 -1335
  19. package/server/pool_admin.py +921 -921
  20. package/server/pool_service.py +1073 -1073
  21. package/server/requirements.txt +6 -6
  22. package/server/smoke_check.py +402 -402
  23. package/server/static/access.html +202 -202
  24. package/server/static/index.html +15 -0
  25. package/vendor/chatgpt2api/source/.env.example +82 -82
  26. package/vendor/chatgpt2api/source/.python-version +1 -1
  27. package/vendor/chatgpt2api/source/LICENSE +21 -21
  28. package/vendor/chatgpt2api/source/README.md +391 -391
  29. package/vendor/chatgpt2api/source/VERSION +1 -1
  30. package/vendor/chatgpt2api/source/api/__init__.py +2 -2
  31. package/vendor/chatgpt2api/source/api/accounts.py +537 -537
  32. package/vendor/chatgpt2api/source/api/ai.py +217 -217
  33. package/vendor/chatgpt2api/source/api/app.py +62 -62
  34. package/vendor/chatgpt2api/source/api/errors.py +46 -46
  35. package/vendor/chatgpt2api/source/api/image_inputs.py +309 -309
  36. package/vendor/chatgpt2api/source/api/image_tasks.py +120 -120
  37. package/vendor/chatgpt2api/source/api/support.py +134 -134
  38. package/vendor/chatgpt2api/source/api/system.py +365 -365
  39. package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -50
  40. package/vendor/chatgpt2api/source/config.default.json +108 -108
  41. package/vendor/chatgpt2api/source/main.py +9 -9
  42. package/vendor/chatgpt2api/source/pyproject.toml +27 -27
  43. package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -186
  44. package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -163
  45. package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -13
  46. package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -385
  47. package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -129
  48. package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -92
  49. package/vendor/chatgpt2api/source/services/account_service.py +1757 -1757
  50. package/vendor/chatgpt2api/source/services/auth_service.py +253 -253
  51. package/vendor/chatgpt2api/source/services/backup_service.py +671 -671
  52. package/vendor/chatgpt2api/source/services/config.py +667 -667
  53. package/vendor/chatgpt2api/source/services/content_filter.py +242 -242
  54. package/vendor/chatgpt2api/source/services/cpa_service.py +315 -315
  55. package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -260
  56. package/vendor/chatgpt2api/source/services/image_service.py +376 -376
  57. package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -405
  58. package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -76
  59. package/vendor/chatgpt2api/source/services/image_task_service.py +550 -550
  60. package/vendor/chatgpt2api/source/services/log_service.py +337 -337
  61. package/vendor/chatgpt2api/source/services/model_service.py +182 -182
  62. package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -267
  63. package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -2763
  64. package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -32
  65. package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -2
  66. package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -307
  67. package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -238
  68. package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -1644
  69. package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -124
  70. package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -19
  71. package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -316
  72. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -86
  73. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -42
  74. package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -53
  75. package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -453
  76. package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -160
  77. package/vendor/chatgpt2api/source/services/proxy_service.py +648 -648
  78. package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -5
  79. package/vendor/chatgpt2api/source/services/storage/base.py +38 -38
  80. package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -194
  81. package/vendor/chatgpt2api/source/services/storage/factory.py +104 -104
  82. package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -183
  83. package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -95
  84. package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -531
  85. package/vendor/chatgpt2api/source/utils/helper.py +469 -469
  86. package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -346
  87. package/vendor/chatgpt2api/source/utils/log.py +110 -110
  88. package/vendor/chatgpt2api/source/utils/pkce.py +19 -19
  89. package/vendor/chatgpt2api/source/utils/pow.py +207 -207
  90. package/vendor/chatgpt2api/source/utils/sentinel.py +159 -159
  91. package/vendor/chatgpt2api/source/utils/turnstile.py +184 -184
  92. package/vendor/chatgpt2api/source/uv.lock +904 -904
  93. package/vendor/chatgpt2api/source/web/bun.lock +1501 -1501
  94. package/vendor/chatgpt2api/source/web/components.json +24 -24
  95. package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -17
  96. package/vendor/chatgpt2api/source/web/next.config.ts +40 -40
  97. package/vendor/chatgpt2api/source/web/package.json +58 -58
  98. package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -5
  99. package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -925
  100. package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -1307
  101. package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -224
  102. package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -444
  103. package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -9
  104. package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -9
  105. package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -172
  106. package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -169
  107. package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -39
  108. package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -59
  109. package/vendor/chatgpt2api/source/web/src/app/globals.css +230 -230
  110. package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -542
  111. package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -554
  112. package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -176
  113. package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -1799
  114. package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -732
  115. package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -50
  116. package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -101
  117. package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -356
  118. package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -30
  119. package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -341
  120. package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -447
  121. package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -71
  122. package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -491
  123. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -108
  124. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -169
  125. package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -185
  126. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -379
  127. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -127
  128. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -200
  129. package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -12
  130. package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -886
  131. package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -81
  132. package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -422
  133. package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -135
  134. package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -1069
  135. package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -49
  136. package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -24
  137. package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -434
  138. package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -46
  139. package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -18
  140. package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -14
  141. package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -263
  142. package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -256
  143. package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -41
  144. package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -59
  145. package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -46
  146. package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -61
  147. package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -27
  148. package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -267
  149. package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -132
  150. package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -13
  151. package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -19
  152. package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -24
  153. package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -149
  154. package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -38
  155. package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -175
  156. package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -143
  157. package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -29
  158. package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -91
  159. package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -269
  160. package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -18
  161. package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -127
  162. package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -6
  163. package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -85
  164. package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -926
  165. package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -26
  166. package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -25
  167. package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -93
  168. package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -89
  169. package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -6
  170. package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -112
  171. package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -33
  172. package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -318
  173. package/vendor/chatgpt2api/source/web/tsconfig.json +42 -42
@@ -1,385 +1,385 @@
1
- from __future__ import annotations
2
-
3
- import argparse
4
- import imaplib
5
- import json
6
- import re
7
- import sys
8
- import urllib.error
9
- import urllib.parse
10
- import urllib.request
11
- from dataclasses import dataclass
12
- from email import message_from_bytes, policy
13
- from email.header import decode_header, make_header
14
- from email.message import Message
15
- from email.utils import parsedate_to_datetime
16
- from pathlib import Path
17
- from typing import Any
18
-
19
-
20
- TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
21
- GRAPH_MESSAGES_URL = "https://graph.microsoft.com/v1.0/me/messages"
22
- GRAPH_SCOPE = "offline_access https://graph.microsoft.com/Mail.Read"
23
- IMAP_SCOPE = "offline_access https://outlook.office.com/IMAP.AccessAsUser.All"
24
- DEFAULT_IMAP_HOST = "outlook.office365.com"
25
-
26
-
27
- @dataclass(frozen=True)
28
- class OutlookCredential:
29
- email: str
30
- password: str
31
- client_id: str
32
- refresh_token: str
33
- line_number: int
34
-
35
-
36
- @dataclass(frozen=True)
37
- class HttpResponse:
38
- status_code: int
39
- text: str
40
-
41
- def json(self) -> Any:
42
- return json.loads(self.text)
43
-
44
-
45
- def _clean(value: str) -> str:
46
- return value.replace("\ufeff", "").replace("\u00a0", " ").strip()
47
-
48
-
49
- def _redact_email(email: str) -> str:
50
- local, sep, domain = email.partition("@")
51
- if not sep:
52
- return "***"
53
- if len(local) <= 2:
54
- masked = local[:1] + "***"
55
- else:
56
- masked = local[:2] + "***" + local[-1:]
57
- return f"{masked}@{domain}"
58
-
59
-
60
- def parse_credentials(path: Path) -> list[OutlookCredential]:
61
- credentials: list[OutlookCredential] = []
62
- for line_number, raw_line in enumerate(path.read_text(encoding="utf-8-sig").splitlines(), start=1):
63
- line = _clean(raw_line)
64
- if not line or "----" not in line:
65
- continue
66
- parts = [_clean(part) for part in line.split("----", 3)]
67
- if len(parts) != 4:
68
- continue
69
- email, password, client_id, refresh_token = parts
70
- if "@" not in email or not client_id or not refresh_token:
71
- continue
72
- credentials.append(
73
- OutlookCredential(
74
- email=email,
75
- password=password,
76
- client_id=client_id,
77
- refresh_token=refresh_token,
78
- line_number=line_number,
79
- )
80
- )
81
- return credentials
82
-
83
-
84
- def _http_request(
85
- method: str,
86
- url: str,
87
- *,
88
- headers: dict[str, str] | None = None,
89
- data: dict[str, str] | None = None,
90
- params: dict[str, Any] | None = None,
91
- timeout: float = 30,
92
- ) -> HttpResponse:
93
- target = url
94
- if params:
95
- query = urllib.parse.urlencode(params)
96
- target = f"{url}?{query}"
97
-
98
- body: bytes | None = None
99
- request_headers = dict(headers or {})
100
- if data is not None:
101
- body = urllib.parse.urlencode(data).encode("utf-8")
102
- request_headers.setdefault("Content-Type", "application/x-www-form-urlencoded")
103
-
104
- request = urllib.request.Request(target, data=body, headers=request_headers, method=method.upper())
105
- try:
106
- with urllib.request.urlopen(request, timeout=timeout) as response:
107
- return HttpResponse(
108
- status_code=int(response.status),
109
- text=response.read().decode("utf-8", errors="replace"),
110
- )
111
- except urllib.error.HTTPError as error:
112
- return HttpResponse(
113
- status_code=int(error.code),
114
- text=error.read().decode("utf-8", errors="replace"),
115
- )
116
-
117
-
118
- def exchange_refresh_token(credential: OutlookCredential, scope: str, timeout: float) -> str:
119
- response = _http_request(
120
- "POST",
121
- TOKEN_URL,
122
- data={
123
- "client_id": credential.client_id,
124
- "grant_type": "refresh_token",
125
- "refresh_token": credential.refresh_token,
126
- "scope": scope,
127
- },
128
- timeout=timeout,
129
- )
130
- try:
131
- data = response.json()
132
- except Exception:
133
- data = {}
134
- if response.status_code != 200:
135
- detail = data.get("error_description") or data.get("error") or response.text[:300]
136
- raise RuntimeError(f"token refresh failed: HTTP {response.status_code}, {detail}")
137
- access_token = str(data.get("access_token") or "").strip()
138
- if not access_token:
139
- raise RuntimeError("token refresh response did not include access_token")
140
- return access_token
141
-
142
-
143
- def _graph_sender(message: dict[str, Any]) -> str:
144
- sender = message.get("from") or {}
145
- if isinstance(sender, dict):
146
- address = sender.get("emailAddress") or {}
147
- if isinstance(address, dict):
148
- return str(address.get("address") or address.get("name") or "")
149
- return ""
150
-
151
-
152
- def read_graph_messages(access_token: str, limit: int, timeout: float) -> list[dict[str, str]]:
153
- response = _http_request(
154
- "GET",
155
- GRAPH_MESSAGES_URL,
156
- headers={"Authorization": f"Bearer {access_token}", "Accept": "application/json"},
157
- params={
158
- "$top": max(1, min(limit, 50)),
159
- "$orderby": "receivedDateTime desc",
160
- "$select": "subject,receivedDateTime,from,bodyPreview",
161
- },
162
- timeout=timeout,
163
- )
164
- try:
165
- data = response.json()
166
- except Exception:
167
- data = {}
168
- if response.status_code != 200:
169
- detail = data.get("error", {}).get("message") if isinstance(data.get("error"), dict) else response.text[:300]
170
- raise RuntimeError(f"graph messages failed: HTTP {response.status_code}, {detail}")
171
- items = data.get("value") if isinstance(data, dict) else None
172
- if not isinstance(items, list):
173
- raise RuntimeError("graph messages response did not include value[]")
174
- return [
175
- {
176
- "received": str(item.get("receivedDateTime") or ""),
177
- "from": _graph_sender(item),
178
- "subject": str(item.get("subject") or ""),
179
- "preview": str(item.get("bodyPreview") or ""),
180
- }
181
- for item in items
182
- if isinstance(item, dict)
183
- ]
184
-
185
-
186
- def _decode_header(value: str | None) -> str:
187
- if not value:
188
- return ""
189
- try:
190
- return str(make_header(decode_header(value)))
191
- except Exception:
192
- return value
193
-
194
-
195
- def _message_text_preview(message: Message, limit: int = 240) -> str:
196
- text_parts: list[str] = []
197
- parts = message.walk() if message.is_multipart() else [message]
198
- for part in parts:
199
- if part.get_content_maintype() == "multipart":
200
- continue
201
- content_type = part.get_content_type()
202
- if content_type not in {"text/plain", "text/html"}:
203
- continue
204
- try:
205
- payload = part.get_content()
206
- except Exception:
207
- continue
208
- if payload:
209
- text_parts.append(str(payload))
210
- if content_type == "text/plain" and text_parts:
211
- break
212
- text = "\n".join(text_parts)
213
- text = re.sub(r"<[^>]+>", " ", text)
214
- text = re.sub(r"\s+", " ", text).strip()
215
- return text[:limit]
216
-
217
-
218
- def _parse_imap_message(raw: bytes, include_preview: bool) -> dict[str, str]:
219
- message = message_from_bytes(raw, policy=policy.default)
220
- received = ""
221
- try:
222
- parsed = parsedate_to_datetime(str(message.get("Date") or ""))
223
- received = parsed.isoformat()
224
- except Exception:
225
- received = str(message.get("Date") or "")
226
- return {
227
- "received": received,
228
- "from": _decode_header(str(message.get("From") or "")),
229
- "subject": _decode_header(str(message.get("Subject") or "")),
230
- "preview": _message_text_preview(message) if include_preview else "",
231
- }
232
-
233
-
234
- def read_imap_messages(access_token: str, email: str, host: str, limit: int, include_preview: bool) -> list[dict[str, str]]:
235
- auth_string = f"user={email}\x01auth=Bearer {access_token}\x01\x01"
236
- mailbox = imaplib.IMAP4_SSL(host)
237
- try:
238
- mailbox.authenticate("XOAUTH2", lambda _: auth_string.encode("utf-8"))
239
- status, _ = mailbox.select("INBOX", readonly=True)
240
- if status != "OK":
241
- raise RuntimeError("imap select INBOX failed")
242
- status, data = mailbox.uid("search", None, "ALL")
243
- if status != "OK" or not data or not data[0]:
244
- return []
245
- uids = data[0].split()[-max(1, limit) :]
246
- messages: list[dict[str, str]] = []
247
- for uid in reversed(uids):
248
- status, fetched = mailbox.uid("fetch", uid, "(RFC822)")
249
- if status != "OK":
250
- continue
251
- raw_payload = next((item[1] for item in fetched if isinstance(item, tuple) and isinstance(item[1], bytes)), b"")
252
- if raw_payload:
253
- messages.append(_parse_imap_message(raw_payload, include_preview))
254
- return messages
255
- finally:
256
- try:
257
- mailbox.logout()
258
- except Exception:
259
- pass
260
-
261
-
262
- def print_messages(messages: list[dict[str, str]], include_preview: bool) -> None:
263
- if not messages:
264
- print(" no recent messages")
265
- return
266
- for index, message in enumerate(messages, start=1):
267
- print(f" {index}. {message['received']} | {message['from']} | {message['subject']}")
268
- if include_preview and message.get("preview"):
269
- print(f" preview: {message['preview']}")
270
-
271
-
272
- def test_credential(
273
- credential: OutlookCredential,
274
- mode: str,
275
- limit: int,
276
- timeout: float,
277
- imap_host: str,
278
- include_preview: bool,
279
- show_email: bool,
280
- ) -> bool:
281
- label = credential.email if show_email else _redact_email(credential.email)
282
- print(f"[line {credential.line_number}] {label}")
283
- errors: list[str] = []
284
-
285
- if mode in {"graph", "auto"}:
286
- try:
287
- access_token = exchange_refresh_token(credential, GRAPH_SCOPE, timeout)
288
- messages = read_graph_messages(access_token, limit, timeout)
289
- print(" graph: ok")
290
- print_messages(messages, include_preview)
291
- return True
292
- except Exception as error:
293
- errors.append(f"graph: {error}")
294
- if mode == "graph":
295
- print(f" graph: failed - {error}")
296
- return False
297
-
298
- if mode in {"imap", "auto"}:
299
- try:
300
- access_token = exchange_refresh_token(credential, IMAP_SCOPE, timeout)
301
- messages = read_imap_messages(access_token, credential.email, imap_host, limit, include_preview)
302
- print(" imap: ok")
303
- print_messages(messages, include_preview)
304
- return True
305
- except Exception as error:
306
- errors.append(f"imap: {error}")
307
- if mode == "imap":
308
- print(f" imap: failed - {error}")
309
- return False
310
-
311
- print(" failed")
312
- for error in errors:
313
- print(f" - {error}")
314
- return False
315
-
316
-
317
- def build_parser() -> argparse.ArgumentParser:
318
- parser = argparse.ArgumentParser(
319
- description="Test Outlook/Hotmail mailbox access from lines formatted as email----password----client_id----refresh_token.",
320
- )
321
- parser.add_argument("--file", default=r"D:\Desktop\yx.txt", help="Credential text file path.")
322
- parser.add_argument("--mode", choices=("auto", "graph", "imap"), default="auto", help="Mailbox read method.")
323
- parser.add_argument("--limit-accounts", type=int, default=1, help="How many accounts to test.")
324
- parser.add_argument("--message-limit", type=int, default=5, help="How many recent messages to list per account.")
325
- parser.add_argument("--timeout", type=float, default=30, help="HTTP request timeout in seconds.")
326
- parser.add_argument("--imap-host", default=DEFAULT_IMAP_HOST, help="IMAP host for XOAUTH2 mode.")
327
- parser.add_argument("--preview", action="store_true", help="Print body preview/snippet. Disabled by default.")
328
- parser.add_argument("--show-email", action="store_true", help="Print full email address. Secrets are never printed.")
329
- parser.add_argument("--json", action="store_true", help="Only parse the file and print non-secret account metadata as JSON.")
330
- return parser
331
-
332
-
333
- def main() -> int:
334
- args = build_parser().parse_args()
335
- path = Path(args.file)
336
- if not path.exists():
337
- print(f"file not found: {path}", file=sys.stderr)
338
- return 2
339
-
340
- credentials = parse_credentials(path)
341
- if not credentials:
342
- print(f"no valid credentials parsed from: {path}", file=sys.stderr)
343
- return 2
344
-
345
- limit_accounts = max(1, int(args.limit_accounts or 1))
346
- selected = credentials[:limit_accounts]
347
-
348
- if args.json:
349
- print(
350
- json.dumps(
351
- [
352
- {
353
- "line": item.line_number,
354
- "email": item.email if args.show_email else _redact_email(item.email),
355
- "client_id": item.client_id,
356
- "has_password": bool(item.password),
357
- "has_refresh_token": bool(item.refresh_token),
358
- }
359
- for item in selected
360
- ],
361
- ensure_ascii=False,
362
- indent=2,
363
- )
364
- )
365
- return 0
366
-
367
- print(f"parsed {len(credentials)} credential(s), testing {len(selected)}")
368
- success = 0
369
- for credential in selected:
370
- if test_credential(
371
- credential=credential,
372
- mode=str(args.mode),
373
- limit=max(1, int(args.message_limit or 1)),
374
- timeout=max(1.0, float(args.timeout or 30)),
375
- imap_host=str(args.imap_host),
376
- include_preview=bool(args.preview),
377
- show_email=bool(args.show_email),
378
- ):
379
- success += 1
380
- print(f"summary: {success}/{len(selected)} account(s) readable")
381
- return 0 if success == len(selected) else 1
382
-
383
-
384
- if __name__ == "__main__":
385
- raise SystemExit(main())
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import imaplib
5
+ import json
6
+ import re
7
+ import sys
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ from dataclasses import dataclass
12
+ from email import message_from_bytes, policy
13
+ from email.header import decode_header, make_header
14
+ from email.message import Message
15
+ from email.utils import parsedate_to_datetime
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+
20
+ TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
21
+ GRAPH_MESSAGES_URL = "https://graph.microsoft.com/v1.0/me/messages"
22
+ GRAPH_SCOPE = "offline_access https://graph.microsoft.com/Mail.Read"
23
+ IMAP_SCOPE = "offline_access https://outlook.office.com/IMAP.AccessAsUser.All"
24
+ DEFAULT_IMAP_HOST = "outlook.office365.com"
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class OutlookCredential:
29
+ email: str
30
+ password: str
31
+ client_id: str
32
+ refresh_token: str
33
+ line_number: int
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class HttpResponse:
38
+ status_code: int
39
+ text: str
40
+
41
+ def json(self) -> Any:
42
+ return json.loads(self.text)
43
+
44
+
45
+ def _clean(value: str) -> str:
46
+ return value.replace("\ufeff", "").replace("\u00a0", " ").strip()
47
+
48
+
49
+ def _redact_email(email: str) -> str:
50
+ local, sep, domain = email.partition("@")
51
+ if not sep:
52
+ return "***"
53
+ if len(local) <= 2:
54
+ masked = local[:1] + "***"
55
+ else:
56
+ masked = local[:2] + "***" + local[-1:]
57
+ return f"{masked}@{domain}"
58
+
59
+
60
+ def parse_credentials(path: Path) -> list[OutlookCredential]:
61
+ credentials: list[OutlookCredential] = []
62
+ for line_number, raw_line in enumerate(path.read_text(encoding="utf-8-sig").splitlines(), start=1):
63
+ line = _clean(raw_line)
64
+ if not line or "----" not in line:
65
+ continue
66
+ parts = [_clean(part) for part in line.split("----", 3)]
67
+ if len(parts) != 4:
68
+ continue
69
+ email, password, client_id, refresh_token = parts
70
+ if "@" not in email or not client_id or not refresh_token:
71
+ continue
72
+ credentials.append(
73
+ OutlookCredential(
74
+ email=email,
75
+ password=password,
76
+ client_id=client_id,
77
+ refresh_token=refresh_token,
78
+ line_number=line_number,
79
+ )
80
+ )
81
+ return credentials
82
+
83
+
84
+ def _http_request(
85
+ method: str,
86
+ url: str,
87
+ *,
88
+ headers: dict[str, str] | None = None,
89
+ data: dict[str, str] | None = None,
90
+ params: dict[str, Any] | None = None,
91
+ timeout: float = 30,
92
+ ) -> HttpResponse:
93
+ target = url
94
+ if params:
95
+ query = urllib.parse.urlencode(params)
96
+ target = f"{url}?{query}"
97
+
98
+ body: bytes | None = None
99
+ request_headers = dict(headers or {})
100
+ if data is not None:
101
+ body = urllib.parse.urlencode(data).encode("utf-8")
102
+ request_headers.setdefault("Content-Type", "application/x-www-form-urlencoded")
103
+
104
+ request = urllib.request.Request(target, data=body, headers=request_headers, method=method.upper())
105
+ try:
106
+ with urllib.request.urlopen(request, timeout=timeout) as response:
107
+ return HttpResponse(
108
+ status_code=int(response.status),
109
+ text=response.read().decode("utf-8", errors="replace"),
110
+ )
111
+ except urllib.error.HTTPError as error:
112
+ return HttpResponse(
113
+ status_code=int(error.code),
114
+ text=error.read().decode("utf-8", errors="replace"),
115
+ )
116
+
117
+
118
+ def exchange_refresh_token(credential: OutlookCredential, scope: str, timeout: float) -> str:
119
+ response = _http_request(
120
+ "POST",
121
+ TOKEN_URL,
122
+ data={
123
+ "client_id": credential.client_id,
124
+ "grant_type": "refresh_token",
125
+ "refresh_token": credential.refresh_token,
126
+ "scope": scope,
127
+ },
128
+ timeout=timeout,
129
+ )
130
+ try:
131
+ data = response.json()
132
+ except Exception:
133
+ data = {}
134
+ if response.status_code != 200:
135
+ detail = data.get("error_description") or data.get("error") or response.text[:300]
136
+ raise RuntimeError(f"token refresh failed: HTTP {response.status_code}, {detail}")
137
+ access_token = str(data.get("access_token") or "").strip()
138
+ if not access_token:
139
+ raise RuntimeError("token refresh response did not include access_token")
140
+ return access_token
141
+
142
+
143
+ def _graph_sender(message: dict[str, Any]) -> str:
144
+ sender = message.get("from") or {}
145
+ if isinstance(sender, dict):
146
+ address = sender.get("emailAddress") or {}
147
+ if isinstance(address, dict):
148
+ return str(address.get("address") or address.get("name") or "")
149
+ return ""
150
+
151
+
152
+ def read_graph_messages(access_token: str, limit: int, timeout: float) -> list[dict[str, str]]:
153
+ response = _http_request(
154
+ "GET",
155
+ GRAPH_MESSAGES_URL,
156
+ headers={"Authorization": f"Bearer {access_token}", "Accept": "application/json"},
157
+ params={
158
+ "$top": max(1, min(limit, 50)),
159
+ "$orderby": "receivedDateTime desc",
160
+ "$select": "subject,receivedDateTime,from,bodyPreview",
161
+ },
162
+ timeout=timeout,
163
+ )
164
+ try:
165
+ data = response.json()
166
+ except Exception:
167
+ data = {}
168
+ if response.status_code != 200:
169
+ detail = data.get("error", {}).get("message") if isinstance(data.get("error"), dict) else response.text[:300]
170
+ raise RuntimeError(f"graph messages failed: HTTP {response.status_code}, {detail}")
171
+ items = data.get("value") if isinstance(data, dict) else None
172
+ if not isinstance(items, list):
173
+ raise RuntimeError("graph messages response did not include value[]")
174
+ return [
175
+ {
176
+ "received": str(item.get("receivedDateTime") or ""),
177
+ "from": _graph_sender(item),
178
+ "subject": str(item.get("subject") or ""),
179
+ "preview": str(item.get("bodyPreview") or ""),
180
+ }
181
+ for item in items
182
+ if isinstance(item, dict)
183
+ ]
184
+
185
+
186
+ def _decode_header(value: str | None) -> str:
187
+ if not value:
188
+ return ""
189
+ try:
190
+ return str(make_header(decode_header(value)))
191
+ except Exception:
192
+ return value
193
+
194
+
195
+ def _message_text_preview(message: Message, limit: int = 240) -> str:
196
+ text_parts: list[str] = []
197
+ parts = message.walk() if message.is_multipart() else [message]
198
+ for part in parts:
199
+ if part.get_content_maintype() == "multipart":
200
+ continue
201
+ content_type = part.get_content_type()
202
+ if content_type not in {"text/plain", "text/html"}:
203
+ continue
204
+ try:
205
+ payload = part.get_content()
206
+ except Exception:
207
+ continue
208
+ if payload:
209
+ text_parts.append(str(payload))
210
+ if content_type == "text/plain" and text_parts:
211
+ break
212
+ text = "\n".join(text_parts)
213
+ text = re.sub(r"<[^>]+>", " ", text)
214
+ text = re.sub(r"\s+", " ", text).strip()
215
+ return text[:limit]
216
+
217
+
218
+ def _parse_imap_message(raw: bytes, include_preview: bool) -> dict[str, str]:
219
+ message = message_from_bytes(raw, policy=policy.default)
220
+ received = ""
221
+ try:
222
+ parsed = parsedate_to_datetime(str(message.get("Date") or ""))
223
+ received = parsed.isoformat()
224
+ except Exception:
225
+ received = str(message.get("Date") or "")
226
+ return {
227
+ "received": received,
228
+ "from": _decode_header(str(message.get("From") or "")),
229
+ "subject": _decode_header(str(message.get("Subject") or "")),
230
+ "preview": _message_text_preview(message) if include_preview else "",
231
+ }
232
+
233
+
234
+ def read_imap_messages(access_token: str, email: str, host: str, limit: int, include_preview: bool) -> list[dict[str, str]]:
235
+ auth_string = f"user={email}\x01auth=Bearer {access_token}\x01\x01"
236
+ mailbox = imaplib.IMAP4_SSL(host)
237
+ try:
238
+ mailbox.authenticate("XOAUTH2", lambda _: auth_string.encode("utf-8"))
239
+ status, _ = mailbox.select("INBOX", readonly=True)
240
+ if status != "OK":
241
+ raise RuntimeError("imap select INBOX failed")
242
+ status, data = mailbox.uid("search", None, "ALL")
243
+ if status != "OK" or not data or not data[0]:
244
+ return []
245
+ uids = data[0].split()[-max(1, limit) :]
246
+ messages: list[dict[str, str]] = []
247
+ for uid in reversed(uids):
248
+ status, fetched = mailbox.uid("fetch", uid, "(RFC822)")
249
+ if status != "OK":
250
+ continue
251
+ raw_payload = next((item[1] for item in fetched if isinstance(item, tuple) and isinstance(item[1], bytes)), b"")
252
+ if raw_payload:
253
+ messages.append(_parse_imap_message(raw_payload, include_preview))
254
+ return messages
255
+ finally:
256
+ try:
257
+ mailbox.logout()
258
+ except Exception:
259
+ pass
260
+
261
+
262
+ def print_messages(messages: list[dict[str, str]], include_preview: bool) -> None:
263
+ if not messages:
264
+ print(" no recent messages")
265
+ return
266
+ for index, message in enumerate(messages, start=1):
267
+ print(f" {index}. {message['received']} | {message['from']} | {message['subject']}")
268
+ if include_preview and message.get("preview"):
269
+ print(f" preview: {message['preview']}")
270
+
271
+
272
+ def test_credential(
273
+ credential: OutlookCredential,
274
+ mode: str,
275
+ limit: int,
276
+ timeout: float,
277
+ imap_host: str,
278
+ include_preview: bool,
279
+ show_email: bool,
280
+ ) -> bool:
281
+ label = credential.email if show_email else _redact_email(credential.email)
282
+ print(f"[line {credential.line_number}] {label}")
283
+ errors: list[str] = []
284
+
285
+ if mode in {"graph", "auto"}:
286
+ try:
287
+ access_token = exchange_refresh_token(credential, GRAPH_SCOPE, timeout)
288
+ messages = read_graph_messages(access_token, limit, timeout)
289
+ print(" graph: ok")
290
+ print_messages(messages, include_preview)
291
+ return True
292
+ except Exception as error:
293
+ errors.append(f"graph: {error}")
294
+ if mode == "graph":
295
+ print(f" graph: failed - {error}")
296
+ return False
297
+
298
+ if mode in {"imap", "auto"}:
299
+ try:
300
+ access_token = exchange_refresh_token(credential, IMAP_SCOPE, timeout)
301
+ messages = read_imap_messages(access_token, credential.email, imap_host, limit, include_preview)
302
+ print(" imap: ok")
303
+ print_messages(messages, include_preview)
304
+ return True
305
+ except Exception as error:
306
+ errors.append(f"imap: {error}")
307
+ if mode == "imap":
308
+ print(f" imap: failed - {error}")
309
+ return False
310
+
311
+ print(" failed")
312
+ for error in errors:
313
+ print(f" - {error}")
314
+ return False
315
+
316
+
317
+ def build_parser() -> argparse.ArgumentParser:
318
+ parser = argparse.ArgumentParser(
319
+ description="Test Outlook/Hotmail mailbox access from lines formatted as email----password----client_id----refresh_token.",
320
+ )
321
+ parser.add_argument("--file", default=r"D:\Desktop\yx.txt", help="Credential text file path.")
322
+ parser.add_argument("--mode", choices=("auto", "graph", "imap"), default="auto", help="Mailbox read method.")
323
+ parser.add_argument("--limit-accounts", type=int, default=1, help="How many accounts to test.")
324
+ parser.add_argument("--message-limit", type=int, default=5, help="How many recent messages to list per account.")
325
+ parser.add_argument("--timeout", type=float, default=30, help="HTTP request timeout in seconds.")
326
+ parser.add_argument("--imap-host", default=DEFAULT_IMAP_HOST, help="IMAP host for XOAUTH2 mode.")
327
+ parser.add_argument("--preview", action="store_true", help="Print body preview/snippet. Disabled by default.")
328
+ parser.add_argument("--show-email", action="store_true", help="Print full email address. Secrets are never printed.")
329
+ parser.add_argument("--json", action="store_true", help="Only parse the file and print non-secret account metadata as JSON.")
330
+ return parser
331
+
332
+
333
+ def main() -> int:
334
+ args = build_parser().parse_args()
335
+ path = Path(args.file)
336
+ if not path.exists():
337
+ print(f"file not found: {path}", file=sys.stderr)
338
+ return 2
339
+
340
+ credentials = parse_credentials(path)
341
+ if not credentials:
342
+ print(f"no valid credentials parsed from: {path}", file=sys.stderr)
343
+ return 2
344
+
345
+ limit_accounts = max(1, int(args.limit_accounts or 1))
346
+ selected = credentials[:limit_accounts]
347
+
348
+ if args.json:
349
+ print(
350
+ json.dumps(
351
+ [
352
+ {
353
+ "line": item.line_number,
354
+ "email": item.email if args.show_email else _redact_email(item.email),
355
+ "client_id": item.client_id,
356
+ "has_password": bool(item.password),
357
+ "has_refresh_token": bool(item.refresh_token),
358
+ }
359
+ for item in selected
360
+ ],
361
+ ensure_ascii=False,
362
+ indent=2,
363
+ )
364
+ )
365
+ return 0
366
+
367
+ print(f"parsed {len(credentials)} credential(s), testing {len(selected)}")
368
+ success = 0
369
+ for credential in selected:
370
+ if test_credential(
371
+ credential=credential,
372
+ mode=str(args.mode),
373
+ limit=max(1, int(args.message_limit or 1)),
374
+ timeout=max(1.0, float(args.timeout or 30)),
375
+ imap_host=str(args.imap_host),
376
+ include_preview=bool(args.preview),
377
+ show_email=bool(args.show_email),
378
+ ):
379
+ success += 1
380
+ print(f"summary: {success}/{len(selected)} account(s) readable")
381
+ return 0 if success == len(selected) else 1
382
+
383
+
384
+ if __name__ == "__main__":
385
+ raise SystemExit(main())