mirror of
https://github.com/cnlimiter/codex-register.git
synced 2026-05-06 20:02:51 +08:00
feat(email): 新增 Outlook 收件箱功能
- 后端:GET /api/email-services/{id}/inbox 端点,通过 IMAPNewProvider 读取收件箱
- 前端:收件箱列表模态框(支持仅未读筛选、刷新)
- 前端:邮件正文弹窗
- Outlook 操作列新增「收件箱」按钮
This commit is contained in:
@@ -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)}")
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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 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">×</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">×</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>
|
||||
|
||||
Reference in New Issue
Block a user