- 我的邮箱页:申请/密码登录/收件箱/详情/删除/写信发信/改密 - 管理员邮箱管理页:列表与禁用 - JMAP 客户端(RFC 8620)经 /API/Mail/Jmap 后端代理,密码仅存内存 - 注册 mail/mailbox 与 mail/admin 路由(含 company/permission 权限页路由)
191 lines
6.4 KiB
TypeScript
191 lines
6.4 KiB
TypeScript
/**
|
||
* 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 || '创建邮件失败')
|
||
}
|
||
}
|
||
}
|