/** * 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 || '创建邮件失败') } } }