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
2 changes: 1 addition & 1 deletion README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ services:
- outlook-net

watchtower:
image: containrrr/watchtower:1.7.1
image: nickfedor/watchtower:latest
container_name: watchtower
restart: unless-stopped
volumes:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ services:
- outlook-net

watchtower:
image: containrrr/watchtower:1.7.1
image: nickfedor/watchtower:latest
container_name: watchtower
restart: unless-stopped
volumes:
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ services:
- outlook-net

watchtower:
image: containrrr/watchtower:1.7.1
image: nickfedor/watchtower:latest
container_name: watchtower
restart: unless-stopped
volumes:
Expand Down
103 changes: 102 additions & 1 deletion outlook_web/controllers/emails.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
import time
from typing import Any, Dict, List, Optional

from flask import current_app, jsonify, request
import json

from flask import Response, current_app, jsonify, request, stream_with_context

from outlook_web import config
from outlook_web.audit import log_audit
Expand All @@ -19,6 +21,8 @@
from outlook_web.services import graph as graph_service
from outlook_web.services import imap as imap_service
from outlook_web.services import verification_channel_routing as verification_channel_service
from outlook_web.services.channel_cache import get_cached_channel, set_cached_channel
from outlook_web.services.email_cache import get_cached_emails, set_cached_emails
from outlook_web.services.imap_generic import (
get_email_detail_imap_generic_result,
get_emails_imap_generic,
Expand Down Expand Up @@ -1549,3 +1553,100 @@ def api_external_get_probe_status(probe_id: str) -> Any:
details={"code": "INTERNAL_ERROR", "probe_id": probe_id},
)
return jsonify(external_api_service.fail("INTERNAL_ERROR", "服务内部错误")), 500


@login_required
def api_stream_emails(email_addr: str) -> Any:
"""SSE 流式获取邮件:IMAP 每拉到一封就推一条 event,前端边收边渲染。"""
account = accounts_repo.get_account_by_email(email_addr)
if not account:
return build_error_response(
"ACCOUNT_NOT_FOUND", "账号不存在",
message_en="Account not found", err_type="NotFoundError",
status=404, details=f"email={email_addr}",
)

folder = request.args.get("folder", "inbox")
skip = int(request.args.get("skip", 0))
top = int(request.args.get("top", 20))
force = request.args.get("force", "").lower() in ("1", "true")

account_type = (account.get("account_type") or "outlook").strip().lower()

# 获取分组代理设置
proxy_url = ""
if account.get("group_id"):
group = groups_repo.get_group_by_id(account["group_id"])
if group:
proxy_url = group.get("proxy_url", "") or ""

def _sse(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"

def generate():
# --- 服务端缓存(2h TTL)---
if not force:
cached = get_cached_emails(email_addr, folder)
if cached:
for e in cached["emails"]:
yield _sse("email", e)
yield _sse("done", {
"method": cached["method"] + " (cached)",
"count": len(cached["emails"]),
"has_more": cached["has_more"],
})
return

collected_emails = []
cached_channel = get_cached_channel(email_addr)

# --- Graph API 尝试 ---
if account_type != "imap" and cached_channel != "imap":
graph_result = graph_service.get_emails_graph(
account["client_id"], account["refresh_token"], folder, skip, top, proxy_url,
)
if graph_result.get("success"):
set_cached_channel(email_addr, "graph")
emails = graph_result.get("emails", [])
for e in emails:
fmt = {
"id": e.get("id"),
"subject": e.get("subject", "无主题"),
"from": e.get("from", {}).get("emailAddress", {}).get("address", "未知"),
"date": e.get("receivedDateTime", ""),
"is_read": e.get("isRead", False),
"has_attachments": e.get("hasAttachments", False),
"body_preview": e.get("bodyPreview", ""),
}
collected_emails.append(fmt)
yield _sse("email", fmt)
has_more = len(emails) >= top
set_cached_emails(email_addr, folder, collected_emails, "Graph API", has_more)
yield _sse("done", {"method": "Graph API", "count": len(emails), "has_more": has_more})
return
elif graph_result.get("no_mail_permission"):
set_cached_channel(email_addr, "imap")

# --- IMAP 流式 ---
yield _sse("status", {"message": "IMAP connecting..."})
imap_method = "IMAP"
for item in imap_service.stream_emails_imap(
account["email"], account["client_id"], account["refresh_token"],
folder, skip, top,
):
if item.get("type") == "email":
collected_emails.append(item["data"])
yield _sse("email", item["data"])
elif item.get("type") == "done":
imap_method = item.get("method", "IMAP")
set_cached_channel(email_addr, "imap")
set_cached_emails(email_addr, folder, collected_emails, imap_method, False)
yield _sse("done", {"method": imap_method, "count": item.get("count", 0), "has_more": False})
elif item.get("type") == "error":
yield _sse("error", item.get("data", {}))

return Response(
stream_with_context(generate()),
mimetype="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
Loading
Loading