Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable changes to OutlookMail Plus are documented in this file.

### 修复 / Bug Fixes

- **验证码提取大小写被篡改**:验证码提取收口处(规则分支 `_smart_extract_code_by_keywords` / `_fallback_extract_code` 及历史函数 `smart_extract_verification_code` / `fallback_extract_verification_code`,以及 AI 回退 `enhance_verification_with_ai_fallback`)不再强制 `.upper()`,`verification_code` 与 `formatted` 保持邮件原文大小写(如 `ab12cd` 不再返回 `AB12CD`)。补充回归测试。
- **Issue #65 Watchtower 容器镜像过时**:`docker-compose.yml` 中固定 Watchtower 版本为 `containrrr/watchtower:1.7.1`,避免本地缓存的旧版镜像(内嵌 Docker 客户端 API 1.25)连接新版本 Docker Engine(要求 API 1.44+)时失败。README 新增故障排查指引。

## [v2.7.0] - 2026-05-29
Expand Down
31 changes: 15 additions & 16 deletions ant-design-pro/src/components/MailboxLayout/ResizableWorkbench.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
clampWidth,
createDefaultLayout,
loadLayoutState,
MAX_WIDTHS,
MIN_WIDTHS,
PANEL_LABELS,
saveLayoutState,
type MailboxLayoutState,
Expand Down Expand Up @@ -255,34 +257,31 @@ const ResizableWorkbench: React.FC<Props> = ({
};

const resizer = (forPanel: PanelKey) => (
<div
role="separator"
<hr
aria-orientation="vertical"
aria-label={`调整${PANEL_LABELS[forPanel]}宽度`}
aria-valuemin={MIN_WIDTHS[forPanel]}
aria-valuemax={MAX_WIDTHS[forPanel]}
aria-valuenow={
layout.panels[forPanel].collapsed
? COLLAPSED_W
: layout.panels[forPanel].width
}
tabIndex={0}
data-resizer={forPanel}
onMouseDown={(e) => onResizerDown(forPanel, e)}
onKeyDown={(e) => onResizerKey(forPanel, e)}
style={{
width: 6,
cursor: layout.panels[forPanel].collapsed ? 'default' : 'col-resize',
background: 'transparent',
background:
'linear-gradient(to right, transparent 0 2px, rgba(5,5,5,0.12) 2px 4px, transparent 4px 6px)',
border: 0,
margin: 0,
position: 'relative',
alignSelf: 'stretch',
}}
>
<div
style={{
position: 'absolute',
top: '20%',
bottom: '20%',
left: 2,
width: 2,
borderRadius: 1,
background: 'rgba(5,5,5,0.12)',
}}
/>
</div>
/>
);

return (
Expand Down
2 changes: 1 addition & 1 deletion ant-design-pro/src/pages/accounts/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -671,7 +671,7 @@ const AccountsPage: React.FC = () => {
imap_port: 993,
}}
onValuesChange={(changed) => {
if (Object.prototype.hasOwnProperty.call(changed, 'provider')) {
if (Object.hasOwn(changed, 'provider')) {
setImportProvider(String(changed.provider || 'outlook'));
}
}}
Expand Down
14 changes: 8 additions & 6 deletions ant-design-pro/src/pages/mailbox/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -541,9 +541,13 @@ const MailboxPage: React.FC = () => {
const polling = !!(selectedEmail && isPolling(selectedEmail));
const pollSnapMap = useMemo(() => {
const m = new Map<string, PollSnapshot>();
allPollSnaps.forEach((s) => m.set(s.email, s));
allPollSnaps.forEach((s) => {
m.set(s.email, s);
});
// 保证订阅外也能读到最新
getPollSnapshots().forEach((s) => m.set(s.email, s));
getPollSnapshots().forEach((s) => {
m.set(s.email, s);
});
return m;
}, [allPollSnaps]);

Expand Down Expand Up @@ -935,7 +939,7 @@ const MailboxPage: React.FC = () => {
// ── Compact 视图 ──
const compactView = (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<ProCard size="small" bordered>
<ProCard size="small">
<Space wrap style={{ width: '100%' }} align="center">
<Typography.Text type="secondary">分组</Typography.Text>
<Space wrap>
Expand Down Expand Up @@ -971,7 +975,6 @@ const MailboxPage: React.FC = () => {
</ProCard>

<ProCard
bordered
title={
<Space>
<span>简洁账号列表</span>
Expand Down Expand Up @@ -1002,7 +1005,6 @@ const MailboxPage: React.FC = () => {
</Button>
</Space>
}
bodyStyle={{ padding: 0 }}
>
<Spin spinning={accountsQuery.isLoading}>
{filteredCompactAccounts.length === 0 ? (
Expand Down Expand Up @@ -1255,7 +1257,7 @@ const MailboxPage: React.FC = () => {
</Space>
}
>
<ProCard size="small" bordered style={{ marginBottom: 12 }}>
<ProCard size="small" style={{ marginBottom: 12 }}>
<Space wrap align="center">
<Typography.Text strong>Compact Poll 高级</Typography.Text>
<Typography.Text type="secondary">间隔(秒)</Typography.Text>
Expand Down
6 changes: 3 additions & 3 deletions ant-design-pro/src/requestErrorConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ describe('requestErrorConfig', () => {
expect(error.name).toBe('BizError');
expect(error.info.errorCode).toBe(403);
expect(error.info.errorMessage).toBe('Forbidden');
expect(error.info.showType).toBe(3);
expect(error.info.showType).toBe(2);
expect(error.info.data).toEqual({ detail: 'more info' });
}
});
Expand Down Expand Up @@ -142,7 +142,7 @@ describe('requestErrorConfig', () => {
errorHandler(error, {});

expect(notification.open).toHaveBeenCalledWith({
title: 1004,
title: '1004',
description: 'This is a notification',
});
});
Expand Down Expand Up @@ -187,7 +187,7 @@ describe('requestErrorConfig', () => {

errorHandler(error, {});

expect(message.error).toHaveBeenCalledWith('Response status:500');
expect(message.error).toHaveBeenCalledWith('请求失败');
});

it('should handle offline error', () => {
Expand Down
15 changes: 10 additions & 5 deletions ant-design-pro/src/services/outlook/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ export type OutlookApiResult<T = unknown> = {
let csrfTokenCache: string | null = null;
let csrfRefreshPromise: Promise<string | null> | null = null;

type CsrfTokenPayload = {
csrf_token?: string | null;
csrf_disabled?: boolean;
};

/** 获取 / 刷新 CSRF Token(与旧前端 main.js 行为对齐) */
export async function ensureCsrfToken(force = false): Promise<string | null> {
if (!force && csrfTokenCache) {
Expand All @@ -51,14 +56,14 @@ export async function ensureCsrfToken(force = false): Promise<string | null> {

csrfRefreshPromise = (async () => {
try {
const data = await request<{
csrf_token?: string | null;
csrf_disabled?: boolean;
}>('/api/csrf-token', {
const response = (await request<CsrfTokenPayload>('/api/csrf-token', {
method: 'GET',
skipErrorHandler: true,
credentials: 'include',
} as any);
} as any)) as unknown as CsrfTokenPayload | { data?: CsrfTokenPayload };
const data =
((response as { data?: CsrfTokenPayload }).data ??
response) as CsrfTokenPayload;

if (data?.csrf_disabled) {
csrfTokenCache = null;
Expand Down
21 changes: 17 additions & 4 deletions outlook_web/controllers/external_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,7 @@ def _check_pool_access(endpoint: str):
)


@api_key_required
@external_api_guards(feature="pool_claim_random")
def api_external_pool_claim_random():
endpoint = "/api/external/pool/claim-random"
def _claim_pool_account(endpoint: str, *, require_email_domain: bool = False):
disabled_resp = _check_pool_external_enabled(endpoint)
if disabled_resp is not None:
return disabled_resp
Expand All @@ -99,6 +96,10 @@ def api_external_pool_claim_random():
project_key = body.get("project_key")
email_domain = body.get("email_domain")

if require_email_domain and not str(email_domain or "").strip():
_audit(endpoint, "error", details={"code": "EMAIL_DOMAIN_REQUIRED"})
return jsonify(external_api_service.fail("EMAIL_DOMAIN_REQUIRED", "email_domain 不能为空")), 400

try:
account = claim_random(
caller_id=caller_id,
Expand Down Expand Up @@ -137,6 +138,18 @@ def api_external_pool_claim_random():
return jsonify(external_api_service.fail("INTERNAL_ERROR", "服务内部错误")), 500


@api_key_required
@external_api_guards(feature="pool_claim_random")
def api_external_pool_claim_random():
return _claim_pool_account("/api/external/pool/claim-random")


@api_key_required
@external_api_guards(feature="pool_claim_random")
def api_external_pool_claim_domain():
return _claim_pool_account("/api/external/pool/claim-domain", require_email_domain=True)


@api_key_required
@external_api_guards(feature="pool_claim_release")
def api_external_pool_claim_release():
Expand Down
5 changes: 3 additions & 2 deletions outlook_web/controllers/token_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,8 +215,9 @@ def save_to_account() -> Any:
if compatibility_error:
return build_error_response("OAUTH_CONFIG_INVALID", compatibility_error, status=400)

validation_scope = (data.get("scope") or "").strip() or COMPATIBLE_SCOPE
if validation_scope == LEGACY_GRAPH_SCOPE:
explicit_scope = (data.get("scope") or data.get("requested_scope") or data.get("granted_scope") or "").strip()
validation_scope = explicit_scope or COMPATIBLE_SCOPE
if not explicit_scope and validation_scope == LEGACY_GRAPH_SCOPE:
validation_scope = COMPATIBLE_SCOPE

valid, error_msg, new_rt = graph_service.test_refresh_token_with_rotation(
Expand Down
5 changes: 5 additions & 0 deletions outlook_web/routes/external_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ def create_blueprint(csrf_exempt: Optional[Callable] = None) -> Blueprint:
external_pool_controller.api_external_pool_claim_random,
["POST"],
),
(
"/api/external/pool/claim-domain",
external_pool_controller.api_external_pool_claim_domain,
["POST"],
),
(
"/api/external/pool/claim-release",
external_pool_controller.api_external_pool_claim_release,
Expand Down
9 changes: 8 additions & 1 deletion outlook_web/services/imap.py
Original file line number Diff line number Diff line change
Expand Up @@ -545,7 +545,14 @@ def fetch_and_detail_imap_with_server(
if status != "OK":
return {"success": True, "emails": [], "detail": None}

for i, (msg_id_str, raw_email) in enumerate(_parse_batch_fetch_response(all_data or [])):
raw_by_id = {msg_id_str: raw_email for msg_id_str, raw_email in _parse_batch_fetch_response(all_data or [])}

for i, msg_id in enumerate(paged_ids):
msg_id_str = msg_id.decode("ascii", errors="ignore").strip()
raw_email = raw_by_id.get(msg_id_str)
if raw_email is None:
continue

msg = email.message_from_bytes(raw_email)
body_preview = get_email_body(msg)
email_item = {
Expand Down
11 changes: 10 additions & 1 deletion outlook_web/services/verification_channel_routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -408,13 +408,22 @@ def extract_verification_for_outlook(
reverse=True,
)[0]

latest_id = str(latest.get("id") or "")
if channel.startswith("imap_"):
detail = channel_result.get("detail")
detail_id = str((detail or {}).get("id") or "")
if latest_id and detail_id and detail_id != latest_id:
detail = fetch_email_detail_for_channel(
account=account,
channel=channel,
message_id=latest_id,
proxy_url=proxy_url,
)
else:
detail = fetch_email_detail_for_channel(
account=account,
channel=channel,
message_id=latest.get("id", ""),
message_id=latest_id,
proxy_url=proxy_url,
)

Expand Down
12 changes: 5 additions & 7 deletions outlook_web/services/verification_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ def smart_extract_verification_code(email_content: str) -> Optional[str]:
# 过滤掉纯字母的匹配(验证码通常包含数字)
for match in matches:
if any(c.isdigit() for c in match):
return match.upper()
return match

return None

Expand Down Expand Up @@ -183,8 +183,6 @@ def fallback_extract_verification_code(email_content: str) -> Optional[str]:
# 过滤规则
filtered = []
for match in matches:
match_upper = match.upper()

# 必须包含至少一个数字
if not any(c.isdigit() for c in match):
continue
Expand All @@ -209,7 +207,7 @@ def fallback_extract_verification_code(email_content: str) -> Optional[str]:
if 2020 <= num <= 2030:
continue

filtered.append(match_upper)
filtered.append(match)

return filtered[0] if filtered else None

Expand Down Expand Up @@ -460,7 +458,7 @@ def _smart_extract_code_by_keywords(email_content: str, code_re: re.Pattern) ->
for m in code_re.finditer(context):
value = m.group(0)
if value and any(c.isdigit() for c in value):
return value.upper()
return value

return None

Expand Down Expand Up @@ -492,7 +490,7 @@ def _fallback_extract_code(email_content: str, code_re: re.Pattern) -> Optional[
if 2020 <= num <= 2030:
continue

candidates.append(value.upper())
candidates.append(value)

return candidates[0] if candidates else None

Expand Down Expand Up @@ -1055,7 +1053,7 @@ def _apply_output_policy(payload: Dict[str, Any]) -> Dict[str, Any]:

updated = False
if ai_code:
result["verification_code"] = ai_code.upper()
result["verification_code"] = ai_code
result["code_confidence"] = "high" if ai_confidence == "high" else "low"
updated = True
if ai_link:
Expand Down
Loading
Loading