dsh-plugin-image-workbench 1.1.5 → 1.1.6
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.
- package/LICENSE +21 -21
- package/README.en.md +5 -3
- package/README.md +7 -3
- package/client.js +241 -241
- package/cordis.patch.yml +14 -14
- package/host.js +51 -0
- package/install.mjs +333 -387
- package/package.json +3 -2
- package/scripts/fetch-style-images.mjs +104 -0
- package/scripts/vendor-snapshot.py +154 -154
- package/server/access.py +26 -26
- package/server/app.py +1329 -1329
- package/server/catalog.py +147 -147
- package/server/composer.py +391 -391
- package/server/drafts.py +158 -158
- package/server/history.py +575 -575
- package/server/imaging.py +172 -172
- package/server/pool.py +1335 -1335
- package/server/pool_admin.py +921 -921
- package/server/pool_service.py +1073 -1073
- package/server/requirements.txt +6 -6
- package/server/smoke_check.py +402 -402
- package/server/static/access.html +202 -202
- package/vendor/chatgpt2api/source/.env.example +82 -82
- package/vendor/chatgpt2api/source/.python-version +1 -1
- package/vendor/chatgpt2api/source/LICENSE +21 -21
- package/vendor/chatgpt2api/source/README.md +391 -391
- package/vendor/chatgpt2api/source/VERSION +1 -1
- package/vendor/chatgpt2api/source/api/__init__.py +2 -2
- package/vendor/chatgpt2api/source/api/accounts.py +537 -537
- package/vendor/chatgpt2api/source/api/ai.py +217 -217
- package/vendor/chatgpt2api/source/api/app.py +62 -62
- package/vendor/chatgpt2api/source/api/errors.py +46 -46
- package/vendor/chatgpt2api/source/api/image_inputs.py +309 -309
- package/vendor/chatgpt2api/source/api/image_tasks.py +120 -120
- package/vendor/chatgpt2api/source/api/support.py +134 -134
- package/vendor/chatgpt2api/source/api/system.py +365 -365
- package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -50
- package/vendor/chatgpt2api/source/config.default.json +108 -108
- package/vendor/chatgpt2api/source/main.py +9 -9
- package/vendor/chatgpt2api/source/pyproject.toml +27 -27
- package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -186
- package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -163
- package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -13
- package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -385
- package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -129
- package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -92
- package/vendor/chatgpt2api/source/services/account_service.py +1757 -1757
- package/vendor/chatgpt2api/source/services/auth_service.py +253 -253
- package/vendor/chatgpt2api/source/services/backup_service.py +671 -671
- package/vendor/chatgpt2api/source/services/config.py +667 -667
- package/vendor/chatgpt2api/source/services/content_filter.py +242 -242
- package/vendor/chatgpt2api/source/services/cpa_service.py +315 -315
- package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -260
- package/vendor/chatgpt2api/source/services/image_service.py +376 -376
- package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -405
- package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -76
- package/vendor/chatgpt2api/source/services/image_task_service.py +550 -550
- package/vendor/chatgpt2api/source/services/log_service.py +337 -337
- package/vendor/chatgpt2api/source/services/model_service.py +182 -182
- package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -267
- package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -2763
- package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -32
- package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -2
- package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -307
- package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -238
- package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -1644
- package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -124
- package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -19
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -316
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -86
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -42
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -53
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -453
- package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -160
- package/vendor/chatgpt2api/source/services/proxy_service.py +648 -648
- package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -5
- package/vendor/chatgpt2api/source/services/storage/base.py +38 -38
- package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -194
- package/vendor/chatgpt2api/source/services/storage/factory.py +104 -104
- package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -183
- package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -95
- package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -531
- package/vendor/chatgpt2api/source/utils/helper.py +469 -469
- package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -346
- package/vendor/chatgpt2api/source/utils/log.py +110 -110
- package/vendor/chatgpt2api/source/utils/pkce.py +19 -19
- package/vendor/chatgpt2api/source/utils/pow.py +207 -207
- package/vendor/chatgpt2api/source/utils/sentinel.py +159 -159
- package/vendor/chatgpt2api/source/utils/turnstile.py +184 -184
- package/vendor/chatgpt2api/source/uv.lock +904 -904
- package/vendor/chatgpt2api/source/web/bun.lock +1501 -1501
- package/vendor/chatgpt2api/source/web/components.json +24 -24
- package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -17
- package/vendor/chatgpt2api/source/web/next.config.ts +40 -40
- package/vendor/chatgpt2api/source/web/package.json +58 -58
- package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -5
- package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -925
- package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -1307
- package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -224
- package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -444
- package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -9
- package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -9
- package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -172
- package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -169
- package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -39
- package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -59
- package/vendor/chatgpt2api/source/web/src/app/globals.css +230 -230
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -542
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -554
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -176
- package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -1799
- package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -732
- package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -50
- package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -101
- package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -356
- package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -30
- package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -341
- package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -447
- package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -71
- package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -491
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -108
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -169
- package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -185
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -379
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -127
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -200
- package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -12
- package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -886
- package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -81
- package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -422
- package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -135
- package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -1069
- package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -49
- package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -24
- package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -434
- package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -46
- package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -18
- package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -14
- package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -263
- package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -256
- package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -41
- package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -59
- package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -46
- package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -61
- package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -27
- package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -267
- package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -132
- package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -13
- package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -19
- package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -24
- package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -149
- package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -38
- package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -175
- package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -143
- package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -29
- package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -91
- package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -269
- package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -18
- package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -127
- package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -6
- package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -85
- package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -926
- package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -26
- package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -25
- package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -93
- package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -89
- package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -6
- package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -112
- package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -33
- package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -318
- 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())
|