api.ts 15 KB

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