feat(mail): 新增我的邮箱与邮箱管理页面(后端统一代理收发)

- 我的邮箱页:申请/密码登录/收件箱/详情/删除/写信发信/改密

- 管理员邮箱管理页:列表与禁用

- JMAP 客户端(RFC 8620)经 /API/Mail/Jmap 后端代理,密码仅存内存

- 注册 mail/mailbox 与 mail/admin 路由(含 company/permission 权限页路由)
This commit is contained in:
TermiNexus committed 2026-08-08 12:59:59 +08:00
1 parent 62a96af74b
commit fb03d60d80
5 files changed
+922

No files matched your search

+67
View File
@@ -0,0 +1,67 @@
import { get, post } from './request'
/** 我的邮箱信息 */
export interface MailboxInfo {
MailboxName: string
QuotaMB: number
Status: number
CreateTime: string | null
}
/** JMAP 连接配置 */
export interface MailSessionConfig {
ApiUrl: string
DiscoverUrl: string
Domain: string
}
/** 我的邮箱接口响应 */
export interface MyMailboxResult {
Mailbox: MailboxInfo | null
Session: MailSessionConfig
}
/** 管理员邮箱列表项 */
export interface MailAdminItem {
UserMailboxUUID: string
UserName: string
MailboxName: string
QuotaMB: number
Status: number
CreateTime: string | null
}
/** JMAP 代理请求结果 */
export interface JmapProxyResult {
AccountId: string
Data: {
methodResponses: Array<[string, Record<string, unknown>, string]>
sessionState: string
}
}
/** 邮箱 API 客户端 */
export const mailApi = {
/** 获取 JMAP 连接配置 */
session: (): Promise<MailSessionConfig> => get<MailSessionConfig>('/API/Mail/Session'),
/** 我的邮箱信息 */
myMailbox: (): Promise<MyMailboxResult> => get<MyMailboxResult>('/API/Mail/MyMailbox'),
/** 申请邮箱 */
apply: (data: { MailboxName: string; Password: string; QuotaMB?: number }): Promise<{ MailboxName: string; QuotaMB: number }> =>
post<{ MailboxName: string; QuotaMB: number }>('/API/Mail/Apply', data),
/** 修改邮箱密码 */
changePassword: (NewPassword: string): Promise<void> => post<void>('/API/Mail/ChangePassword', { NewPassword }),
/** JMAP 收发代理(后端统一转发,避免浏览器跨域) */
jmap: (data: { Password: string; MethodCalls: Array<[string, Record<string, unknown>, string]>; AccountId?: string }): Promise<JmapProxyResult> =>
post<JmapProxyResult>('/API/Mail/Jmap', data),
/** 管理员:全部邮箱列表 */
adminList: (): Promise<{ list: MailAdminItem[]; total: number }> => get<{ list: MailAdminItem[]; total: number }>('/API/Mail/AdminList'),
/** 管理员:禁用邮箱 */
disable: (UserMailboxUUID: string): Promise<void> => post<void>('/API/Mail/Disable', { UserMailboxUUID }),
}
+19
View File
@@ -104,6 +104,12 @@ export const staticRoutes: RouteRecordRaw[] = [
component: () => import('@/views/company/Role.vue'), component: () => import('@/views/company/Role.vue'),
meta: { title: '公司角色', icon: 'Avatar', requiresAuth: true, menuGuarded: true }, meta: { title: '公司角色', icon: 'Avatar', requiresAuth: true, menuGuarded: true },
}, },
{
path: 'company/permission',
name: 'CompanyPermission',
component: () => import('@/views/company/Permission.vue'),
meta: { title: '权限管理', requiresAuth: true },
},
// === 阶段 3:详情页 === // === 阶段 3:详情页 ===
{ {
path: 'system/user-detail', path: 'system/user-detail',
@@ -167,6 +173,19 @@ export const staticRoutes: RouteRecordRaw[] = [
component: () => import('@/views/system/Message.vue'), component: () => import('@/views/system/Message.vue'),
meta: { title: '站内信', icon: 'ChatDotRound', requiresAuth: true, menuGuarded: true }, meta: { title: '站内信', icon: 'ChatDotRound', requiresAuth: true, menuGuarded: true },
}, },
// === 企业邮箱(Stalwart) ===
{
path: 'mail/mailbox',
name: 'Mailbox',
component: () => import('@/views/mail/Mailbox.vue'),
meta: { title: '我的邮箱', icon: 'Message', requiresAuth: true, menuGuarded: true },
},
{
path: 'mail/admin',
name: 'MailAdmin',
component: () => import('@/views/mail/MailAdmin.vue'),
meta: { title: '邮箱管理', icon: 'Message', requiresAuth: true, menuGuarded: true },
},
{ {
path: 'system/tenant-create', path: 'system/tenant-create',
name: 'SystemTenantCreate', name: 'SystemTenantCreate',
+190
View File
@@ -0,0 +1,190 @@
/**
* JMAP 客户端(RFC 8620)——后端代理模式
*
* 所有 JMAP 调用统一经 Django 后端转发到 Stalwart(/API/Mail/Jmap),
* 避免浏览器跨域问题;邮箱密码仅存于前端内存,随请求传递给后端(不落库)。
*/
import { mailApi } from '@/api/mail'
/** JMAP 会话(/.well-known/jmap 返回) */
export interface JmapSession {
apiUrl: string
username: string
accounts: Record<string, { name: string; isPersonal: boolean }>
primaryAccounts: Record<string, string>
}
/** 邮箱(Mailbox) */
export interface JmapMailbox {
id: string
name: string
role: string | null
totalEmails: number
unreadEmails: number
}
/** 邮件摘要(Email/get properties) */
export interface JmapEmail {
id: string
subject: string
from: Array<{ name?: string; email: string }>
to: Array<{ name?: string; email: string }>
date: string
receivedAt: string
size: number
keywords: Record<string, boolean>
preview?: string
textBody?: Array<{ type: string; partId?: string; value?: string; blobId?: string }>
htmlBody?: Array<{ type: string; partId?: string; value?: string; blobId?: string }>
messageId: string[]
inReplyTo?: string[] | null
}
/** JMAP 标准方法调用结果 */
export interface JmapCallResult {
methodResponses: Array<[string, Record<string, unknown>, string]>
sessionState: string
}
export class JmapClient {
readonly mailboxName: string
readonly password: string
private accountId = ''
constructor(mailboxName: string, password: string) {
this.mailboxName = mailboxName
this.password = password
}
/** 登录验证:通过一次 Mailbox/get 触发后端发现账户 ID */
async discover(): Promise<void> {
await this.request([['Mailbox/get', {}, 'c0']])
}
/** 批量调用 JMAP 方法(经后端代理) */
async request(methodCalls: Array<[string, Record<string, unknown>, string]>): Promise<JmapCallResult> {
const result = await mailApi.jmap({
Password: this.password,
MethodCalls: methodCalls,
AccountId: this.accountId || undefined,
})
this.accountId = result.AccountId
return result.Data
}
/** 提取首个指定方法的响应 */
private async call<T>(method: string, args: Record<string, unknown>): Promise<T> {
const result = await this.request([[method, args, 'c0']])
const [, payload] = result.methodResponses[0] || []
if (!payload) throw new Error(`JMAP 方法 ${method} 无响应`)
const err = payload as unknown as { type?: string; description?: string; notFound?: unknown[] }
if (err.type === 'urn:ietf:params:jmap:error:serverFail' || err.type === 'urn:ietf:params:jmap:error:invalidArguments') {
throw new Error(err.description || `JMAP ${method} 失败`)
}
return payload as unknown as T
}
/** 获取全部邮箱 */
async getMailboxes(): Promise<JmapMailbox[]> {
const payload = await this.call<{ list: JmapMailbox[] }>('Mailbox/get', { accountId: this.accountId, ids: null })
return payload.list || []
}
/** 查询指定邮箱内的邮件 id(默认收件箱,按接收时间倒序) */
async queryEmailIds(mailboxId: string, limit = 50): Promise<string[]> {
const payload = await this.call<{ ids: string[] }>('Email/query', {
accountId: this.accountId,
filter: { inMailbox: mailboxId },
sort: [{ property: 'receivedAt', isAscending: false }],
limit,
})
return payload.ids || []
}
/** 获取邮件详情 */
async getEmails(ids: string[]): Promise<JmapEmail[]> {
const payload = await this.call<{ list: JmapEmail[] }>('Email/get', {
accountId: this.accountId,
ids,
properties: [
'id', 'subject', 'from', 'to', 'cc', 'date', 'receivedAt', 'size',
'keywords', 'preview', 'textBody', 'htmlBody', 'messageId', 'inReplyTo',
],
})
return payload.list || []
}
/** 标记已读/未读 */
async setSeen(emailId: string, seen: boolean): Promise<void> {
const keywords = seen ? { $seen: true } : { $seen: false }
await this.call<unknown>('Email/set', {
accountId: this.accountId,
update: { [emailId]: { keywords } },
})
}
/** 删除邮件 */
async deleteEmail(emailId: string): Promise<void> {
await this.call<unknown>('Email/set', {
accountId: this.accountId,
destroy: [emailId],
})
}
/** 获取发信身份(Identity) */
private async getIdentityId(): Promise<string> {
const payload = await this.call<{ list: Array<{ id: string }> }>('Identity/get', { accountId: this.accountId, ids: null })
const identity = payload.list?.[0]
if (!identity) throw new Error('未找到发信身份')
return identity.id
}
/** 发送邮件(通过 Drafts 邮箱 + EmailSubmission) */
async sendEmail(opts: {
fromEmail: string
fromName?: string
to: Array<{ name?: string; email: string }>
subject: string
textBody?: string
htmlBody?: string
}): Promise<void> {
const mailboxes = await this.getMailboxes()
const drafts = mailboxes.find((m) => m.role === 'drafts') || mailboxes[0]
const identityId = await this.getIdentityId()
const emailData: Record<string, unknown> = {
mailboxIds: { [drafts.id]: true },
from: [{ name: opts.fromName || '', email: opts.fromEmail }],
to: opts.to.map((t) => ({ name: t.name || '', email: t.email })),
subject: opts.subject,
}
if (opts.htmlBody) emailData.htmlBody = [{ type: 'text/html', value: opts.htmlBody }]
if (opts.textBody) emailData.textBody = [{ type: 'text/plain', value: opts.textBody }]
const result = await this.request([
['Email/set', { accountId: this.accountId, create: { d1: emailData } }, 'c1'],
['EmailSubmission/set', {
accountId: this.accountId,
create: {
s1: {
identityId,
emailId: '#d1',
envelope: {
mailFrom: { email: opts.fromEmail },
rcptTo: opts.to.map((t) => ({ email: t.email })),
},
},
},
}, 'c2'],
])
const [, created] = result.methodResponses[0] || []
const createdData = created as unknown as { created?: Record<string, unknown> }
if (!createdData?.created) {
const err = created as unknown as { notCreated?: Record<string, unknown> }
const firstErr = Object.values(err.notCreated || {})[0] as { type?: string; description?: string } | undefined
throw new Error(firstErr?.description || '创建邮件失败')
}
}
}
+102
View File
@@ -0,0 +1,102 @@
<script setup lang="ts">
/**
* 员工邮箱管理(管理员)
*
* 展示所有员工企业邮箱(用户名/邮箱/配额/状态/创建时间),支持禁用。
*
* 接口:
* - GET /API/Mail/AdminList → { list, total }
* - POST /API/Mail/Disable → body { UserMailboxUUID }
*/
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { mailApi, type MailAdminItem } from '@/api/mail'
const loading = ref(true)
const rows = ref<MailAdminItem[]>([])
async function load(): Promise<void> {
loading.value = true
try {
const result = await mailApi.adminList()
rows.value = result.list || []
} catch {
// 拦截器已提示
} finally {
loading.value = false
}
}
onMounted(load)
async function disable(row: MailAdminItem): Promise<void> {
try {
await ElMessageBox.confirm(`确定禁用邮箱 ${row.MailboxName} 吗?禁用后该邮箱将无法使用。`, '禁用确认', {
type: 'warning',
})
} catch {
return
}
try {
await mailApi.disable(row.UserMailboxUUID)
ElMessage.success('已禁用')
await load()
} catch {
// 拦截器已提示
}
}
</script>
<template>
<div class="mail-admin">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>员工邮箱管理</span>
<el-button :icon="'Refresh'" @click="load">刷新</el-button>
</div>
</template>
<el-table v-loading="loading" :data="rows">
<el-table-column prop="UserName" label="用户名" min-width="140" />
<el-table-column prop="MailboxName" label="邮箱" min-width="220" />
<el-table-column prop="QuotaMB" label="配额" width="100">
<template #default="{ row }">{{ row.QuotaMB }} MB</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.Status === 1 ? 'success' : 'danger'">{{ row.Status === 1 ? '启用' : '禁用' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="CreateTime" label="创建时间" width="180">
<template #default="{ row }">
{{ row.CreateTime ? new Date(row.CreateTime).toLocaleString('zh-CN', { hour12: false }) : '' }}
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center">
<template #default="{ row }">
<el-button v-if="row.Status === 1" text type="danger" size="small" @click="disable(row)">禁用</el-button>
<span v-else class="disabled-text">已禁用</span>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无员工邮箱" :image-size="80" />
</template>
</el-table>
</el-card>
</div>
</template>
<style scoped>
.mail-admin {
padding: 16px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.disabled-text {
color: var(--el-text-color-placeholder);
}
</style>
+544
View File
@@ -0,0 +1,544 @@
<script setup lang="ts">
/**
* 我的邮箱(Stalwart)
*
* 流程:
* - 未申请 → 申请表单(邮箱名 + 密码 + 确认密码)
* - 已申请 → 输入邮箱密码登录 JMAP(凭据仅存内存)→ 收发邮件
* - 支持:收件箱/全部/已发送查看、邮件详情(标记已读)、删除、写信发信、修改邮箱密码
*
* 接口:
* - GET /API/Mail/MyMailbox → { Mailbox, Session }
* - POST /API/Mail/Apply → 申请(MailboxName/Password/QuotaMB)
* - POST /API/Mail/ChangePassword → 改密(NewPassword)
* - 收发走浏览器直连 Stalwart JMAP(/jmap/),Basic 认证
*/
import { ref, computed, onMounted, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { mailApi, type MailboxInfo, type MailSessionConfig } from '@/api/mail'
import { JmapClient, buildBasicToken, type JmapMailbox, type JmapEmail } from '@/utils/jmap'
// === 状态 ===
const loading = ref(true)
const mailbox = ref<MailboxInfo | null>(null)
const session = ref<MailSessionConfig | null>(null)
// 申请表单
const applyVisible = ref(false)
const applyForm = ref({ MailboxName: '', Password: '', ConfirmPassword: '' })
// JMAP 会话
const client = ref<JmapClient | null>(null)
const jmapPassword = ref('')
const loginVisible = ref(false)
const loginForm = ref({ Password: '' })
const connecting = ref(false)
// 邮件数据
const mailboxes = ref<JmapMailbox[]>([])
const currentMailboxId = ref('')
const emails = ref<JmapEmail[]>([])
const emailsLoading = ref(false)
const totalEmails = ref(0)
// 详情 / 写信
const detailVisible = ref(false)
const currentEmail = ref<JmapEmail | null>(null)
const composeVisible = ref(false)
const composeForm = ref({ To: '', Subject: '', TextBody: '' })
const sending = ref(false)
// 改密
const changePwdVisible = ref(false)
const changePwdForm = ref({ NewPassword: '', ConfirmPassword: '' })
const myAddress = computed(() => mailbox.value?.MailboxName || '')
// === 初始化 ===
async function init(): Promise<void> {
try {
const result = await mailApi.myMailbox()
mailbox.value = result.Mailbox
session.value = result.Session
} catch {
// 拦截器已提示
} finally {
loading.value = false
}
}
onMounted(init)
// === 申请邮箱 ===
async function submitApply(): Promise<void> {
const f = applyForm.value
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{2,62}$/.test(f.MailboxName)) {
ElMessage.warning('邮箱名需为字母/数字/点/下划线/连字符,长度 3~63')
return
}
if (!/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/.test(f.Password)) {
ElMessage.warning('密码需 8~64 位且同时包含字母和数字')
return
}
if (f.Password !== f.ConfirmPassword) {
ElMessage.warning('两次输入的密码不一致')
return
}
try {
const result = await mailApi.apply({ MailboxName: f.MailboxName, Password: f.Password })
ElMessage.success(`邮箱 ${result.MailboxName} 申请成功`)
applyVisible.value = false
await init()
} catch {
// 拦截器已提示
}
}
// === 登录 JMAP ===
async function submitLogin(): Promise<void> {
if (!mailbox.value || !session.value) return
if (!loginForm.value.Password) {
ElMessage.warning('请输入邮箱密码')
return
}
connecting.value = true
try {
const c = new JmapClient(myAddress.value, loginForm.value.Password)
await c.discover()
client.value = c
jmapPassword.value = loginForm.value.Password
loginVisible.value = false
loginForm.value.Password = ''
await loadMailboxes()
} catch (e) {
ElMessage.error((e as Error).message || '登录失败')
} finally {
connecting.value = false
}
}
// === 邮件加载 ===
async function loadMailboxes(): Promise<void> {
if (!client.value) return
try {
mailboxes.value = await client.value.getMailboxes()
const inbox = mailboxes.value.find((m) => m.role === 'inbox') || mailboxes.value[0]
currentMailboxId.value = inbox?.id || ''
totalEmails.value = inbox?.totalEmails || 0
await loadEmails()
} catch (e) {
ElMessage.error((e as Error).message || '加载邮箱失败')
}
}
async function loadEmails(): Promise<void> {
if (!client.value || !currentMailboxId.value) return
emailsLoading.value = true
try {
const ids = await client.value.queryEmailIds(currentMailboxId.value, 50)
emails.value = ids.length ? await client.value.getEmails(ids) : []
} catch (e) {
ElMessage.error((e as Error).message || '加载邮件失败')
} finally {
emailsLoading.value = false
}
}
async function switchMailbox(id: string): Promise<void> {
currentMailboxId.value = id
await loadEmails()
}
// === 邮件详情 / 操作 ===
function formatSender(e: JmapEmail): string {
const from = e.from?.[0]
if (!from) return '未知发件人'
return from.name ? `${from.name} <${from.email}>` : from.email
}
async function openDetail(e: JmapEmail): Promise<void> {
currentEmail.value = e
detailVisible.value = true
if (!e.keywords?.$seen) {
await client.value?.setSeen(e.id, true)
e.keywords = { ...e.keywords, $seen: true }
}
}
function formatDate(iso: string): string {
if (!iso) return ''
const d = new Date(iso)
return d.toLocaleString('zh-CN', { hour12: false })
}
async function deleteEmail(e: JmapEmail): Promise<void> {
try {
await ElMessageBox.confirm('确定删除这封邮件吗?', '删除确认', { type: 'warning' })
} catch {
return
}
try {
await client.value?.deleteEmail(e.id)
emails.value = emails.value.filter((x) => x.id !== e.id)
if (currentEmail.value?.id === e.id) detailVisible.value = false
ElMessage.success('已删除')
} catch (err) {
ElMessage.error((err as Error).message || '删除失败')
}
}
function openCompose(): void {
composeForm.value = { To: '', Subject: '', TextBody: '' }
composeVisible.value = true
}
async function submitCompose(): Promise<void> {
if (!client.value) return
const toList = composeForm.value.To
.split(/[;,,;\s]+/)
.map((s) => s.trim())
.filter(Boolean)
.map((email) => ({ email }))
if (!toList.length) {
ElMessage.warning('请填写收件人')
return
}
if (!composeForm.value.Subject.trim() && !composeForm.value.TextBody.trim()) {
ElMessage.warning('请填写主题或正文')
return
}
sending.value = true
try {
await client.value.sendEmail({
fromEmail: myAddress.value,
to: toList,
subject: composeForm.value.Subject.trim() || '(无主题)',
textBody: composeForm.value.TextBody,
})
ElMessage.success('发送成功')
composeVisible.value = false
} catch (e) {
ElMessage.error((e as Error).message || '发送失败')
} finally {
sending.value = false
}
}
// === 改密 ===
async function submitChangePassword(): Promise<void> {
const f = changePwdForm.value
if (!/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/.test(f.NewPassword)) {
ElMessage.warning('新密码需 8~64 位且同时包含字母和数字')
return
}
if (f.NewPassword !== f.ConfirmPassword) {
ElMessage.warning('两次输入的新密码不一致')
return
}
try {
await mailApi.changePassword(f.NewPassword)
ElMessage.success('密码修改成功,请重新登录邮箱')
changePwdVisible.value = false
changePwdForm.value = { NewPassword: '', ConfirmPassword: '' }
client.value = null
mailboxes.value = []
emails.value = []
} catch {
// 拦截器已提示
}
}
function logoutMail(): void {
client.value = null
mailboxes.value = []
emails.value = []
ElMessage.success('已退出邮箱')
}
// === 渲染辅助 ===
const emailBodyText = computed(() => {
const e = currentEmail.value
if (!e) return ''
const textPart = e.textBody?.find((p) => p.value != null)
if (textPart?.value) return textPart.value
const htmlPart = e.htmlBody?.find((p) => p.value != null)
if (htmlPart?.value) return htmlPart.value.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ')
return '(无正文内容)'
})
const dateNow = Date.now()
// 打开邮箱详情页时挂载邮箱列表
async function openMailbox(): Promise<void> {
loginVisible.value = true
await nextTick()
}
</script>
<template>
<div class="mail-page">
<div v-loading="loading" class="mail-container">
<!-- 未申请邮箱 -->
<el-empty v-if="!loading && !mailbox" description="您还没有企业邮箱">
<el-button type="primary" @click="applyVisible = true">申请邮箱</el-button>
</el-empty>
<!-- 已申请:邮箱概览 -->
<template v-else-if="mailbox && !client">
<el-card shadow="never" class="mailbox-card">
<template #header>
<div class="card-header">
<span>我的企业邮箱</span>
<el-button text type="primary" @click="changePwdVisible = true">修改密码</el-button>
</div>
</template>
<div class="mailbox-info">
<div class="mailbox-address">
<el-icon :size="40" color="var(--el-color-primary)"><Message /></el-icon>
<div>
<div class="address-text">{{ mailbox.MailboxName }}</div>
<div class="address-meta">
配额 {{ mailbox.QuotaMB }} MB
<el-tag size="small" :type="mailbox.Status === 1 ? 'success' : 'danger'">
{{ mailbox.Status === 1 ? '启用' : '禁用' }}
</el-tag>
</div>
</div>
</div>
<el-button type="primary" size="large" @click="openMailbox">登录邮箱</el-button>
</div>
</el-card>
</template>
<!-- 已登录 JMAP:邮件工作台 -->
<template v-else-if="mailbox && client">
<div class="mail-toolbar">
<el-select :model-value="currentMailboxId" style="width: 180px" @change="switchMailbox">
<el-option v-for="m in mailboxes" :key="m.id" :value="m.id" :label="`${m.name}(${m.totalEmails})`" />
</el-select>
<el-button type="primary" :icon="'EditPen'" @click="openCompose">写信</el-button>
<el-button :icon="'Refresh'" @click="loadEmails">刷新</el-button>
<div class="spacer" />
<el-button text type="danger" @click="logoutMail">退出邮箱</el-button>
</div>
<el-table v-loading="emailsLoading" :data="emails" class="email-table" @row-click="(row: JmapEmail) => openDetail(row)">
<el-table-column width="50">
<template #default="{ row }">
<span v-if="!row.keywords?.$seen" class="unread-dot" />
</template>
</el-table-column>
<el-table-column prop="from" :width="260">
<template #default="{ row }">
<span :class="{ 'unread-text': !row.keywords?.$seen }">{{ formatSender(row) }}</span>
</template>
</el-table-column>
<el-table-column prop="subject" min-width="240">
<template #default="{ row }">
<span :class="{ 'unread-text': !row.keywords?.$seen }">
{{ row.subject || '(无主题)' }}
</span>
<span v-if="row.preview" class="email-preview"> — {{ row.preview }}</span>
</template>
</el-table-column>
<el-table-column :width="180" align="right">
<template #default="{ row }">
<div class="email-date">
<span>{{ formatDate(row.receivedAt || row.date) }}</span>
<el-button text type="danger" size="small" @click.stop="deleteEmail(row)">删除</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无邮件" :image-size="80" />
</template>
</el-table>
</template>
<el-empty v-else-if="!loading" description="暂无数据" />
</div>
<!-- 申请邮箱弹窗 -->
<el-dialog v-model="applyVisible" title="申请企业邮箱" width="420px">
<el-form label-width="80px">
<el-form-item label="邮箱名">
<el-input v-model="applyForm.MailboxName" placeholder="如 zhangsan" maxlength="63">
<template #append>@{{ session?.Domain || 'gvsds.com' }}</template>
</el-input>
</el-form-item>
<el-form-item label="密码">
<el-input v-model="applyForm.Password" type="password" show-password placeholder="8~64 位,含字母和数字" />
</el-form-item>
<el-form-item label="确认密码">
<el-input v-model="applyForm.ConfirmPassword" type="password" show-password placeholder="再次输入密码" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="applyVisible = false">取消</el-button>
<el-button type="primary" @click="submitApply">申请</el-button>
</template>
</el-dialog>
<!-- 邮箱登录弹窗 -->
<el-dialog v-model="loginVisible" title="登录邮箱" width="380px">
<div class="login-tip">使用邮箱 {{ myAddress }} 的密码登录</div>
<el-input v-model="loginForm.Password" type="password" show-password placeholder="邮箱密码" @keyup.enter="submitLogin" />
<template #footer>
<el-button @click="loginVisible = false">取消</el-button>
<el-button type="primary" :loading="connecting" @click="submitLogin">登录</el-button>
</template>
</el-dialog>
<!-- 邮件详情弹窗 -->
<el-dialog v-model="detailVisible" :title="currentEmail?.subject || '邮件详情'" width="640px">
<template v-if="currentEmail">
<div class="email-detail-meta">
<div><b>发件人:</b>{{ formatSender(currentEmail) }}</div>
<div><b>收件人:</b>{{ (currentEmail.to || []).map((t) => t.email).join(', ') }}</div>
<div><b>时间:</b>{{ formatDate(currentEmail.receivedAt || currentEmail.date) }}</div>
</div>
<el-divider />
<pre class="email-body">{{ emailBodyText }}</pre>
</template>
<template #footer>
<el-button @click="detailVisible = false">关闭</el-button>
<el-button type="danger" plain @click="deleteEmail(currentEmail!)">删除</el-button>
</template>
</el-dialog>
<!-- 写信弹窗 -->
<el-dialog v-model="composeVisible" title="写信" width="620px">
<el-form label-width="70px">
<el-form-item label="收件人">
<el-input v-model="composeForm.To" placeholder="多个收件人用逗号或分号分隔" />
</el-form-item>
<el-form-item label="主题">
<el-input v-model="composeForm.Subject" maxlength="200" />
</el-form-item>
<el-form-item label="正文">
<el-input v-model="composeForm.TextBody" type="textarea" :rows="10" placeholder="请输入邮件正文" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="composeVisible = false">取消</el-button>
<el-button type="primary" :loading="sending" @click="submitCompose">发送</el-button>
</template>
</el-dialog>
<!-- 改密弹窗 -->
<el-dialog v-model="changePwdVisible" title="修改邮箱密码" width="400px">
<el-form label-width="80px">
<el-form-item label="新密码">
<el-input v-model="changePwdForm.NewPassword" type="password" show-password placeholder="8~64 位,含字母和数字" />
</el-form-item>
<el-form-item label="确认密码">
<el-input v-model="changePwdForm.ConfirmPassword" type="password" show-password placeholder="再次输入新密码" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="changePwdVisible = false">取消</el-button>
<el-button type="primary" @click="submitChangePassword">确认修改</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.mail-page {
padding: 16px;
height: 100%;
box-sizing: border-box;
}
.mail-container {
height: 100%;
display: flex;
flex-direction: column;
}
.mailbox-card {
max-width: 560px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.mailbox-info {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.mailbox-address {
display: flex;
align-items: center;
gap: 12px;
}
.address-text {
font-size: 20px;
font-weight: 600;
}
.address-meta {
margin-top: 6px;
color: var(--el-text-color-secondary);
display: flex;
align-items: center;
gap: 8px;
}
.mail-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.mail-toolbar .spacer {
flex: 1;
}
.email-table {
cursor: pointer;
}
.unread-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--el-color-primary);
}
.unread-text {
font-weight: 700;
}
.email-preview {
color: var(--el-text-color-secondary);
}
.email-date {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
color: var(--el-text-color-secondary);
}
.login-tip {
color: var(--el-text-color-secondary);
margin-bottom: 12px;
}
.email-detail-meta {
line-height: 1.9;
color: var(--el-text-color-regular);
}
.email-body {
white-space: pre-wrap;
word-break: break-word;
font-family: inherit;
line-height: 1.7;
max-height: 420px;
overflow-y: auto;
margin: 0;
}
</style>