瀏覽代碼

feat: add api token usage guide

AI-Co-Authored-By: Codex
chendeben 1 月之前
父節點
當前提交
60b7a86b36

File diff suppressed because it is too large
+ 1 - 0
public/assets/index-CLNbnZjS.js


File diff suppressed because it is too large
+ 0 - 0
public/assets/login-3I-QNHaj.js


File diff suppressed because it is too large
+ 0 - 0
public/assets/styles-BqV0Lkls.css


File diff suppressed because it is too large
+ 0 - 0
public/assets/styles-CLwaXWnz.js


+ 3 - 3
public/index.html

@@ -4,9 +4,9 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>MailHub</title>
-    <script type="module" crossorigin src="/assets/index-D13udm4g.js"></script>
-    <link rel="modulepreload" crossorigin href="/assets/styles-DmsKpU2U.js">
-    <link rel="stylesheet" crossorigin href="/assets/styles-DMRLjM8Z.css">
+    <script type="module" crossorigin src="/assets/index-CLNbnZjS.js"></script>
+    <link rel="modulepreload" crossorigin href="/assets/styles-CLwaXWnz.js">
+    <link rel="stylesheet" crossorigin href="/assets/styles-BqV0Lkls.css">
     <link rel="stylesheet" crossorigin href="/assets/index-Tu04tXLf.css">
   </head>
   <body>

+ 3 - 3
public/login.html

@@ -4,9 +4,9 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>MailHub Auth</title>
-    <script type="module" crossorigin src="/assets/login-Bf3wwaVX.js"></script>
-    <link rel="modulepreload" crossorigin href="/assets/styles-DmsKpU2U.js">
-    <link rel="stylesheet" crossorigin href="/assets/styles-DMRLjM8Z.css">
+    <script type="module" crossorigin src="/assets/login-3I-QNHaj.js"></script>
+    <link rel="modulepreload" crossorigin href="/assets/styles-CLwaXWnz.js">
+    <link rel="stylesheet" crossorigin href="/assets/styles-BqV0Lkls.css">
   </head>
   <body>
     <div id="auth-root"></div>

+ 10 - 1
src/frontend/App.tsx

@@ -451,7 +451,16 @@ function MailHubConsole() {
       );
     }
     if (activeView === 'tokens') {
-      return <ApiTokens tokens={data.apiTokens} loading={actionLoading} onCreate={createApiToken} onDelete={deleteApiToken} onCopy={copy} />;
+      return (
+        <ApiTokens
+          tokens={data.apiTokens}
+          config={data.config}
+          loading={actionLoading}
+          onCreate={createApiToken}
+          onDelete={deleteApiToken}
+          onCopy={copy}
+        />
+      );
     }
     if (activeView === 'logs') {
       return <SendingLogs events={data.events} domains={data.domains} />;

+ 48 - 0
src/frontend/api-token-model.js

@@ -13,3 +13,51 @@ export function getCopyableApiToken(token = {}) {
 export function formatApiTokenPrefix(token = {}) {
   return token.tokenPrefix ? `${token.tokenPrefix}...` : '-';
 }
+
+export function buildApiUsageExamples({
+  endpoint = '/api/send',
+  token = '<USER_API_TOKEN>',
+  from = 'noreply@example.com',
+  to = 'user@example.com'
+} = {}) {
+  const body = {
+    from,
+    to,
+    subject: 'Hello from MailHub',
+    text: 'Signed with DKIM and queued by MailHub.'
+  };
+  const requestBody = JSON.stringify(body, null, 2);
+
+  return {
+    requestBody,
+    successResponse: JSON.stringify({
+      queued: true,
+      domain: domainFromAddress(from) || 'example.com',
+      recipients: [to],
+      smtp: 'Message queued'
+    }, null, 2),
+    curl: `curl -X POST ${endpoint} \\
+  -H 'Authorization: Bearer ${token}' \\
+  -H 'Content-Type: application/json' \\
+  -d '${requestBody}'`,
+    nodeFetch: `const response = await fetch('${endpoint}', {
+  method: 'POST',
+  headers: {
+    Authorization: 'Bearer ${token}',
+    'Content-Type': 'application/json'
+  },
+  body: JSON.stringify({
+    from: '${from}',
+    to: '${to}',
+    subject: 'Hello from MailHub',
+    text: 'Signed with DKIM and queued by MailHub.'
+  })
+});
+
+const result = await response.json();`
+  };
+}
+
+function domainFromAddress(value) {
+  return String(value || '').split('@')[1] || '';
+}

+ 46 - 6
src/frontend/i18n/index.js

@@ -212,6 +212,26 @@ const messages = {
     'tokens.deleteConfirm': '确认删除该 Token?',
     'tokens.copyCreated': '复制完整 Token',
     'tokens.prefixOnlyHelp': '历史 Token 不保存明文,只能复制前缀用于识别。',
+    'tokens.docsTitle': '发送 API 使用文档',
+    'tokens.endpoint': 'API Endpoint',
+    'tokens.authHeader': '认证方式',
+    'tokens.authHeaderValue': 'Authorization: Bearer <USER_API_TOKEN>',
+    'tokens.contentType': 'Content-Type',
+    'tokens.requestFields': '请求字段',
+    'tokens.fieldFrom': 'from:发件地址,域名必须已添加并通过验证。',
+    'tokens.fieldTo': 'to:收件人地址,支持单个邮箱或邮箱数组。',
+    'tokens.fieldSubject': 'subject:邮件主题。',
+    'tokens.fieldText': 'text:纯文本正文。',
+    'tokens.fieldHtml': 'html:HTML 正文,可选。',
+    'tokens.curlExample': 'curl 示例',
+    'tokens.nodeExample': 'Node / Fetch 示例',
+    'tokens.requestExample': '请求 Body 示例',
+    'tokens.responseExample': '成功响应示例',
+    'tokens.securityTips': '安全建议',
+    'tokens.securityTipStore': '只在服务端环境保存 Token,不要放进浏览器前端代码或公开仓库。',
+    'tokens.securityTipRotate': '不同环境使用不同 Token,泄露后立即删除并重新创建。',
+    'tokens.securityTipDomain': 'From 域名必须属于当前账号,建议先完成 DNS 验证再接入生产发送。',
+    'tokens.noTokenHint': '创建 Token 后可复制完整密钥;历史 Token 只显示前缀,示例中使用占位符。',
     'testMail.title': '发送测试邮件',
     'testMail.fromRequired': '请输入发件人',
     'testMail.toRequired': '请输入收件人',
@@ -228,14 +248,14 @@ const messages = {
     'actions.dnsApiTestCompleted': '连接测试完成',
     'actions.smtpSaved': 'SMTP 凭据已保存',
     'actions.settingsSaved': '系统设置已保存',
-    'nav.dashboard': 'Dashboard',
-    'nav.domains': 'Domains',
+    'nav.dashboard': '仪表盘',
+    'nav.domains': '发信域名',
     'nav.dnsApi': 'DNS API',
-    'nav.smtp': 'SMTP Credentials',
-    'nav.tokens': 'API Tokens',
-    'nav.logs': 'Sending Logs',
+    'nav.smtp': 'SMTP 凭据',
+    'nav.tokens': 'API Token',
+    'nav.logs': '发送记录',
     'nav.webhooks': 'Webhooks',
-    'nav.settings': 'Settings'
+    'nav.settings': '系统设置'
   },
   'en-US': {
     'common.account': 'Account',
@@ -447,6 +467,26 @@ const messages = {
     'tokens.deleteConfirm': 'Delete this token?',
     'tokens.copyCreated': 'Copy full token',
     'tokens.prefixOnlyHelp': 'Historical tokens do not store plaintext. The prefix is only for identification.',
+    'tokens.docsTitle': 'Sending API guide',
+    'tokens.endpoint': 'API Endpoint',
+    'tokens.authHeader': 'Authentication',
+    'tokens.authHeaderValue': 'Authorization: Bearer <USER_API_TOKEN>',
+    'tokens.contentType': 'Content-Type',
+    'tokens.requestFields': 'Request fields',
+    'tokens.fieldFrom': 'from: sender address. The domain must be added and verified.',
+    'tokens.fieldTo': 'to: recipient address. Single email or an array of emails.',
+    'tokens.fieldSubject': 'subject: email subject.',
+    'tokens.fieldText': 'text: plain text body.',
+    'tokens.fieldHtml': 'html: optional HTML body.',
+    'tokens.curlExample': 'curl example',
+    'tokens.nodeExample': 'Node / Fetch example',
+    'tokens.requestExample': 'Request body example',
+    'tokens.responseExample': 'Success response example',
+    'tokens.securityTips': 'Security tips',
+    'tokens.securityTipStore': 'Store tokens only on the server side. Do not put them in browser code or public repositories.',
+    '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.',
     'testMail.title': 'Send test email',
     'testMail.fromRequired': 'Enter the sender',
     'testMail.toRequired': 'Enter the recipient',

+ 16 - 0
src/frontend/styles.css

@@ -111,6 +111,22 @@ body {
   margin-bottom: 16px;
 }
 
+.api-doc-list {
+  color: #475569;
+  margin: 0;
+  padding-left: 18px;
+}
+
+.api-doc-list li + li {
+  margin-top: 8px;
+}
+
+.inline-code-value {
+  max-width: min(620px, 64vw);
+  overflow-wrap: anywhere;
+  white-space: normal;
+}
+
 .trend-bars {
   align-items: end;
   display: grid;

+ 69 - 4
src/pages/ApiTokens.tsx

@@ -1,24 +1,37 @@
 import { CopyOutlined, DeleteOutlined, KeyOutlined } from '@ant-design/icons';
-import { Alert, Button, Card, Form, Input, Modal, Popconfirm, Space, Table, Tag, Tooltip, Typography } from 'antd';
+import { Alert, Button, Card, Collapse, Descriptions, Form, Input, Modal, Popconfirm, Space, Table, Tag, Tooltip, Typography } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
 import { useState } from 'react';
 
-import { canCopyFullApiToken, formatApiTokenPrefix, getCreatedApiTokenSecret } from '../frontend/api-token-model.js';
+import {
+  buildApiUsageExamples,
+  canCopyFullApiToken,
+  formatApiTokenPrefix,
+  getCreatedApiTokenSecret
+} from '../frontend/api-token-model.js';
 import { useI18n } from '../frontend/i18n/react';
-import type { ApiToken } from '../frontend/types';
+import type { ApiToken, RuntimeConfig } from '../frontend/types';
 
 interface ApiTokensProps {
   tokens: ApiToken[];
+  config: RuntimeConfig | null;
   loading?: boolean;
   onCreate: (name: string) => Promise<ApiToken | null | void>;
   onDelete: (token: ApiToken) => void;
   onCopy: (value: string) => void;
 }
 
-export default function ApiTokens({ tokens, loading, onCreate, onDelete, onCopy }: ApiTokensProps) {
+export default function ApiTokens({ tokens, config, loading, onCreate, onDelete, onCopy }: ApiTokensProps) {
   const { t } = useI18n();
   const [form] = Form.useForm<{ name: string }>();
   const [createdToken, setCreatedToken] = useState<ApiToken | null>(null);
+  const endpoint = `${config?.appBaseUrl || window.location.origin}/api/send`;
+  const examples = buildApiUsageExamples({
+    endpoint,
+    token: '<USER_API_TOKEN>',
+    from: 'noreply@example.com',
+    to: 'user@example.com'
+  });
 
   const columns: ColumnsType<ApiToken> = [
     { title: t('tokens.name'), dataIndex: 'name' },
@@ -70,6 +83,41 @@ export default function ApiTokens({ tokens, loading, onCreate, onDelete, onCopy
         <Alert type="info" showIcon message={t('tokens.prefixOnlyHelp')} className="token-list-alert" />
         <Table rowKey="id" columns={columns} dataSource={tokens} scroll={{ x: 900 }} />
       </Card>
+      <Card title={t('tokens.docsTitle')}>
+        <Space direction="vertical" size={16} className="full-width">
+          <Alert type="info" showIcon message={t('tokens.noTokenHint')} />
+          <Descriptions column={1} bordered size="small">
+            <Descriptions.Item label={t('tokens.endpoint')}>{copyable(endpoint, onCopy)}</Descriptions.Item>
+            <Descriptions.Item label={t('tokens.authHeader')}>{copyable(t('tokens.authHeaderValue'), onCopy)}</Descriptions.Item>
+            <Descriptions.Item label={t('tokens.contentType')}><Typography.Text code>application/json</Typography.Text></Descriptions.Item>
+          </Descriptions>
+          <Card size="small" title={t('tokens.requestFields')}>
+            <ul className="api-doc-list">
+              <li>{t('tokens.fieldFrom')}</li>
+              <li>{t('tokens.fieldTo')}</li>
+              <li>{t('tokens.fieldSubject')}</li>
+              <li>{t('tokens.fieldText')}</li>
+              <li>{t('tokens.fieldHtml')}</li>
+            </ul>
+          </Card>
+          <Collapse
+            defaultActiveKey={['curl']}
+            items={[
+              { key: 'curl', label: t('tokens.curlExample'), children: <CodeSample value={examples.curl} /> },
+              { key: 'node', label: t('tokens.nodeExample'), children: <CodeSample value={examples.nodeFetch} /> },
+              { key: 'body', label: t('tokens.requestExample'), children: <CodeSample value={examples.requestBody} /> },
+              { key: 'response', label: t('tokens.responseExample'), children: <CodeSample value={examples.successResponse} /> }
+            ]}
+          />
+          <Card size="small" title={t('tokens.securityTips')}>
+            <ul className="api-doc-list">
+              <li>{t('tokens.securityTipStore')}</li>
+              <li>{t('tokens.securityTipRotate')}</li>
+              <li>{t('tokens.securityTipDomain')}</li>
+            </ul>
+          </Card>
+        </Space>
+      </Card>
     </Space>
       <Modal
         title={t('tokens.createdTitle')}
@@ -102,3 +150,20 @@ export default function ApiTokens({ tokens, loading, onCreate, onDelete, onCopy
     </>
   );
 }
+
+function copyable(value: string, onCopy: (value: string) => void) {
+  return (
+    <Space>
+      <Typography.Text code className="inline-code-value">{value}</Typography.Text>
+      <Button size="small" icon={<CopyOutlined />} onClick={() => onCopy(value)} />
+    </Space>
+  );
+}
+
+function CodeSample({ value }: { value: string }) {
+  return (
+    <Typography.Paragraph code copyable className="code-sample">
+      {value}
+    </Typography.Paragraph>
+  );
+}

+ 17 - 0
test/frontend-api-token-model.test.js

@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
 import { test } from 'node:test';
 
 import {
+  buildApiUsageExamples,
   canCopyFullApiToken,
   formatApiTokenPrefix,
   getCopyableApiToken,
@@ -25,3 +26,19 @@ test('formats token prefixes without pretending the full secret is available', (
   assert.equal(formatApiTokenPrefix({ tokenPrefix: 'mh_abcdef1234' }), 'mh_abcdef1234...');
   assert.equal(formatApiTokenPrefix({}), '-');
 });
+
+test('builds API usage examples with endpoint, bearer token, and message body', () => {
+  const examples = buildApiUsageExamples({
+    endpoint: 'https://mail-send.ss5.xyz/api/send',
+    token: 'mh_example_token',
+    from: 'noreply@example.com',
+    to: 'user@example.com'
+  });
+
+  assert.match(examples.curl, /Authorization: Bearer mh_example_token/);
+  assert.match(examples.curl, /https:\/\/mail-send\.ss5\.xyz\/api\/send/);
+  assert.match(examples.nodeFetch, /fetch\('https:\/\/mail-send\.ss5\.xyz\/api\/send'/);
+  assert.match(examples.nodeFetch, /from: 'noreply@example.com'/);
+  assert.match(examples.requestBody, /"to": "user@example.com"/);
+  assert.match(examples.successResponse, /"queued": true/);
+});

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

@@ -27,3 +27,16 @@ test('translates known UI keys and falls back safely', () => {
   assert.equal(en('auth.loginTitle'), 'Sign in to console');
   assert.equal(en('missing.translation.key'), 'missing.translation.key');
 });
+
+test('uses localized Chinese labels for the main navigation', () => {
+  const zh = createTranslator('zh-CN');
+
+  assert.equal(zh('nav.dashboard'), '仪表盘');
+  assert.equal(zh('nav.domains'), '发信域名');
+  assert.equal(zh('nav.dnsApi'), 'DNS API');
+  assert.equal(zh('nav.smtp'), 'SMTP 凭据');
+  assert.equal(zh('nav.tokens'), 'API Token');
+  assert.equal(zh('nav.logs'), '发送记录');
+  assert.equal(zh('nav.webhooks'), 'Webhooks');
+  assert.equal(zh('nav.settings'), '系统设置');
+});

Some files were not shown because too many files changed in this diff