Kaynağa Gözat

fix: move PTR checks to system settings

AI-Co-Authored-By: Codex
chendeben 1 ay önce
ebeveyn
işleme
9c58b5bc65

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 1
public/assets/index-BEmjLh1y.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
public/assets/login-C-Jd8_Pp.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
public/assets/styles-BdD3aAK4.js


+ 2 - 2
public/index.html

@@ -4,8 +4,8 @@
     <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-rR5xt_XM.js"></script>
-    <link rel="modulepreload" crossorigin href="/assets/styles-BBgHuOwj.js">
+    <script type="module" crossorigin src="/assets/index-BEmjLh1y.js"></script>
+    <link rel="modulepreload" crossorigin href="/assets/styles-BdD3aAK4.js">
     <link rel="stylesheet" crossorigin href="/assets/styles-CWLk_28-.css">
     <link rel="stylesheet" crossorigin href="/assets/index-Tu04tXLf.css">
   </head>

+ 2 - 2
public/login.html

@@ -4,8 +4,8 @@
     <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-BngjuMPw.js"></script>
-    <link rel="modulepreload" crossorigin href="/assets/styles-BBgHuOwj.js">
+    <script type="module" crossorigin src="/assets/login-C-Jd8_Pp.js"></script>
+    <link rel="modulepreload" crossorigin href="/assets/styles-BdD3aAK4.js">
     <link rel="stylesheet" crossorigin href="/assets/styles-CWLk_28-.css">
   </head>
   <body>

+ 31 - 18
src/dns-guide.js

@@ -67,18 +67,6 @@ export async function buildDnsGuide(domain) {
       warnings: live.senderA.includes(domain.sendingIp)
         ? []
         : [`${domain.senderHost} 当前未解析到 ${domain.sendingIp},这是平台发信主机,请联系管理员检查。`]
-    },
-    {
-      key: 'ptr',
-      label: 'PTR 反向解析',
-      host: domain.sendingIp,
-      type: 'PTR',
-      value: domain.senderHost,
-      status: live.ptr.includes(domain.senderHost) ? 'ok' : 'warn',
-      current: live.ptr.join(', '),
-      warnings: live.ptr.includes(domain.senderHost)
-        ? []
-        : ['PTR 需要在云服务器或 IP 服务商控制台设置,普通 DNS 控制台通常不能修改。']
     }
   ];
 
@@ -102,23 +90,43 @@ async function readLiveDns(domain) {
     verificationTxt,
     dkimTxt,
     dmarcTxt,
-    senderA,
-    ptr
+    senderA
   ] = await Promise.all([
     resolveTxt(domain.domain),
     resolveTxt(`_mailhub.${domain.domain}`),
     resolveTxt(`${domain.selector}._domainkey.${domain.domain}`),
     resolveTxt(`_dmarc.${domain.domain}`),
-    resolve4(domain.senderHost),
-    resolvePtr(domain.sendingIp)
+    resolve4(domain.senderHost)
   ]);
   return {
     rootTxt,
     verificationTxt,
     dkimTxt,
     dmarcTxt,
-    senderA,
-    ptr
+    senderA
+  };
+}
+
+export async function buildSystemDnsChecks(settings = {}) {
+  const mailHostname = String(settings.mailHostname || '').trim();
+  const sendingIp = String(settings.sendingIp || '').trim();
+  const ptr = sendingIp ? await resolvePtr(sendingIp) : [];
+  const ok = Boolean(mailHostname && sendingIp && ptr.some((value) => hostEquals(value, mailHostname)));
+
+  return {
+    checkedAt: new Date().toISOString(),
+    ptr: {
+      key: 'ptr',
+      label: '发信 IP PTR',
+      host: sendingIp,
+      type: 'PTR',
+      value: mailHostname,
+      status: ok ? 'ok' : (sendingIp ? 'warn' : 'missing'),
+      current: ptr,
+      warnings: ok
+        ? []
+        : ['PTR 需要在云服务器或 IP 服务商控制台设置,普通 DNS 控制台通常不能修改。']
+    }
   };
 }
 
@@ -156,6 +164,11 @@ function normalizeTxt(value) {
   return String(value).replace(/\s+/g, ' ').trim();
 }
 
+function hostEquals(left, right) {
+  return String(left || '').trim().replace(/\.$/, '').toLowerCase()
+    === String(right || '').trim().replace(/\.$/, '').toLowerCase();
+}
+
 function isSpfRecord(value) {
   return /^v=spf1(?:\s|$)/i.test(value.trim());
 }

+ 8 - 1
src/frontend/domain-model.js

@@ -1,4 +1,4 @@
-const REQUIRED_DNS_KEYS = ['verification', 'dkim', 'spf', 'dmarc', 'sender-a', 'ptr'];
+const REQUIRED_DNS_KEYS = ['verification', 'dkim', 'spf', 'dmarc', 'sender-a'];
 
 const STATUS_META = {
   ok: { key: 'success', label: '已通过', color: 'success' },
@@ -28,6 +28,13 @@ export function getRequiredDnsRecords(domain = {}) {
   return REQUIRED_DNS_KEYS.map((key) => byKey.get(key)).filter(Boolean);
 }
 
+export function getVisibleDnsRecords(records = []) {
+  const rank = new Map(REQUIRED_DNS_KEYS.map((key, index) => [key, index]));
+  return [...records]
+    .filter((record) => rank.has(record.key))
+    .sort((a, b) => rank.get(a.key) - rank.get(b.key));
+}
+
 export function buildDomainHealth(domain = {}) {
   const records = getRequiredDnsRecords(domain);
   const total = REQUIRED_DNS_KEYS.length;

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

@@ -240,6 +240,8 @@ const messages = {
     'dnsApi.save': '保存修改',
     'dnsApi.create': '新增凭据',
     'dnsApi.secretHint': '密钥只在服务端加密保存,不会在列表中回显。',
+    'settings.checkItem': '检查项',
+    'settings.deliveryChecks': '发信环境检查',
     'settings.noPermission': '当前账号没有系统设置权限。',
     'settings.save': '保存设置',
     'admin.title': '管理员面板',
@@ -568,6 +570,8 @@ const messages = {
     'dnsApi.save': 'Save changes',
     'dnsApi.create': 'Create credential',
     'dnsApi.secretHint': 'Secrets are encrypted server-side and never shown in the list.',
+    'settings.checkItem': 'Check',
+    'settings.deliveryChecks': 'Sending Environment Checks',
     'settings.noPermission': 'This account cannot access system settings.',
     'settings.save': 'Save settings',
     'admin.title': 'Admin Panel',

+ 6 - 0
src/frontend/types.ts

@@ -44,6 +44,7 @@ export interface RuntimeConfig {
   dmarcPolicy: string;
   dmarcRua: string;
   sendRequiresVerified: boolean;
+  systemChecks?: SystemChecks;
   submission?: {
     enabled: boolean;
     host: string;
@@ -57,6 +58,11 @@ export interface RuntimeConfig {
   usingDefaultAdminPassword?: boolean;
 }
 
+export interface SystemChecks {
+  checkedAt: string;
+  ptr: DnsRecord;
+}
+
 export interface DnsRecord {
   key: string;
   label: string;

+ 2 - 3
src/pages/Domains/DomainDetail.tsx

@@ -23,7 +23,7 @@ import { useMemo, useState } from 'react';
 
 import { DomainHealthCard } from '../../components/domain/DomainHealthCard';
 import { DnsRecordCard } from '../../components/domain/DnsRecordCard';
-import { getDnsRecordOrder } from '../../frontend/domain-model.js';
+import { getVisibleDnsRecords } from '../../frontend/domain-model.js';
 import { useI18n } from '../../frontend/i18n/react';
 import type {
   ApiToken,
@@ -416,8 +416,7 @@ function Placeholder({ title }: { title: string }) {
 }
 
 function orderedRecords(records: DnsRecord[]) {
-  const order = getDnsRecordOrder();
-  return [...records].sort((a, b) => order.indexOf(a.key) - order.indexOf(b.key));
+  return getVisibleDnsRecords(records);
 }
 
 function sendStatusLabel(status: string, t: (key: string) => string) {

+ 0 - 1
src/pages/Domains/index.tsx

@@ -70,7 +70,6 @@ export default function DomainsPage({
     recordColumn('DKIM', 'dkim'),
     recordColumn('SPF', 'spf'),
     recordColumn('DMARC', 'dmarc'),
-    recordColumn('PTR', 'ptr'),
     {
       title: t('domains.smtp'),
       width: 110,

+ 40 - 1
src/pages/Settings.tsx

@@ -1,8 +1,10 @@
 import { Button, Card, Form, Input, Select, Space, Switch, Table, Tag, Typography } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
 
+import { StatusTag } from '../components/common/StatusTag';
+import { getDnsCurrentValues } from '../frontend/domain-model.js';
 import { useI18n } from '../frontend/i18n/react';
-import type { RuntimeConfig, User } from '../frontend/types';
+import type { DnsRecord, RuntimeConfig, User } from '../frontend/types';
 
 interface SettingsProps {
   me: User | null;
@@ -28,6 +30,27 @@ export default function Settings({ me, settings, users, loading, onSave }: Setti
     { title: 'Role', dataIndex: 'role', render: (value) => <Tag>{value}</Tag> },
     { title: 'Status', dataIndex: 'status', render: (value) => <Tag color={value === 'active' ? 'success' : 'default'}>{value}</Tag> }
   ];
+  const checkColumns: ColumnsType<DnsRecord> = [
+    { title: t('settings.checkItem'), dataIndex: 'label', width: 160 },
+    { title: t('dnsRecord.hostname'), dataIndex: 'host', width: 180, render: (value) => <Typography.Text code>{value || '-'}</Typography.Text> },
+    { title: t('dnsRecord.targetValue'), dataIndex: 'value', width: 220, render: (value) => <Typography.Text code>{value || '-'}</Typography.Text> },
+    {
+      title: t('dnsRecord.currentValue'),
+      width: 260,
+      render: (_, record) => {
+        const values: string[] = getDnsCurrentValues(record);
+        return values.length
+          ? (
+              <Space direction="vertical" size={4}>
+                {values.map((value) => <Typography.Text key={value} code>{value}</Typography.Text>)}
+              </Space>
+            )
+          : <Typography.Text type="secondary">{t('dnsRecord.emptyCurrent')}</Typography.Text>;
+      }
+    },
+    { title: t('common.status'), width: 120, render: (_, record) => <StatusTag record={record} /> }
+  ];
+  const checkData = settings?.systemChecks?.ptr ? [settings.systemChecks.ptr] : [];
 
   return (
     <Space direction="vertical" size={16} className="full-width">
@@ -66,6 +89,22 @@ export default function Settings({ me, settings, users, loading, onSave }: Setti
           </Button>
         </Form>
       </Card>
+      <Card
+        title={t('settings.deliveryChecks')}
+        extra={
+          settings?.systemChecks?.checkedAt
+            ? <Typography.Text type="secondary">{new Date(settings.systemChecks.checkedAt).toLocaleString()}</Typography.Text>
+            : null
+        }
+      >
+        <Table
+          rowKey="key"
+          columns={checkColumns}
+          dataSource={checkData}
+          pagination={false}
+          scroll={{ x: 940 }}
+        />
+      </Card>
       <Card title="Users">
         <Table rowKey="id" columns={columns} dataSource={users} />
       </Card>

+ 11 - 3
src/server.js

@@ -66,7 +66,7 @@ import {
 import { applyDnsSetup, testDnsCredential } from './dns-providers.js';
 import { startDnsAutoChecker } from './dns-auto-checker.js';
 import { startPostfixDeliveryTracker } from './delivery-tracker.js';
-import { buildDnsGuide } from './dns-guide.js';
+import { buildDnsGuide, buildSystemDnsChecks } from './dns-guide.js';
 import { createDkimKeyPair } from './dkim.js';
 import {
   buildMessage,
@@ -505,7 +505,7 @@ async function handleAdminApi(req, res, url, user) {
   if (!pathname.startsWith('/api/admin/')) return null;
   if (user.role !== 'admin') return sendJson(res, 403, { error: '需要管理员权限。' });
   if (method === 'GET' && pathname === '/api/admin/settings') {
-    return sendJson(res, 200, { settings: runtimeSettings() });
+    return sendJson(res, 200, { settings: await adminRuntimeSettings() });
   }
   if (method === 'GET' && pathname === '/api/admin/system-email') {
     return sendJson(res, 200, { settings: getSystemEmailSettings() });
@@ -642,7 +642,7 @@ async function handleAdminApi(req, res, url, user) {
       dmarcRua: body.dmarcRua,
       sendRequiresVerified: boolString(body.sendRequiresVerified)
     });
-    return sendJson(res, 200, { settings: runtimeSettings() });
+    return sendJson(res, 200, { settings: await adminRuntimeSettings() });
   }
   if (method === 'GET' && pathname === '/api/admin/users') {
     return sendJson(res, 200, { users: listUsersWithResourceCounts() });
@@ -1224,6 +1224,14 @@ function runtimeSettings() {
   };
 }
 
+async function adminRuntimeSettings() {
+  const settings = runtimeSettings();
+  return {
+    ...settings,
+    systemChecks: await buildSystemDnsChecks(settings)
+  };
+}
+
 async function serveStatic(req, res, url) {
   const publicDir = path.join(__dirname, '..', 'public');
   const pathname = decodeURIComponent(resolveStaticPathname(url.pathname));

+ 28 - 0
test/dns-guide.test.js

@@ -50,9 +50,37 @@ test('includes live current values for verification and dkim records', async ()
     const verification = guide.records.find((record) => record.key === 'verification');
     const dkim = guide.records.find((record) => record.key === 'dkim');
 
+    assert.equal(guide.records.some((record) => record.key === 'ptr'), false);
     assert.deepEqual(verification.current, ['mailhub-verification=verify-token']);
     assert.deepEqual(dkim.current, ['v=DKIM1; k=rsa; p=dkim-public']);
   } finally {
     dns.promises.Resolver = originalResolver;
   }
 });
+
+test('builds PTR as a system delivery check', async () => {
+  const originalResolver = dns.promises.Resolver;
+
+  dns.promises.Resolver = class FakeResolver {
+    setServers() {}
+    async reverse(ip) {
+      return ip === '192.0.2.10' ? ['mail.example.com'] : [];
+    }
+  };
+
+  try {
+    const { buildSystemDnsChecks } = await import(`../src/dns-guide.js?test=${Date.now()}-system`);
+    const checks = await buildSystemDnsChecks({
+      mailHostname: 'mail.example.com',
+      sendingIp: '192.0.2.10'
+    });
+
+    assert.equal(checks.ptr.key, 'ptr');
+    assert.equal(checks.ptr.host, '192.0.2.10');
+    assert.equal(checks.ptr.value, 'mail.example.com');
+    assert.equal(checks.ptr.status, 'ok');
+    assert.deepEqual(checks.ptr.current, ['mail.example.com']);
+  } finally {
+    dns.promises.Resolver = originalResolver;
+  }
+});

+ 2 - 3
test/frontend-analytics-model.test.js

@@ -28,8 +28,7 @@ test('builds dashboard summary from analytics, DNS health, and SMTP state', () =
         record('dkim', 'ok'),
         record('spf', 'ok'),
         record('dmarc', 'ok'),
-        record('sender-a', 'ok'),
-        record('ptr', 'ok')
+        record('sender-a', 'ok')
       ]),
       domain(false, [
         record('verification', 'ok'),
@@ -47,7 +46,7 @@ test('builds dashboard summary from analytics, DNS health, and SMTP state', () =
   assert.equal(summary.successRate, 90);
   assert.equal(summary.bounceRate, 10);
   assert.equal(summary.complaintRate, 0);
-  assert.equal(summary.dnsIssues, 5);
+  assert.equal(summary.dnsIssues, 4);
   assert.equal(summary.smtpReady, true);
   assert.equal(summary.lastSentAt, '2026-07-08T12:30:00.000Z');
 });

+ 16 - 7
test/frontend-domain-model.test.js

@@ -5,7 +5,8 @@ import {
   buildDomainHealth,
   getDnsCurrentValues,
   getRecordStatusMeta,
-  getRequiredDnsRecords
+  getRequiredDnsRecords,
+  getVisibleDnsRecords
 } from '../src/frontend/domain-model.js';
 
 test('maps DNS record states to stable UI status metadata', () => {
@@ -44,7 +45,7 @@ test('builds domain health from required DNS records only', () => {
         record('spf', 'pending'),
         record('dmarc', 'warn'),
         record('sender-a', 'missing'),
-        record('ptr', 'ok'),
+        record('ptr', 'missing'),
         record('optional-mta-sts', 'ok')
       ]
     }
@@ -55,16 +56,15 @@ test('builds domain health from required DNS records only', () => {
     'dkim',
     'spf',
     'dmarc',
-    'sender-a',
-    'ptr'
+    'sender-a'
   ]);
 
   assert.deepEqual(buildDomainHealth(domain), {
     status: 'error',
     label: '需要处理',
-    passed: 3,
-    total: 6,
-    percent: 50,
+    passed: 2,
+    total: 5,
+    percent: 40,
     dnsIssues: 2,
     checkedAt: '2026-07-08T10:30:00.000Z'
   });
@@ -101,6 +101,15 @@ test('normalizes DNS current values with legacy successful record fallback only'
   assert.deepEqual(getDnsCurrentValues({ value: 'target', status: 'missing' }), []);
 });
 
+test('hides legacy PTR records from per-domain DNS record lists', () => {
+  assert.deepEqual(getVisibleDnsRecords([
+    record('ptr', 'ok'),
+    record('spf', 'ok'),
+    record('verification', 'ok'),
+    record('unknown', 'ok')
+  ]).map((item) => item.key), ['verification', 'spf']);
+});
+
 function record(key, status) {
   return {
     key,

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

@@ -52,7 +52,9 @@ test('translates admin panel labels', () => {
   assert.equal(zh('admin.users'), '用户');
   assert.equal(zh('admin.resources'), '资源');
   assert.equal(zh('admin.auditLogs'), '审计日志');
+  assert.equal(zh('settings.deliveryChecks'), '发信环境检查');
   assert.equal(en('admin.title'), 'Admin Panel');
+  assert.equal(en('settings.deliveryChecks'), 'Sending Environment Checks');
 });
 
 test('translates smtp login credential labels separately from outbound relays', () => {

+ 3 - 1
test/server-admin-api.test.js

@@ -37,7 +37,9 @@ test('admin API routes respond once and keep the server alive', async () => {
       headers: { Cookie: cookie }
     });
     assert.equal(settings.status, 200);
-    assert.equal((await settings.json()).settings.mailHostname, 'mailhub.local');
+    const settingsPayload = await settings.json();
+    assert.equal(settingsPayload.settings.mailHostname, 'mailhub.local');
+    assert.equal(settingsPayload.settings.systemChecks.ptr.key, 'ptr');
 
     const exited = await waitForExit(child, 300);
     assert.equal(exited, false);

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor