api.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404
  1. import type {
  2. AddDomainPayload,
  3. AdminMailboxAccessEntry,
  4. AdminResourceInventory,
  5. AdminUser,
  6. ApiToken,
  7. ApiTokenInput,
  8. Analytics,
  9. AuditLogEntry,
  10. DnsCredential,
  11. Domain,
  12. DomainPatchPayload,
  13. InboundMailboxDomain,
  14. InboundMailbox,
  15. InboundMailboxDeleteResult,
  16. InboundFolder,
  17. InboundMessage,
  18. InboundMessageFilters,
  19. MailboxPermissions,
  20. MailboxClientConfig,
  21. RuntimeConfig,
  22. SendEvent,
  23. SendEventFilters,
  24. SmtpCredential,
  25. SmtpCredentialInput,
  26. SmtpRelay,
  27. SmtpRelayInput,
  28. SystemEmailSettings,
  29. User,
  30. UserMergeOptions,
  31. UserMergePreview,
  32. UserMergeResult,
  33. UserRole,
  34. UserStatus,
  35. WebmailLogin,
  36. Webhook,
  37. WebhookDelivery,
  38. WebhookDeliveryFilters,
  39. WebhookPatchPayload,
  40. WebhookPayload
  41. } from '../types';
  42. interface RequestOptions extends RequestInit {
  43. data?: unknown;
  44. }
  45. async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
  46. const headers = new Headers(options.headers);
  47. if (options.data !== undefined && !headers.has('Content-Type')) {
  48. headers.set('Content-Type', 'application/json');
  49. }
  50. const response = await send(path, {
  51. method: options.method || 'GET',
  52. headers,
  53. body: options.data === undefined ? options.body : JSON.stringify(options.data)
  54. });
  55. const text = response.text;
  56. const payload = text ? JSON.parse(text) : {};
  57. if (response.status < 200 || response.status >= 300) {
  58. throw new Error(payload.error || payload.message || requestFailedMessage(response.status));
  59. }
  60. return payload as T;
  61. }
  62. function send(
  63. path: string,
  64. options: { method: string; headers: Headers; body?: BodyInit | null }
  65. ): Promise<{ status: number; text: string }> {
  66. if (typeof globalThis.fetch === 'function') {
  67. return globalThis.fetch(path, {
  68. method: options.method,
  69. headers: options.headers,
  70. body: options.body
  71. }).then(async (response) => ({
  72. status: response.status,
  73. text: await response.text()
  74. }));
  75. }
  76. return new Promise((resolve, reject) => {
  77. const xhr = new XMLHttpRequest();
  78. xhr.open(options.method, path, true);
  79. options.headers.forEach((value, key) => xhr.setRequestHeader(key, value));
  80. xhr.onload = () => resolve({ status: xhr.status, text: xhr.responseText || '' });
  81. xhr.onerror = () => reject(new Error(networkFailedMessage()));
  82. xhr.send((options.body || null) as XMLHttpRequestBodyInit | null);
  83. });
  84. }
  85. function requestFailedMessage(status: number) {
  86. return currentLocale().startsWith('en') ? `Request failed: ${status}` : `请求失败:${status}`;
  87. }
  88. function networkFailedMessage() {
  89. return currentLocale().startsWith('en') ? 'Network request failed' : '网络请求失败';
  90. }
  91. function currentLocale() {
  92. return document.documentElement.lang || window.localStorage.getItem('mailhub.locale') || 'zh-CN';
  93. }
  94. export const api = {
  95. me: () => request<{ user: User }>('/api/me'),
  96. config: () => request<RuntimeConfig>('/api/config'),
  97. domains: () => request<{ domains: Domain[] }>('/api/domains'),
  98. events: (filters: SendEventFilters = {}) => {
  99. const query = queryString(filters);
  100. return request<{ events: SendEvent[]; total: number; page: number; pageSize: number }>(
  101. `/api/events${query ? `?${query}` : ''}`
  102. );
  103. },
  104. event: (id: number) => request<{ event: SendEvent | null }>(`/api/events/${id}`),
  105. inboundMailboxes: (scope: boolean | 'owned' | 'effective' | 'all' = false) => {
  106. const query = typeof scope === 'boolean'
  107. ? (scope ? '?all=true' : '')
  108. : `?scope=${encodeURIComponent(scope)}`;
  109. return request<{ mailboxes: InboundMailbox[] }>(`/api/inbound-mailboxes${query}`);
  110. },
  111. inboundMailboxDomains: () => request<{ domains: InboundMailboxDomain[] }>('/api/inbound-mailbox-domains'),
  112. createInboundMailbox: (data: {
  113. address: string;
  114. displayName?: string;
  115. password: string;
  116. aliases?: string | string[];
  117. forwardTo?: string | string[];
  118. keepForwarded?: boolean;
  119. quotaMb?: number | string | null;
  120. }) => request<{ mailbox: InboundMailbox; clientConfig?: MailboxClientConfig }>('/api/inbound-mailboxes', { method: 'POST', data }),
  121. updateInboundMailbox: (id: number, data: Partial<{
  122. displayName: string;
  123. password: string;
  124. aliases: string | string[];
  125. forwardTo: string | string[];
  126. keepForwarded: boolean;
  127. quotaMb: number | string | null;
  128. status: string;
  129. }>) => request<{ mailbox: InboundMailbox; clientConfig?: MailboxClientConfig }>(`/api/inbound-mailboxes/${id}`, {
  130. method: 'PATCH',
  131. data
  132. }),
  133. deleteInboundMailbox: async (
  134. id: number,
  135. data: { targetMailboxId: number | null; confirmAddress: string }
  136. ) => {
  137. const response = await request<InboundMailboxDeleteResult | { result: InboundMailboxDeleteResult }>(
  138. `/api/inbound-mailboxes/${id}`,
  139. { method: 'DELETE', data }
  140. );
  141. return 'result' in response ? response.result : response;
  142. },
  143. inboundMessages: (filters: InboundMessageFilters | number | null = {}) => {
  144. const normalized = typeof filters === 'number'
  145. ? { mailboxId: filters }
  146. : (filters || {});
  147. const query = queryString(normalized);
  148. return request<{ messages: InboundMessage[]; total: number; page: number; pageSize: number }>(
  149. `/api/inbound-messages${query ? `?${query}` : ''}`
  150. );
  151. },
  152. inboundFolders: (mailboxId: number) =>
  153. request<{ folders: InboundFolder[] }>(`/api/inbound-mailboxes/${mailboxId}/folders`),
  154. createWebmailLogin: (mailboxId: number) =>
  155. request<{ webmailLogin: WebmailLogin }>(`/api/inbound-mailboxes/${mailboxId}/webmail-login`, {
  156. method: 'POST'
  157. }),
  158. inboundMessage: (id: number) => request<{ message: InboundMessage | null }>(`/api/inbound-messages/${id}`),
  159. markInboundMessageRead: (id: number, read = true) =>
  160. request<{ message: InboundMessage | null }>(`/api/inbound-messages/${id}`, { method: 'PATCH', data: { read } }),
  161. analytics: (days = 7) => request<{ analytics: Analytics }>(`/api/analytics?days=${days}`),
  162. smtpCredential: () => request<{ credential: SmtpCredential | null }>('/api/smtp-credential'),
  163. saveSmtpCredential: (data: SmtpCredentialInput) =>
  164. request<{ credential: SmtpCredential }>('/api/smtp-credential', { method: 'PUT', data }),
  165. smtpCredentials: () => request<{ credentials: SmtpCredential[] }>('/api/smtp-credentials'),
  166. smtpCredentialDetail: (id: number) => request<{ credential: SmtpCredential }>(`/api/smtp-credentials/${id}`),
  167. saveSmtpLoginCredential: (data: SmtpCredentialInput, id?: number) =>
  168. request<{ credential: SmtpCredential }>(id ? `/api/smtp-credentials/${id}` : '/api/smtp-credentials', {
  169. method: id ? 'PATCH' : 'POST',
  170. data
  171. }),
  172. deleteSmtpCredential: (id: number) =>
  173. request<{ deleted: boolean }>(`/api/smtp-credentials/${id}`, { method: 'DELETE' }),
  174. smtpRelays: () => request<{ relays: SmtpRelay[] }>('/api/smtp-relays'),
  175. smtpRelay: (id: number) => request<{ relay: SmtpRelay }>(`/api/smtp-relays/${id}`),
  176. saveSmtpRelay: (data: SmtpRelayInput, id?: number) =>
  177. request<{ relay: SmtpRelay }>(id ? `/api/smtp-relays/${id}` : '/api/smtp-relays', {
  178. method: id ? 'PATCH' : 'POST',
  179. data
  180. }),
  181. deleteSmtpRelay: (id: number) =>
  182. request<{ deleted: boolean }>(`/api/smtp-relays/${id}`, { method: 'DELETE' }),
  183. dnsCredentials: () => request<{ credentials: DnsCredential[] }>('/api/dns-credentials'),
  184. saveDnsCredential: (data: object, id?: number) =>
  185. request<{ credential: DnsCredential }>(id ? `/api/dns-credentials/${id}` : '/api/dns-credentials', {
  186. method: id ? 'PATCH' : 'POST',
  187. data
  188. }),
  189. testDnsCredential: (id: number) =>
  190. request<{ ok: boolean; detail?: string; provider?: string; error?: string }>(`/api/dns-credentials/${id}/test`, {
  191. method: 'POST'
  192. }),
  193. deleteDnsCredential: (id: number) =>
  194. request<{ deleted: boolean }>(`/api/dns-credentials/${id}`, { method: 'DELETE' }),
  195. apiTokens: () => request<{ tokens: ApiToken[] }>('/api/api-tokens'),
  196. createApiToken: (data: ApiTokenInput & { name: string }) =>
  197. request<{ token: ApiToken }>('/api/api-tokens', { method: 'POST', data }),
  198. updateApiToken: (id: number, data: ApiTokenInput) =>
  199. request<{ token: ApiToken }>(`/api/api-tokens/${id}`, { method: 'PATCH', data }),
  200. rotateApiToken: (id: number) =>
  201. request<{ token: ApiToken }>(`/api/api-tokens/${id}/rotate`, { method: 'POST' }),
  202. deleteApiToken: (id: number) => request<{ deleted: boolean; revoked: boolean; token?: ApiToken | null }>(`/api/api-tokens/${id}`, { method: 'DELETE' }),
  203. createDomain: (data: AddDomainPayload) => request<{ domain: Domain }>('/api/domains', { method: 'POST', data }),
  204. patchDomain: (id: number, data: DomainPatchPayload) =>
  205. request<{ domain: Domain }>(`/api/domains/${id}`, { method: 'PATCH', data }),
  206. checkDomain: (id: number) => request<{ domain: Domain }>(`/api/domains/${id}/check`, { method: 'POST' }),
  207. applyDns: (id: number) =>
  208. request<{ domain: Domain; apply?: Domain['status']['apply'] }>(`/api/domains/${id}/apply-dns`, { method: 'POST' }),
  209. rotateDkim: (id: number, selector?: string) =>
  210. request<{ domain: Domain }>(`/api/domains/${id}/rotate-dkim`, { method: 'POST', data: { selector } }),
  211. sendTest: (
  212. id: number,
  213. data: {
  214. from?: string;
  215. to: string;
  216. subject?: string;
  217. text?: string;
  218. html?: string;
  219. tracking?: boolean | { opens?: boolean; clicks?: boolean };
  220. smtpRelayId?: number | string | null;
  221. }
  222. ) =>
  223. request<{ queued: boolean }>(`/api/domains/${id}/test-send`, { method: 'POST', data }),
  224. deleteDomain: (id: number) => request<{ deleted: boolean }>(`/api/domains/${id}`, { method: 'DELETE' }),
  225. adminSettings: () => request<{ settings: RuntimeConfig }>('/api/admin/settings'),
  226. saveAdminSettings: (data: Partial<RuntimeConfig>) =>
  227. request<{ settings: RuntimeConfig }>('/api/admin/settings', { method: 'PATCH', data }),
  228. adminUsers: () => request<{ users: AdminUser[] }>('/api/admin/users'),
  229. adminMailboxAccess: async () => {
  230. const result = await request<{
  231. mailboxes?: AdminMailboxAccessEntry[];
  232. entries?: AdminMailboxAccessEntry[];
  233. }>('/api/admin/mailbox-access');
  234. return { mailboxes: result.mailboxes || result.entries || [] };
  235. },
  236. saveAdminMailboxAccess: (
  237. id: number,
  238. grants: Array<{ userId: number } & MailboxPermissions>
  239. ) => request<{ mailbox: AdminMailboxAccessEntry }>(`/api/admin/inbound-mailboxes/${id}/access`, {
  240. method: 'PUT',
  241. data: { grants }
  242. }),
  243. bulkUpdateAdminMailboxAccess: (data: {
  244. mailboxIds: number[];
  245. userIds: number[];
  246. operation: 'upsert' | 'remove';
  247. permissions?: MailboxPermissions;
  248. }) => request<{
  249. mailboxes: AdminMailboxAccessEntry[];
  250. summary: {
  251. mailboxCount: number;
  252. userCount: number;
  253. pairCount?: number;
  254. changedGrantCount: number;
  255. unchangedGrantCount?: number;
  256. skippedOwnerCount: number;
  257. revokedWebmailSessionCount?: number;
  258. };
  259. }>('/api/admin/inbound-mailboxes/access/bulk', {
  260. method: 'POST',
  261. data
  262. }),
  263. updateAdminUser: (id: number, data: { role?: UserRole; status?: UserStatus; password?: string }) =>
  264. request<{ user: AdminUser }>(`/api/admin/users/${id}`, { method: 'PATCH', data }),
  265. approveAdminUser: (id: number) =>
  266. request<{ user: AdminUser }>(`/api/admin/users/${id}/approve`, { method: 'POST' }),
  267. resendAdminVerification: (id: number) =>
  268. request<{
  269. verificationEmailSent?: boolean;
  270. message: string;
  271. result?: SystemMailActionResult;
  272. }>(`/api/admin/users/${id}/resend-verification`, { method: 'POST' }),
  273. sendAdminPasswordReset: (id: number) =>
  274. request<{ result: SystemMailActionResult }>(`/api/admin/users/${id}/password-reset`, { method: 'POST' }),
  275. setAdminTemporaryPassword: (id: number, password: string) =>
  276. request<{ user: AdminUser }>(`/api/admin/users/${id}/temporary-password`, {
  277. method: 'POST',
  278. data: { password }
  279. }),
  280. adminResources: () => request<{ inventory: AdminResourceInventory }>('/api/admin/resources'),
  281. transferAdminDomain: (
  282. id: number,
  283. data: { targetUserId: number; dnsCredentialMode?: 'domain_only' | 'with_dns_credential' | 'clear_dns_credential' }
  284. ) => request<{ domain: Domain }>(`/api/admin/resources/domains/${id}/transfer`, { method: 'POST', data }),
  285. transferAdminDnsCredential: (id: number, data: { targetUserId: number }) =>
  286. request<{ credential: DnsCredential }>(`/api/admin/resources/dns-credentials/${id}/transfer`, {
  287. method: 'POST',
  288. data
  289. }),
  290. transferAdminApiTokens: (data: { tokenIds: number[]; targetUserId: number }) =>
  291. request<{ tokens: ApiToken[] }>('/api/admin/resources/api-tokens/transfer', { method: 'POST', data }),
  292. previewUserMerge: (data: { sourceUserId: number; targetUserId: number }) =>
  293. request<{ preview: UserMergePreview }>('/api/admin/migrations/user-merge/preview', { method: 'POST', data }),
  294. executeUserMerge: (data: {
  295. sourceUserId: number;
  296. targetUserId: number;
  297. options: Partial<UserMergeOptions>;
  298. confirmation: string;
  299. }) => request<{ result: UserMergeResult }>('/api/admin/migrations/user-merge/execute', { method: 'POST', data }),
  300. adminSystemEmail: () => request<{ settings: SystemEmailSettings }>('/api/admin/system-email'),
  301. saveAdminSystemEmail: (data: Partial<SystemEmailSettings>) =>
  302. request<{ settings: SystemEmailSettings }>('/api/admin/system-email', { method: 'PATCH', data }),
  303. testAdminSystemEmail: (to?: string) =>
  304. request<{ result: SystemMailActionResult }>('/api/admin/system-email/test', {
  305. method: 'POST',
  306. data: to ? { to } : {}
  307. }),
  308. adminAuditLogs: (query = '') =>
  309. request<{ logs: AuditLogEntry[] }>(`/api/admin/audit-logs${query ? `?${query}` : ''}`),
  310. resendVerification: (email: string) =>
  311. request<{ message: string }>('/api/auth/resend-verification', { method: 'POST', data: { email } }),
  312. forgotPassword: (email: string) =>
  313. request<{ message: string }>('/api/auth/forgot-password', { method: 'POST', data: { email } }),
  314. resetPassword: (token: string, password: string) =>
  315. request<{ message: string }>('/api/auth/reset-password', { method: 'POST', data: { token, password } }),
  316. logout: () => request<{ ok: boolean }>('/api/logout', { method: 'POST' }),
  317. webhooks: (domainId?: number | null, mailboxId?: number) => {
  318. const params = new URLSearchParams();
  319. if (domainId === null) params.set('domainId', 'null');
  320. else if (domainId !== undefined) params.set('domainId', String(domainId));
  321. if (mailboxId !== undefined) params.set('mailboxId', String(mailboxId));
  322. const query = params.toString();
  323. return request<{ webhooks: Webhook[] }>(`/api/webhooks${query ? `?${query}` : ''}`);
  324. },
  325. createWebhook: (data: WebhookPayload) =>
  326. request<{ webhook: Webhook }>('/api/webhooks', { method: 'POST', data }),
  327. updateWebhook: (id: number, data: WebhookPatchPayload) =>
  328. request<{ webhook: Webhook }>(`/api/webhooks/${id}`, { method: 'PATCH', data }),
  329. deleteWebhook: (id: number) =>
  330. request<{ deleted: boolean }>(`/api/webhooks/${id}`, { method: 'DELETE' }),
  331. rotateWebhookSecret: (id: number) =>
  332. request<{ webhook: Webhook }>(`/api/webhooks/${id}/rotate-secret`, { method: 'POST' }),
  333. testWebhook: (id: number) =>
  334. request<{ delivery: WebhookDelivery }>(`/api/webhooks/${id}/test`, { method: 'POST' }),
  335. webhookDeliveries: (filters: WebhookDeliveryFilters = {}) => {
  336. const params = new URLSearchParams();
  337. if (filters.status) params.set('status', String(filters.status));
  338. if (filters.webhookId != null) params.set('webhookId', String(filters.webhookId));
  339. if (filters.eventType) params.set('eventType', String(filters.eventType));
  340. if (filters.limit != null) params.set('limit', String(filters.limit));
  341. const query = params.toString();
  342. return request<{ deliveries: WebhookDelivery[] }>(
  343. `/api/webhook-deliveries${query ? `?${query}` : ''}`
  344. );
  345. },
  346. replayWebhookDelivery: (id: number) =>
  347. request<{ delivery: WebhookDelivery }>(`/api/webhook-deliveries/${id}/replay`, { method: 'POST' })
  348. };
  349. export function submitWebmailLogin(webmailLogin: WebmailLogin) {
  350. const form = document.createElement('form');
  351. form.hidden = true;
  352. form.method = 'POST';
  353. form.action = webmailLogin.action;
  354. form.target = '_self';
  355. form.acceptCharset = 'UTF-8';
  356. const fields = {
  357. mailhub_ticket: webmailLogin.ticket,
  358. _task: 'mail',
  359. _mbox: 'INBOX'
  360. };
  361. Object.entries(fields).forEach(([name, value]) => {
  362. const input = document.createElement('input');
  363. input.type = 'hidden';
  364. input.name = name;
  365. input.value = value;
  366. form.append(input);
  367. });
  368. document.body.append(form);
  369. try {
  370. if (typeof form.requestSubmit === 'function') form.requestSubmit();
  371. else form.submit();
  372. } finally {
  373. form.remove();
  374. }
  375. }
  376. function queryString(values: object) {
  377. const params = new URLSearchParams();
  378. Object.entries(values).forEach(([key, value]) => {
  379. if (value === undefined || value === null || value === '') return;
  380. params.set(key, typeof value === 'boolean' ? String(value) : String(value));
  381. });
  382. return params.toString();
  383. }
  384. interface SystemMailActionResult {
  385. ok: boolean;
  386. message: string;
  387. queueId?: string;
  388. }