feat(mail): 新增我的邮箱与邮箱管理页面(后端统一代理收发)
- 我的邮箱页:申请/密码登录/收件箱/详情/删除/写信发信/改密 - 管理员邮箱管理页:列表与禁用 - JMAP 客户端(RFC 8620)经 /API/Mail/Jmap 后端代理,密码仅存内存 - 注册 mail/mailbox 与 mail/admin 路由(含 company/permission 权限页路由)
This commit is contained in:
1 parent
62a96af74b
commit
fb03d60d80
5 files changed
+922
No files matched your search
@@ -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 }),
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 || '创建邮件失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Reference in new issue
Block a user