From fb03d60d8086d8b4b1b4f54fcb9f07166ade78d6 Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Sat, 8 Aug 2026 12:59:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(mail):=20=E6=96=B0=E5=A2=9E=E6=88=91?= =?UTF-8?q?=E7=9A=84=E9=82=AE=E7=AE=B1=E4=B8=8E=E9=82=AE=E7=AE=B1=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E9=A1=B5=E9=9D=A2=EF=BC=88=E5=90=8E=E7=AB=AF=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E4=BB=A3=E7=90=86=E6=94=B6=E5=8F=91=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 我的邮箱页:申请/密码登录/收件箱/详情/删除/写信发信/改密 - 管理员邮箱管理页:列表与禁用 - JMAP 客户端(RFC 8620)经 /API/Mail/Jmap 后端代理,密码仅存内存 - 注册 mail/mailbox 与 mail/admin 路由(含 company/permission 权限页路由) --- src/api/mail.ts | 67 +++++ src/router/index.ts | 19 ++ src/utils/jmap.ts | 190 ++++++++++++ src/views/mail/MailAdmin.vue | 102 +++++++ src/views/mail/Mailbox.vue | 544 +++++++++++++++++++++++++++++++++++ 5 files changed, 922 insertions(+) create mode 100644 src/api/mail.ts create mode 100644 src/utils/jmap.ts create mode 100644 src/views/mail/MailAdmin.vue create mode 100644 src/views/mail/Mailbox.vue diff --git a/src/api/mail.ts b/src/api/mail.ts new file mode 100644 index 0000000..915304e --- /dev/null +++ b/src/api/mail.ts @@ -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]> + sessionState: string + } +} + +/** 邮箱 API 客户端 */ +export const mailApi = { + /** 获取 JMAP 连接配置 */ + session: (): Promise => get('/API/Mail/Session'), + + /** 我的邮箱信息 */ + myMailbox: (): Promise => get('/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 => post('/API/Mail/ChangePassword', { NewPassword }), + + /** JMAP 收发代理(后端统一转发,避免浏览器跨域) */ + jmap: (data: { Password: string; MethodCalls: Array<[string, Record, string]>; AccountId?: string }): Promise => + post('/API/Mail/Jmap', data), + + /** 管理员:全部邮箱列表 */ + adminList: (): Promise<{ list: MailAdminItem[]; total: number }> => get<{ list: MailAdminItem[]; total: number }>('/API/Mail/AdminList'), + + /** 管理员:禁用邮箱 */ + disable: (UserMailboxUUID: string): Promise => post('/API/Mail/Disable', { UserMailboxUUID }), +} diff --git a/src/router/index.ts b/src/router/index.ts index 25dfe2f..1b2206d 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -104,6 +104,12 @@ export const staticRoutes: RouteRecordRaw[] = [ component: () => import('@/views/company/Role.vue'), meta: { title: '公司角色', icon: 'Avatar', requiresAuth: true, menuGuarded: true }, }, + { + path: 'company/permission', + name: 'CompanyPermission', + component: () => import('@/views/company/Permission.vue'), + meta: { title: '权限管理', requiresAuth: true }, + }, // === 阶段 3:详情页 === { path: 'system/user-detail', @@ -167,6 +173,19 @@ export const staticRoutes: RouteRecordRaw[] = [ component: () => import('@/views/system/Message.vue'), 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', name: 'SystemTenantCreate', diff --git a/src/utils/jmap.ts b/src/utils/jmap.ts new file mode 100644 index 0000000..0bbbb6d --- /dev/null +++ b/src/utils/jmap.ts @@ -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 + primaryAccounts: Record +} + +/** 邮箱(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 + 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]> + 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 { + await this.request([['Mailbox/get', {}, 'c0']]) + } + + /** 批量调用 JMAP 方法(经后端代理) */ + async request(methodCalls: Array<[string, Record, string]>): Promise { + const result = await mailApi.jmap({ + Password: this.password, + MethodCalls: methodCalls, + AccountId: this.accountId || undefined, + }) + this.accountId = result.AccountId + return result.Data + } + + /** 提取首个指定方法的响应 */ + private async call(method: string, args: Record): Promise { + 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 { + 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 { + 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 { + 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 { + const keywords = seen ? { $seen: true } : { $seen: false } + await this.call('Email/set', { + accountId: this.accountId, + update: { [emailId]: { keywords } }, + }) + } + + /** 删除邮件 */ + async deleteEmail(emailId: string): Promise { + await this.call('Email/set', { + accountId: this.accountId, + destroy: [emailId], + }) + } + + /** 获取发信身份(Identity) */ + private async getIdentityId(): Promise { + 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 { + const mailboxes = await this.getMailboxes() + const drafts = mailboxes.find((m) => m.role === 'drafts') || mailboxes[0] + const identityId = await this.getIdentityId() + + const emailData: Record = { + 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 } + if (!createdData?.created) { + const err = created as unknown as { notCreated?: Record } + const firstErr = Object.values(err.notCreated || {})[0] as { type?: string; description?: string } | undefined + throw new Error(firstErr?.description || '创建邮件失败') + } + } +} diff --git a/src/views/mail/MailAdmin.vue b/src/views/mail/MailAdmin.vue new file mode 100644 index 0000000..af2acf3 --- /dev/null +++ b/src/views/mail/MailAdmin.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/src/views/mail/Mailbox.vue b/src/views/mail/Mailbox.vue new file mode 100644 index 0000000..f26c994 --- /dev/null +++ b/src/views/mail/Mailbox.vue @@ -0,0 +1,544 @@ + + + + +