소스 검색

feat(webhooks): add frontend types, API client, and i18n

AI-Co-Authored-By: Grok
chendeben 1 개월 전
부모
커밋
094816a194
4개의 변경된 파일248개의 추가작업 그리고 2개의 파일을 삭제
  1. 138 0
      src/frontend/i18n/index.js
  2. 37 2
      src/frontend/services/api.ts
  3. 58 0
      src/frontend/types.ts
  4. 15 0
      test/frontend-i18n.test.js

+ 138 - 0
src/frontend/i18n/index.js

@@ -300,6 +300,69 @@ const messages = {
     'tokens.securityTipRotate': '不同环境使用不同 Token,泄露后立即删除并重新创建。',
     'tokens.securityTipDomain': 'From 域名必须属于当前账号,建议先完成 DNS 验证再接入生产发送。',
     'tokens.noTokenHint': '创建 Token 后可复制完整密钥;历史 Token 只显示前缀,示例中使用占位符。',
+    'webhooks.title': 'Webhooks',
+    'webhooks.subtitle': '在投递状态变为已送达、退信或失败时,向你的 HTTPS 端点推送通知。',
+    'webhooks.create': '新建 Webhook',
+    'webhooks.createTitle': '新建 Webhook',
+    'webhooks.editTitle': '编辑 Webhook',
+    'webhooks.name': '名称',
+    'webhooks.namePlaceholder': 'Production deliveries',
+    'webhooks.nameRequired': '请输入 Webhook 名称',
+    'webhooks.url': '回调 URL',
+    'webhooks.urlPlaceholder': 'https://example.com/webhooks/mailhub',
+    'webhooks.urlRequired': '请输入回调 URL',
+    'webhooks.urlHint': '生产环境请使用 HTTPS。签名校验使用 X-MailHub-Signature 请求头。',
+    'webhooks.events': '订阅事件',
+    'webhooks.eventsRequired': '请至少选择一个事件',
+    'webhooks.eventSent': '已送达',
+    'webhooks.eventBounced': '已退信',
+    'webhooks.eventFailed': '失败',
+    'webhooks.enabled': '启用',
+    'webhooks.disabled': '已停用',
+    'webhooks.scope': '作用域',
+    'webhooks.scopeAccount': '账号级',
+    'webhooks.scopeDomain': '域名级',
+    'webhooks.domain': '域名',
+    'webhooks.domainAccount': '整个账号',
+    'webhooks.domainOverrideHelp': '若某域名对某一事件存在任何已启用的端点,则该事件不会再投递到账号级端点。',
+    'webhooks.secret': '签名密钥',
+    'webhooks.secretPrefix': '密钥前缀',
+    'webhooks.secretCreatedTitle': 'Webhook 密钥已创建',
+    'webhooks.secretRotatedTitle': 'Webhook 密钥已轮换',
+    'webhooks.secretCreatedWarning': '完整密钥只会显示这一次。关闭后无法再次查看,请立即复制并保存到安全位置。',
+    'webhooks.copySecret': '复制完整密钥',
+    'webhooks.rotateSecret': '轮换密钥',
+    'webhooks.rotateConfirm': '确认轮换该 Webhook 的签名密钥?旧密钥将立即失效。',
+    'webhooks.test': '发送测试',
+    'webhooks.testQueued': '测试投递已入队',
+    'webhooks.deleteConfirm': '确认删除该 Webhook?相关投递记录也会一并删除。',
+    'webhooks.empty': '暂无 Webhook。创建后即可接收投递状态回调。',
+    'webhooks.listTitle': '端点列表',
+    'webhooks.actions': '操作',
+    'webhooks.deliveriesTitle': '投递记录',
+    'webhooks.deliveriesEmpty': '暂无投递记录',
+    'webhooks.deliveriesFilterWebhook': '按 Webhook 筛选',
+    'webhooks.deliveriesFilterStatus': '按状态筛选',
+    'webhooks.deliveriesFilterEvent': '按事件筛选',
+    'webhooks.allWebhooks': '全部 Webhook',
+    'webhooks.allStatuses': '全部状态',
+    'webhooks.allEvents': '全部事件',
+    'webhooks.statusPending': '待投递',
+    'webhooks.statusProcessing': '投递中',
+    'webhooks.statusSuccess': '成功',
+    'webhooks.statusDead': '已放弃',
+    'webhooks.attemptCount': '尝试次数',
+    'webhooks.responseStatus': 'HTTP 状态',
+    'webhooks.error': '错误',
+    'webhooks.replay': '重放',
+    'webhooks.replaySuccess': '投递已重新入队',
+    'webhooks.lastAttemptAt': '最近尝试',
+    'webhooks.nextAttemptAt': '下次尝试',
+    'webhooks.createdAt': '创建时间',
+    'webhooks.viewDeliveries': '查看投递',
+    'webhooks.docsTitle': '回调说明',
+    'webhooks.docsSignature': '使用 HMAC-SHA256 校验请求体;签名放在 X-MailHub-Signature。',
+    'webhooks.docsEvents': '事件类型:sent、bounced、failed(终端状态)。',
     'testMail.title': '发送测试邮件',
     'testMail.fromRequired': '请输入发件人',
     'testMail.toRequired': '请输入收件人',
@@ -322,6 +385,12 @@ const messages = {
     'actions.smtpRelayUpdated': 'SMTP 出口已更新',
     'actions.smtpRelayDeleted': 'SMTP 出口已删除',
     'actions.settingsSaved': '系统设置已保存',
+    'actions.webhookCreated': 'Webhook 已创建',
+    'actions.webhookUpdated': 'Webhook 已更新',
+    'actions.webhookDeleted': 'Webhook 已删除',
+    'actions.webhookSecretRotated': 'Webhook 密钥已轮换',
+    'actions.webhookTestQueued': 'Webhook 测试已入队',
+    'actions.webhookDeliveryReplayed': 'Webhook 投递已重放',
     'nav.dashboard': '仪表盘',
     'nav.domains': '发信域名',
     'nav.dnsApi': 'DNS API',
@@ -633,6 +702,69 @@ const messages = {
     'tokens.securityTipRotate': 'Use separate tokens per environment. Delete and recreate immediately after a leak.',
     'tokens.securityTipDomain': 'The From domain must belong to this account. Verify DNS before production sending.',
     'tokens.noTokenHint': 'Copy the full secret right after creation. Historical tokens show prefixes only, so examples use a placeholder.',
+    'webhooks.title': 'Webhooks',
+    'webhooks.subtitle': 'Push delivery updates to your HTTPS endpoint when mail is sent, bounced, or failed.',
+    'webhooks.create': 'Create webhook',
+    'webhooks.createTitle': 'Create webhook',
+    'webhooks.editTitle': 'Edit webhook',
+    'webhooks.name': 'Name',
+    'webhooks.namePlaceholder': 'Production deliveries',
+    'webhooks.nameRequired': 'Enter a webhook name',
+    'webhooks.url': 'Callback URL',
+    'webhooks.urlPlaceholder': 'https://example.com/webhooks/mailhub',
+    'webhooks.urlRequired': 'Enter a callback URL',
+    'webhooks.urlHint': 'Use HTTPS in production. Verify requests with the X-MailHub-Signature header.',
+    'webhooks.events': 'Events',
+    'webhooks.eventsRequired': 'Select at least one event',
+    'webhooks.eventSent': 'Sent',
+    'webhooks.eventBounced': 'Bounced',
+    'webhooks.eventFailed': 'Failed',
+    'webhooks.enabled': 'Enabled',
+    'webhooks.disabled': 'Disabled',
+    'webhooks.scope': 'Scope',
+    'webhooks.scopeAccount': 'Account',
+    'webhooks.scopeDomain': 'Domain',
+    'webhooks.domain': 'Domain',
+    'webhooks.domainAccount': 'Entire account',
+    'webhooks.domainOverrideHelp': 'If this domain has any enabled endpoint for an event, account-level endpoints for that event are skipped.',
+    'webhooks.secret': 'Signing secret',
+    'webhooks.secretPrefix': 'Secret prefix',
+    'webhooks.secretCreatedTitle': 'Webhook secret created',
+    'webhooks.secretRotatedTitle': 'Webhook secret rotated',
+    'webhooks.secretCreatedWarning': 'The full secret is shown only once. Copy it now and store it somewhere secure.',
+    'webhooks.copySecret': 'Copy full secret',
+    'webhooks.rotateSecret': 'Rotate secret',
+    'webhooks.rotateConfirm': 'Rotate this webhook signing secret? The previous secret stops working immediately.',
+    'webhooks.test': 'Send test',
+    'webhooks.testQueued': 'Test delivery queued',
+    'webhooks.deleteConfirm': 'Delete this webhook? Related delivery records will also be removed.',
+    'webhooks.empty': 'No webhooks yet. Create one to receive delivery status callbacks.',
+    'webhooks.listTitle': 'Endpoints',
+    'webhooks.actions': 'Actions',
+    'webhooks.deliveriesTitle': 'Deliveries',
+    'webhooks.deliveriesEmpty': 'No deliveries yet',
+    'webhooks.deliveriesFilterWebhook': 'Filter by webhook',
+    'webhooks.deliveriesFilterStatus': 'Filter by status',
+    'webhooks.deliveriesFilterEvent': 'Filter by event',
+    'webhooks.allWebhooks': 'All webhooks',
+    'webhooks.allStatuses': 'All statuses',
+    'webhooks.allEvents': 'All events',
+    'webhooks.statusPending': 'Pending',
+    'webhooks.statusProcessing': 'Processing',
+    'webhooks.statusSuccess': 'Success',
+    'webhooks.statusDead': 'Dead',
+    'webhooks.attemptCount': 'Attempts',
+    'webhooks.responseStatus': 'HTTP status',
+    'webhooks.error': 'Error',
+    'webhooks.replay': 'Replay',
+    'webhooks.replaySuccess': 'Delivery re-queued',
+    'webhooks.lastAttemptAt': 'Last attempt',
+    'webhooks.nextAttemptAt': 'Next attempt',
+    'webhooks.createdAt': 'Created at',
+    'webhooks.viewDeliveries': 'View deliveries',
+    'webhooks.docsTitle': 'Callback notes',
+    'webhooks.docsSignature': 'Verify the body with HMAC-SHA256; the signature is in X-MailHub-Signature.',
+    'webhooks.docsEvents': 'Event types: sent, bounced, and failed (terminal statuses).',
     'testMail.title': 'Send test email',
     'testMail.fromRequired': 'Enter the sender',
     'testMail.toRequired': 'Enter the recipient',
@@ -655,6 +787,12 @@ const messages = {
     'actions.smtpRelayUpdated': 'SMTP relay updated',
     'actions.smtpRelayDeleted': 'SMTP relay deleted',
     'actions.settingsSaved': 'System settings saved',
+    'actions.webhookCreated': 'Webhook created',
+    'actions.webhookUpdated': 'Webhook updated',
+    'actions.webhookDeleted': 'Webhook deleted',
+    'actions.webhookSecretRotated': 'Webhook secret rotated',
+    'actions.webhookTestQueued': 'Webhook test queued',
+    'actions.webhookDeliveryReplayed': 'Webhook delivery replayed',
     'nav.dashboard': 'Dashboard',
     'nav.domains': 'Domains',
     'nav.dnsApi': 'DNS API',

+ 37 - 2
src/frontend/services/api.ts

@@ -19,7 +19,12 @@ import type {
   UserMergePreview,
   UserMergeResult,
   UserRole,
-  UserStatus
+  UserStatus,
+  Webhook,
+  WebhookDelivery,
+  WebhookDeliveryFilters,
+  WebhookPatchPayload,
+  WebhookPayload
 } from '../types';
 
 interface RequestOptions extends RequestInit {
@@ -195,7 +200,37 @@ export const api = {
     request<{ message: string }>('/api/auth/forgot-password', { method: 'POST', data: { email } }),
   resetPassword: (token: string, password: string) =>
     request<{ message: string }>('/api/auth/reset-password', { method: 'POST', data: { token, password } }),
-  logout: () => request<{ ok: boolean }>('/api/logout', { method: 'POST' })
+  logout: () => request<{ ok: boolean }>('/api/logout', { method: 'POST' }),
+  webhooks: (domainId?: number | null) => {
+    const params = new URLSearchParams();
+    if (domainId === null) params.set('domainId', 'null');
+    else if (domainId !== undefined) params.set('domainId', String(domainId));
+    const query = params.toString();
+    return request<{ webhooks: Webhook[] }>(`/api/webhooks${query ? `?${query}` : ''}`);
+  },
+  createWebhook: (data: WebhookPayload) =>
+    request<{ webhook: Webhook }>('/api/webhooks', { method: 'POST', data }),
+  updateWebhook: (id: number, data: WebhookPatchPayload) =>
+    request<{ webhook: Webhook }>(`/api/webhooks/${id}`, { method: 'PATCH', data }),
+  deleteWebhook: (id: number) =>
+    request<{ deleted: boolean }>(`/api/webhooks/${id}`, { method: 'DELETE' }),
+  rotateWebhookSecret: (id: number) =>
+    request<{ webhook: Webhook }>(`/api/webhooks/${id}/rotate-secret`, { method: 'POST' }),
+  testWebhook: (id: number) =>
+    request<{ delivery: WebhookDelivery }>(`/api/webhooks/${id}/test`, { method: 'POST' }),
+  webhookDeliveries: (filters: WebhookDeliveryFilters = {}) => {
+    const params = new URLSearchParams();
+    if (filters.status) params.set('status', String(filters.status));
+    if (filters.webhookId != null) params.set('webhookId', String(filters.webhookId));
+    if (filters.eventType) params.set('eventType', String(filters.eventType));
+    if (filters.limit != null) params.set('limit', String(filters.limit));
+    const query = params.toString();
+    return request<{ deliveries: WebhookDelivery[] }>(
+      `/api/webhook-deliveries${query ? `?${query}` : ''}`
+    );
+  },
+  replayWebhookDelivery: (id: number) =>
+    request<{ delivery: WebhookDelivery }>(`/api/webhook-deliveries/${id}/replay`, { method: 'POST' })
 };
 
 interface SystemMailActionResult {

+ 58 - 0
src/frontend/types.ts

@@ -367,3 +367,61 @@ export interface DomainPatchPayload {
   dmarcPolicy?: string;
   dmarcRua?: string;
 }
+
+export type WebhookEvent = 'sent' | 'bounced' | 'failed';
+export type WebhookDeliveryStatus = 'pending' | 'processing' | 'success' | 'dead';
+
+export interface Webhook {
+  id: number;
+  userId: number;
+  domainId: number | null;
+  name: string;
+  url: string;
+  secretPrefix: string;
+  events: WebhookEvent[];
+  enabled: boolean;
+  createdAt: string;
+  updatedAt: string;
+  /** Present only on create / rotate-secret responses. */
+  secret?: string;
+}
+
+export interface WebhookPayload {
+  name: string;
+  url: string;
+  events: WebhookEvent[];
+  domainId?: number | string | null;
+  enabled?: boolean;
+}
+
+export interface WebhookPatchPayload {
+  name?: string;
+  url?: string;
+  events?: WebhookEvent[];
+  domainId?: number | string | null;
+  enabled?: boolean;
+}
+
+export interface WebhookDelivery {
+  id: number;
+  webhookId: number;
+  userId: number;
+  sendEventId: number;
+  eventType: WebhookEvent | string;
+  payloadJson?: string;
+  status: WebhookDeliveryStatus | string;
+  attemptCount: number;
+  nextAttemptAt?: string | null;
+  lastAttemptAt?: string | null;
+  responseStatus?: number | null;
+  responseBodyPreview?: string;
+  error?: string;
+  createdAt: string;
+}
+
+export interface WebhookDeliveryFilters {
+  status?: WebhookDeliveryStatus | string;
+  webhookId?: number;
+  eventType?: WebhookEvent | string;
+  limit?: number;
+}

+ 15 - 0
test/frontend-i18n.test.js

@@ -77,3 +77,18 @@ test('translates smtp login credential labels separately from outbound relays',
   assert.equal(en('smtp.loginCredentialsTitle'), 'Sending login credentials');
   assert.equal(en('smtpRelay.title'), 'Advanced: external SMTP relays');
 });
+
+test('translates webhook page chrome strings', () => {
+  const zh = createTranslator('zh-CN');
+  const en = createTranslator('en-US');
+
+  assert.equal(zh('webhooks.title'), 'Webhooks');
+  assert.equal(zh('webhooks.secretCreatedWarning'), '完整密钥只会显示这一次。关闭后无法再次查看,请立即复制并保存到安全位置。');
+  assert.equal(zh('webhooks.eventSent'), '已送达');
+  assert.equal(zh('webhooks.replay'), '重放');
+  assert.equal(zh('webhooks.domainOverrideHelp'), '若某域名对某一事件存在任何已启用的端点,则该事件不会再投递到账号级端点。');
+  assert.equal(en('webhooks.title'), 'Webhooks');
+  assert.equal(en('webhooks.create'), 'Create webhook');
+  assert.equal(en('webhooks.statusDead'), 'Dead');
+  assert.equal(en('actions.webhookCreated'), 'Webhook created');
+});