Files
GERP/src/utils/jmap.ts
T
TermiNexus fb03d60d80 feat(mail): 新增我的邮箱与邮箱管理页面(后端统一代理收发)
- 我的邮箱页:申请/密码登录/收件箱/详情/删除/写信发信/改密

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

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

- 注册 mail/mailbox 与 mail/admin 路由(含 company/permission 权限页路由)
2026-08-08 12:59:59 +08:00

191 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 || '创建邮件失败')
}
}
}