feat(email): 新增 Outlook 收件箱功能

- 后端:GET /api/email-services/{id}/inbox 端点,通过 IMAPNewProvider 读取收件箱
- 前端:收件箱列表模态框(支持仅未读筛选、刷新)
- 前端:邮件正文弹窗
- Outlook 操作列新增「收件箱」按钮
This commit is contained in:
cnlimiter
2026-03-21 02:37:35 +08:00
committed by Mison
parent 668500028a
commit 344cf0088c
3 changed files with 235 additions and 25 deletions

View File

@@ -67,7 +67,7 @@ class ServiceTestResult(BaseModel):
class OutlookBatchImportRequest(BaseModel):
"""Outlook 批量导入请求"""
data: str # 多行数据,每行格式: 邮箱----密码 或 邮箱----密码----client_id----refresh_token
data: str # 多行数据,每行格式: 邮箱----密码----client_id----refresh_token
enabled: bool = True
priority: int = 0
@@ -461,11 +461,8 @@ async def batch_import_outlook(request: OutlookBatchImportRequest):
"""
批量导入 Outlook 邮箱账户
支持两种格式:
- 格式一(密码认证):邮箱----密码
- 格式二XOAUTH2 认证):邮箱----密码----client_id----refresh_token
每行一个账户,使用四个连字符(----)分隔字段
格式(每行):邮箱----密码----client_id----refresh_token
使用四个连字符(----)分隔字段
"""
lines = request.data.strip().split("\n")
total = len(lines)
@@ -484,14 +481,18 @@ async def batch_import_outlook(request: OutlookBatchImportRequest):
parts = line.split("----")
# 验证格式
if len(parts) < 2:
# 必须是四字段格式
if len(parts) < 4:
failed += 1
errors.append(f"{i+1}: 格式错误,至少需要邮箱和密码")
errors.append(
f"{i+1}: 格式错误,必须为 邮箱----密码----client_id----refresh_token"
)
continue
email = parts[0].strip()
password = parts[1].strip()
client_id = parts[2].strip()
refresh_token = parts[3].strip()
# 验证邮箱格式
if "@" not in email:
@@ -499,6 +500,12 @@ async def batch_import_outlook(request: OutlookBatchImportRequest):
errors.append(f"{i+1}: 无效的邮箱地址: {email}")
continue
# 验证 OAuth 字段非空
if not client_id or not refresh_token:
failed += 1
errors.append(f"{i+1}: [{email}] client_id 或 refresh_token 不能为空")
continue
# 检查是否已存在
existing = db.query(EmailServiceModel).filter(
EmailServiceModel.service_type == "outlook",
@@ -513,17 +520,11 @@ async def batch_import_outlook(request: OutlookBatchImportRequest):
# 构建配置
config = {
"email": email,
"password": password
"password": password,
"client_id": client_id,
"refresh_token": refresh_token,
}
# 检查是否有 OAuth 信息(格式二)
if len(parts) >= 4:
client_id = parts[2].strip()
refresh_token = parts[3].strip()
if client_id and refresh_token:
config["client_id"] = client_id
config["refresh_token"] = refresh_token
# 创建服务记录
try:
service = EmailServiceModel(
@@ -608,3 +609,86 @@ async def test_tempmail_service(request: TempmailTestRequest):
except Exception as e:
logger.error(f"测试临时邮箱失败: {e}")
return {"success": False, "message": f"测试失败: {str(e)}"}
# ============== 收件箱 ==============
@router.get("/{service_id}/inbox")
async def get_outlook_inbox(
service_id: int,
count: int = Query(30, ge=1, le=100),
only_unseen: bool = Query(False),
):
"""获取 Outlook 收件箱邮件列表"""
with get_db() as db:
service = db.query(EmailServiceModel).filter(EmailServiceModel.id == service_id).first()
if not service:
raise HTTPException(status_code=404, detail="服务不存在")
if service.service_type != "outlook":
raise HTTPException(status_code=400, detail="仅支持 Outlook 类型服务")
config = service.config or {}
email_addr = config.get("email", "")
client_id = config.get("client_id", "")
refresh_token = config.get("refresh_token", "")
# client_id 为空时尝试使用全局默认值
if not client_id:
from ...config.settings import get_settings
client_id = get_settings().outlook_default_client_id or ""
if not client_id or not refresh_token:
raise HTTPException(status_code=400, detail="该账户缺少 OAuth 配置client_id / refresh_token无法读取收件箱")
try:
from ...services.outlook.account import OutlookAccount
from ...services.outlook.token_manager import TokenManager
from ...services.outlook.providers.imap_new import IMAPNewProvider
from ...services.outlook.providers.base import ProviderConfig
account = OutlookAccount(
email=email_addr,
password=config.get("password", ""),
client_id=client_id,
refresh_token=refresh_token,
)
provider_config = ProviderConfig(
proxy_url=None,
timeout=30,
service_id=service_id,
)
provider = IMAPNewProvider(account, provider_config)
connected = provider.connect()
if not connected:
raise HTTPException(status_code=502, detail="IMAP 连接失败,请检查 OAuth 配置")
try:
messages = provider.get_recent_emails(count=count, only_unseen=only_unseen)
finally:
provider.disconnect()
emails = []
for m in messages:
received_str = m.received_at.isoformat() if m.received_at else None
emails.append({
"id": m.id or "",
"subject": m.subject or "",
"sender": m.sender or "",
"received_at": received_str,
"body_preview": m.body_preview or (m.body or "")[:200],
"body": m.body or "",
"is_read": m.is_read,
})
return {
"email": email_addr,
"total": len(emails),
"emails": emails,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"获取收件箱失败 service_id={service_id}: {e}")
raise HTTPException(status_code=500, detail=f"获取收件箱失败: {str(e)}")

View File

@@ -72,6 +72,25 @@ const elements = {
editOutlookForm: document.getElementById('edit-outlook-form'),
closeEditOutlookModal: document.getElementById('close-edit-outlook-modal'),
cancelEditOutlook: document.getElementById('cancel-edit-outlook'),
// 收件箱模态框
inboxModal: document.getElementById('inbox-modal'),
closeInboxModal: document.getElementById('close-inbox-modal'),
inboxRefreshBtn: document.getElementById('inbox-refresh-btn'),
inboxOnlyUnseen: document.getElementById('inbox-only-unseen'),
inboxLoading: document.getElementById('inbox-loading'),
inboxTable: document.getElementById('inbox-table'),
inboxTbody: document.getElementById('inbox-tbody'),
inboxEmpty: document.getElementById('inbox-empty'),
inboxModalEmail: document.getElementById('inbox-modal-email'),
// 邮件正文模态框
emailDetailModal: document.getElementById('email-detail-modal'),
closeEmailDetailModal: document.getElementById('close-email-detail-modal'),
emailDetailSubject: document.getElementById('email-detail-subject'),
emailDetailSender: document.getElementById('email-detail-sender'),
emailDetailDate: document.getElementById('email-detail-date'),
emailDetailBody: document.getElementById('email-detail-body'),
};
const CUSTOM_SUBTYPE_LABELS = {
@@ -164,6 +183,12 @@ function initEventListeners() {
document.addEventListener('click', () => {
document.querySelectorAll('.dropdown-menu.active').forEach(m => m.classList.remove('active'));
});
// 收件箱模态框事件
elements.closeInboxModal.addEventListener('click', () => elements.inboxModal.classList.remove('active'));
elements.closeEmailDetailModal.addEventListener('click', () => elements.emailDetailModal.classList.remove('active'));
elements.inboxRefreshBtn.addEventListener('click', () => loadInbox(currentInboxServiceId, true));
elements.inboxOnlyUnseen.addEventListener('change', () => loadInbox(currentInboxServiceId));
}
function toggleEmailMoreMenu(btn) {
@@ -247,6 +272,7 @@ async function loadOutlookServices() {
<td>${format.date(service.last_used)}</td>
<td>
<div style="display:flex;gap:4px;align-items:center;white-space:nowrap;">
<button class="btn btn-secondary btn-sm" onclick="openInboxModal(${service.id}, '${escapeHtml(service.config?.email || service.name)}')">收件箱</button>
<button class="btn btn-secondary btn-sm" onclick="editOutlookService(${service.id})">编辑</button>
<div class="dropdown" style="position:relative;">
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation();toggleEmailMoreMenu(this)">更多</button>
@@ -787,3 +813,57 @@ async function handleEditOutlook(e) {
toast.error('更新失败: ' + error.message);
}
}
// ============== 收件箱 ==============
let currentInboxServiceId = null;
async function openInboxModal(serviceId, email) {
currentInboxServiceId = serviceId;
elements.inboxModalEmail.textContent = email;
elements.inboxOnlyUnseen.checked = false;
elements.inboxModal.classList.add('active');
await loadInbox(serviceId);
}
async function loadInbox(serviceId) {
if (!serviceId) return;
const onlyUnseen = elements.inboxOnlyUnseen.checked;
elements.inboxLoading.style.display = 'block';
elements.inboxTable.style.display = 'none';
elements.inboxEmpty.style.display = 'none';
elements.inboxEmpty.textContent = '暂无邮件';
try {
const params = new URLSearchParams({ count: 50, only_unseen: onlyUnseen });
const data = await api.get(`/email-services/${serviceId}/inbox?${params}`);
const emails = data.emails || [];
elements.inboxLoading.style.display = 'none';
if (emails.length === 0) {
elements.inboxEmpty.style.display = 'block';
return;
}
elements.inboxTbody.innerHTML = emails.map(m => {
const dataAttr = escapeHtml(JSON.stringify(m));
return `<tr style="cursor:pointer;" onclick="showEmailDetail(JSON.parse(this.dataset.mail))" data-mail="${dataAttr}">
<td style="text-align:center;">${m.is_read ? '' : '<span style="color:var(--primary);font-size:10px;">●</span>'}</td>
<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escapeHtml(m.subject)}">${escapeHtml(m.subject) || '(无主题)'}</td>
<td style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escapeHtml(m.sender)}">${escapeHtml(m.sender)}</td>
<td>${format.date(m.received_at)}</td>
</tr>`;
}).join('');
elements.inboxTable.style.display = 'table';
} catch (e) {
elements.inboxLoading.style.display = 'none';
elements.inboxEmpty.style.display = 'block';
elements.inboxEmpty.textContent = '加载失败:' + (e.message || '未知错误');
console.error('加载收件箱失败:', e);
}
}
function showEmailDetail(mail) {
elements.emailDetailSubject.textContent = mail.subject || '(无主题)';
elements.emailDetailSender.textContent = mail.sender || '';
elements.emailDetailDate.textContent = format.date(mail.received_at);
elements.emailDetailBody.textContent = mail.body || mail.body_preview || '(无正文)';
elements.emailDetailModal.classList.add('active');
}

View File

@@ -62,16 +62,13 @@
</div>
<div class="card-body" id="outlook-import-body" style="display: none;">
<div class="import-info">
<p><strong>支持格式:</strong></p>
<ul>
<li><code>邮箱----密码</code> (密码认证)</li>
<li><code>邮箱----密码----client_id----refresh_token</code> XOAUTH2 认证,推荐)</li>
</ul>
<p>每行一个账户,使用四个连字符(----)分隔字段。以 # 开头的行将被忽略。</p>
<p><strong>格式(每行一个账户)</strong></p>
<p><code>邮箱----密码----client_id----refresh_token</code></p>
<p>使用四个连字符(----)分隔字段,以 # 开头的行将被忽略。</p>
</div>
<div class="form-group">
<label for="outlook-import-data">批量导入数据</label>
<textarea id="outlook-import-data" rows="8" placeholder="example@outlook.com----password123&#10;test@outlook.com----password456----client_id----refresh_token"></textarea>
<textarea id="outlook-import-data" rows="8" placeholder="example@outlook.com----password123----client_id----refresh_token"></textarea>
</div>
<div class="form-row">
<div class="form-group">
@@ -516,6 +513,55 @@
</div>
<!-- 收件箱模态框 -->
<div class="modal" id="inbox-modal">
<div class="modal-content" style="max-width:800px;width:95%;">
<div class="modal-header">
<h3>📬 收件箱 — <span id="inbox-modal-email"></span></h3>
<div style="display:flex;gap:8px;align-items:center;">
<label style="display:flex;align-items:center;gap:4px;font-size:13px;">
<input type="checkbox" id="inbox-only-unseen"> 仅未读
</label>
<button class="btn btn-secondary btn-sm" id="inbox-refresh-btn">刷新</button>
<button class="modal-close" id="close-inbox-modal">&times;</button>
</div>
</div>
<div class="modal-body" style="padding:0;max-height:70vh;overflow-y:auto;">
<div id="inbox-loading" style="padding:32px;text-align:center;">加载中...</div>
<table class="data-table" id="inbox-table" style="display:none;">
<thead>
<tr>
<th style="width:36px;"></th>
<th>主题</th>
<th style="width:200px;">发件人</th>
<th style="width:150px;">时间</th>
</tr>
</thead>
<tbody id="inbox-tbody"></tbody>
</table>
<div id="inbox-empty" style="display:none;padding:32px;text-align:center;color:var(--text-muted);">暂无邮件</div>
</div>
</div>
</div>
<!-- 邮件正文模态框 -->
<div class="modal" id="email-detail-modal">
<div class="modal-content" style="max-width:700px;width:95%;">
<div class="modal-header">
<div>
<h3 id="email-detail-subject" style="margin:0;"></h3>
<div style="font-size:12px;color:var(--text-muted);margin-top:4px;">
<span id="email-detail-sender"></span> · <span id="email-detail-date"></span>
</div>
</div>
<button class="modal-close" id="close-email-detail-modal">&times;</button>
</div>
<div class="modal-body" style="max-height:65vh;overflow-y:auto;">
<div id="email-detail-body" style="white-space:pre-wrap;word-break:break-word;font-size:13px;"></div>
</div>
</div>
</div>
<script src="/static/js/utils.js?v={{ static_version }}"></script>
<script src="/static/js/email_services.js?v={{ static_version }}"></script>
</body>