diff --git a/src/api/mail.ts b/src/api/mail.ts index 4678ec4..a7130f3 100644 --- a/src/api/mail.ts +++ b/src/api/mail.ts @@ -61,15 +61,15 @@ export const mailApi = { /** 修改邮箱密码 */ changePassword: (NewPassword: string): Promise => post('/API/Mail/ChangePassword', { NewPassword }), - /** 邮箱登录:校验密码并换取会话 token(密码仅此一次传输,后端存内存) */ - login: (Password: string): Promise => post('/API/Mail/Login', { Password }), + /** 邮箱登录:校验密码并换取会话 token(密码仅此一次传输,后端存内存;401 为密码错误,不触发系统登出) */ + login: (Password: string): Promise => post('/API/Mail/Login', { Password }, { silent: true }), /** 邮箱登出:吊销会话 token */ logout: (Token: string): Promise => post('/API/Mail/Logout', { Token }), - /** JMAP 收发代理(携带会话 token,不再传输明文密码) */ + /** JMAP 收发代理(携带会话 token,不再传输明文密码;401 为邮箱会话失效,不触发系统登出) */ jmap: (data: { Token: string; MethodCalls: Array<[string, Record, string]>; AccountId?: string }): Promise => - post('/API/Mail/Jmap', data), + post('/API/Mail/Jmap', data, { silent: true }), /** 管理员:全部邮箱列表 */ adminList: (): Promise<{ list: MailAdminItem[]; total: number }> => get<{ list: MailAdminItem[]; total: number }>('/API/Mail/AdminList'), diff --git a/src/utils/jmap.ts b/src/utils/jmap.ts index 700c5b8..28c537a 100644 --- a/src/utils/jmap.ts +++ b/src/utils/jmap.ts @@ -41,6 +41,14 @@ export interface JmapEmail { inReplyTo?: string[] | null } +/** JMAP 会话失效错误(后端校验 token 不通过:过期/后端重启),前端应回退登录页 */ +export class JmapAuthError extends Error { + constructor() { + super('邮箱登录已失效,请重新登录') + this.name = 'JmapAuthError' + } +} + /** JMAP 标准方法调用结果 */ export interface JmapCallResult { methodResponses: Array<[string, Record, string]> @@ -51,10 +59,13 @@ export class JmapClient { readonly mailboxName: string readonly token: string private accountId = '' + /** 邮箱会话失效(401)时回调:前端据此清理本地会话并回退登录页 */ + onAuthExpired?: () => void - constructor(mailboxName: string, token: string) { + constructor(mailboxName: string, token: string, onAuthExpired?: () => void) { this.mailboxName = mailboxName this.token = token + this.onAuthExpired = onAuthExpired } /** 登录验证:通过一次 Mailbox/get 触发后端发现账户 ID */ @@ -64,13 +75,23 @@ export class JmapClient { /** 批量调用 JMAP 方法(经后端代理,携带会话 token,密码不落前端) */ async request(methodCalls: Array<[string, Record, string]>): Promise { - const result = await mailApi.jmap({ - Token: this.token, - MethodCalls: methodCalls, - AccountId: this.accountId || undefined, - }) - this.accountId = result.AccountId - return result.Data + try { + const result = await mailApi.jmap({ + Token: this.token, + MethodCalls: methodCalls, + AccountId: this.accountId || undefined, + }) + this.accountId = result.AccountId + return result.Data + } catch (e) { + // 后端校验 token 不通过(HTTP 401)→ 触发失效回调,抛 JmapAuthError 供调用方静默回退 + const status = (e as { response?: { status?: number } })?.response?.status + if (status === 401) { + this.onAuthExpired?.() + throw new JmapAuthError() + } + throw e + } } /** 提取首个指定方法的响应 */ @@ -93,14 +114,14 @@ export class JmapClient { /** 查询邮箱内邮件 id(mailboxId 为空时查全部;按接收时间倒序) */ async queryEmailIds(mailboxId: string | null = null, limit = 50): Promise { - const filter: Record = {} - if (mailboxId) filter.inMailbox = mailboxId - const payload = await this.call<{ ids: string[] }>('Email/query', { + // filter 为空时不传该字段:Stalwart v0.11 对空 filter {} 会返回 serverFail(全部邮件视图 502) + const args: Record = { accountId: this.accountId, - filter, sort: [{ property: 'receivedAt', isAscending: false }], limit, - }) + } + if (mailboxId) args.filter = { inMailbox: mailboxId } + const payload = await this.call<{ ids: string[] }>('Email/query', args) return payload.ids || [] } diff --git a/src/views/mail/Mailbox.vue b/src/views/mail/Mailbox.vue index da3c981..c577e31 100644 --- a/src/views/mail/Mailbox.vue +++ b/src/views/mail/Mailbox.vue @@ -4,7 +4,7 @@ * * 流程: * - 未申请 → 申请表单(邮箱名 + 密码 + 确认密码) - * - 已申请 → 输入邮箱密码登录 JMAP(凭据仅存内存)→ 收发邮件 + * - 已申请 → 企业化登录页输入密码登录 JMAP(token 存 localStorage,刷新自动恢复) * - 三栏布局:左侧文件夹树(未读数徽标)+ 中间邮件列表(多选/批量已读/删除) * + 右侧阅读窗格(回复/转发/删除) * - 支持:收件箱/全部/草稿/已发送/垃圾箱查看、标记已读、删除(移入垃圾箱)、 @@ -17,11 +17,11 @@ * - POST /API/Mail/Login → 校验密码并签发会话 token(密码仅传一次) * - POST /API/Mail/Jmap → JMAP 收发代理(携带 token,不传明文密码) */ -import { ref, computed, onMounted, nextTick } from 'vue' +import { ref, computed, onMounted } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' -import { Message, EditPen, Promotion, Delete, Folder, Warning, Refresh, RefreshLeft } from '@element-plus/icons-vue' +import { Message, EditPen, Promotion, Delete, Folder, Warning, Refresh, RefreshLeft, User, Lock } from '@element-plus/icons-vue' import { mailApi, type MailboxInfo, type MailSessionConfig } from '@/api/mail' -import { JmapClient, type JmapMailbox, type JmapEmail } from '@/utils/jmap' +import { JmapClient, JmapAuthError, type JmapMailbox, type JmapEmail } from '@/utils/jmap' // === 状态 === @@ -35,9 +35,9 @@ const applyForm = ref({ MailboxName: '', Password: '', ConfirmPassword: '' }) // JMAP 会话 const client = ref(null) -const loginVisible = ref(false) const loginForm = ref({ Password: '' }) const connecting = ref(false) +const restoring = ref(false) // 邮件数据 const ALL_ID = '__all__' @@ -62,6 +62,53 @@ const changePwdForm = ref({ NewPassword: '', ConfirmPassword: '' }) const myAddress = computed(() => mailbox.value?.MailboxName || '') +// === 邮箱会话持久化 === +// 登录成功后将会话 token 存入 localStorage,刷新/重开页面自动恢复,无需重复输入密码。 +// token 由后端签发(24h 有效,存进程内存映射邮箱凭据),失效时自动清除并回到登录页。 +const MAIL_TOKEN_KEY = 'gerp_mail_token' +const MAIL_NAME_KEY = 'gerp_mail_name' + +function saveMailSession(token: string): void { + localStorage.setItem(MAIL_TOKEN_KEY, token) + localStorage.setItem(MAIL_NAME_KEY, mailbox.value?.MailboxName || '') +} + +function clearMailSession(): void { + localStorage.removeItem(MAIL_TOKEN_KEY) + localStorage.removeItem(MAIL_NAME_KEY) +} + +/** 邮箱会话失效(后端校验 token 不通过):清除本地会话并回退到登录页 */ +function handleMailSessionExpired(): void { + clearMailSession() + client.value = null + mailboxes.value = [] + emails.value = [] + currentEmail.value = null + currentMailboxId.value = '' + selectedIds.value = [] + ElMessage.warning('邮箱登录已失效,请重新登录') +} + +/** 恢复上次邮箱会话:localStorage 中的 token 直接连接 JMAP,失效则自动清除 */ +async function restoreSession(): Promise { + if (!mailbox.value || client.value) return + const token = localStorage.getItem(MAIL_TOKEN_KEY) + const name = localStorage.getItem(MAIL_NAME_KEY) + if (!token || name !== mailbox.value.MailboxName) return + restoring.value = true + try { + const c = new JmapClient(name, token, handleMailSessionExpired) + await c.discover() + client.value = c + await loadMailboxes() + } catch { + clearMailSession() + } finally { + restoring.value = false + } +} + // === 初始化 === async function init(): Promise { @@ -76,7 +123,10 @@ async function init(): Promise { } } -onMounted(init) +onMounted(async () => { + await init() + await restoreSession() +}) // === 申请邮箱 === @@ -116,13 +166,14 @@ async function submitLogin(): Promise { try { // 后端校验密码并签发会话 token(密码仅此一次传输,后端存内存;后续 JMAP 调用只带 token) const result = await mailApi.login(loginForm.value.Password) - const c = new JmapClient(result.MailboxName, result.Token) + const c = new JmapClient(result.MailboxName, result.Token, handleMailSessionExpired) await c.discover() client.value = c - loginVisible.value = false + saveMailSession(result.Token) loginForm.value.Password = '' await loadMailboxes() } catch (e) { + if (e instanceof JmapAuthError) return ElMessage.error((e as Error).message || '登录失败') } finally { connecting.value = false @@ -184,6 +235,7 @@ async function loadMailboxes(): Promise { currentMailboxId.value = inbox?.id || '' await loadEmails() } catch (e) { + if (e instanceof JmapAuthError) return ElMessage.error((e as Error).message || '加载邮箱失败') } } @@ -196,6 +248,7 @@ async function loadEmails(): Promise { emails.value = ids.length ? await client.value.getEmails(ids) : [] selectedIds.value = [] } catch (e) { + if (e instanceof JmapAuthError) return ElMessage.error((e as Error).message || '加载邮件失败') } finally { emailsLoading.value = false @@ -244,6 +297,7 @@ async function markSelected(seen: boolean): Promise { selectedIds.value = [] ElMessage.success(seen ? '已标记为已读' : '已标记为未读') } catch (e) { + if (e instanceof JmapAuthError) return ElMessage.error((e as Error).message || '操作失败') } } @@ -266,6 +320,7 @@ async function deleteSelected(): Promise { selectedIds.value = [] ElMessage.success('已删除') } catch (e) { + if (e instanceof JmapAuthError) return ElMessage.error((e as Error).message || '删除失败') } } @@ -280,6 +335,7 @@ async function deleteCurrent(): Promise { currentEmail.value = null ElMessage.success('已删除') } catch (err) { + if (err instanceof JmapAuthError) return ElMessage.error((err as Error).message || '删除失败') } } @@ -366,6 +422,7 @@ async function submitCompose(): Promise { composeVisible.value = false await loadEmails() } catch (e) { + if (e instanceof JmapAuthError) return ElMessage.error((e as Error).message || '发送失败') } finally { sending.value = false @@ -389,6 +446,7 @@ async function submitChangePassword(): Promise { ElMessage.success('密码修改成功,请重新登录邮箱') changePwdVisible.value = false changePwdForm.value = { NewPassword: '', ConfirmPassword: '' } + clearMailSession() client.value = null mailboxes.value = [] emails.value = [] @@ -406,18 +464,13 @@ async function logoutMail(): Promise { currentEmail.value = null currentMailboxId.value = '' selectedIds.value = [] + clearMailSession() if (c) { try { await mailApi.logout(c.token) } catch { /* 忽略 */ } } ElMessage.success('已退出邮箱') } -// 打开邮箱登录 -async function openMailbox(): Promise { - loginVisible.value = true - await nextTick() -} - // === 渲染辅助 === const emailBodyText = computed(() => plainBody(currentEmail.value)) @@ -479,25 +532,40 @@ function isSelected(id: string): boolean { 申请邮箱 - + @@ -620,16 +688,6 @@ function isSelected(id: string): boolean { - - - - - - - @@ -681,37 +739,97 @@ function isSelected(id: string): boolean { align-items: stretch; } -/* 邮箱概览卡片 */ -.mailbox-card { - display: flex; - justify-content: center; - padding-top: 10vh; -} -.mailbox-card-inner { +/* 企业邮箱登录页 */ +.mail-login-wrap { + flex: 1; display: flex; + flex-direction: column; align-items: center; - gap: 20px; + justify-content: center; + gap: 24px; + background: + radial-gradient(1100px 480px at 50% -8%, var(--el-color-primary-light-8), transparent), + var(--el-bg-color-page); +} +.mail-login-card { + width: 380px; background: var(--el-bg-color); border: 1px solid var(--el-border-color-lighter); - border-radius: 8px; - padding: 32px 40px; - box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(31, 45, 61, 0.1); + padding: 36px 36px 24px; + display: flex; + flex-direction: column; + gap: 16px; } -.address-text { - font-size: 20px; - font-weight: 600; +.login-brand { + text-align: center; } -.address-meta { - margin-top: 6px; - color: var(--el-text-color-secondary); +.login-logo { + width: 56px; + height: 56px; + margin: 0 auto 12px; + border-radius: 14px; + background: linear-gradient(135deg, var(--el-color-primary), var(--el-color-primary-dark-2)); + color: #fff; display: flex; align-items: center; + justify-content: center; +} +.login-title { + font-size: 22px; + font-weight: 700; + color: var(--el-text-color-primary); +} +.login-subtitle { + margin-top: 4px; + font-size: 12px; + color: var(--el-text-color-secondary); +} +.login-address { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 12px; + border-radius: 6px; + background: var(--el-fill-color-light); + color: var(--el-text-color-regular); + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.login-submit { + width: 100%; + height: 42px; + font-size: 15px; +} +.login-links { + display: flex; + align-items: center; + justify-content: center; gap: 8px; } -.mailbox-actions { - display: flex; - align-items: center; - gap: 12px; +.link-divider { + width: 1px; + height: 12px; + background: var(--el-border-color); +} +.login-tip { + text-align: center; + font-size: 12px; + color: var(--el-text-color-secondary); +} +.login-footer { + font-size: 12px; + color: var(--el-text-color-placeholder); +} +/* 深色主题:登录页顶部光晕改用半透明主色(light-8 在深色下过亮) */ +html.dark .mail-login-wrap { + background: + radial-gradient(1100px 480px at 50% -8%, rgba(64, 158, 255, 0.14), transparent), + var(--el-bg-color-page); } /* === 三栏布局 === */ @@ -999,10 +1117,6 @@ function isSelected(id: string): boolean { align-items: center; justify-content: center; } -.login-tip { - color: var(--el-text-color-secondary); - margin-bottom: 12px; -} /* ===== 深色主题适配 ===== */ /* 选中态:light-9 背景在深色下过亮,改用半透明主色(与全局 global.scss 约定一致) */