Переглянути джерело

feat: add delivery logs and cloudflare zone matching

AI-Co-Authored-By: Codex
chendeben 1 місяць тому
батько
коміт
35f085f3f3

Різницю між файлами не показано, бо вона завелика
+ 1 - 0
public/assets/index-CDcbky2i.js


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
public/assets/login-CxIl1qG4.js


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
public/assets/styles-B6t-ADxX.css


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
public/assets/styles-C5VOfA7x.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-CLNbnZjS.js"></script>
-    <link rel="modulepreload" crossorigin href="/assets/styles-CLwaXWnz.js">
-    <link rel="stylesheet" crossorigin href="/assets/styles-BqV0Lkls.css">
+    <script type="module" crossorigin src="/assets/index-CDcbky2i.js"></script>
+    <link rel="modulepreload" crossorigin href="/assets/styles-C5VOfA7x.js">
+    <link rel="stylesheet" crossorigin href="/assets/styles-B6t-ADxX.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-3I-QNHaj.js"></script>
-    <link rel="modulepreload" crossorigin href="/assets/styles-CLwaXWnz.js">
-    <link rel="stylesheet" crossorigin href="/assets/styles-BqV0Lkls.css">
+    <script type="module" crossorigin src="/assets/login-CxIl1qG4.js"></script>
+    <link rel="modulepreload" crossorigin href="/assets/styles-C5VOfA7x.js">
+    <link rel="stylesheet" crossorigin href="/assets/styles-B6t-ADxX.css">
   </head>
   <body>
     <div id="auth-root"></div>

+ 6 - 2
src/db.js

@@ -53,6 +53,7 @@ export function initDatabase(dataDir, secret = '') {
       subject TEXT NOT NULL,
       status TEXT NOT NULL,
       detail TEXT NOT NULL DEFAULT '',
+      delivery_log_json TEXT NOT NULL DEFAULT '[]',
       created_at TEXT NOT NULL,
       FOREIGN KEY(domain_id) REFERENCES domains(id) ON DELETE SET NULL
     );
@@ -104,6 +105,7 @@ export function initDatabase(dataDir, secret = '') {
   ensureColumn('domains', 'user_id', 'INTEGER');
   ensureColumn('domains', 'dns_credential_id', 'INTEGER');
   ensureColumn('send_events', 'user_id', 'INTEGER');
+  ensureColumn('send_events', 'delivery_log_json', "TEXT NOT NULL DEFAULT '[]'");
   ensureColumn('smtp_credentials', 'password_secret', "TEXT NOT NULL DEFAULT ''");
   db.exec(`
     CREATE INDEX IF NOT EXISTS idx_domains_user_id ON domains(user_id);
@@ -326,8 +328,8 @@ export function deleteDomain(id, userId) {
 export function logSendEvent(event) {
   const result = requireDb()
     .prepare(`
-      INSERT INTO send_events (user_id, domain_id, sender, recipients, subject, status, detail, created_at)
-      VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+      INSERT INTO send_events (user_id, domain_id, sender, recipients, subject, status, detail, delivery_log_json, created_at)
+      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
     `)
     .run(
       event.userId ?? null,
@@ -337,6 +339,7 @@ export function logSendEvent(event) {
       event.subject,
       event.status,
       event.detail ?? '',
+      JSON.stringify(Array.isArray(event.deliveryLog) ? event.deliveryLog : []),
       now()
     );
   return result.lastInsertRowid;
@@ -363,6 +366,7 @@ export function listSendEvents(userId, limit = 30) {
       subject: row.subject,
       status: row.status,
       detail: row.detail,
+      deliveryLog: safeJson(row.delivery_log_json, []),
       createdAt: row.created_at
     }));
 }

+ 30 - 8
src/dns-providers.js

@@ -19,7 +19,7 @@ export async function applyDnsSetup(domain, credential, guide) {
   const records = (guide.records || []).filter((record) => ['verification', 'dkim', 'spf', 'dmarc'].includes(record.key));
   const results = [];
   for (const record of records) {
-    const zoneName = credential.zoneName || '';
+    const zoneName = effectiveZoneName(credential, domain, record);
     if (zoneName && !isHostInZone(record.host, zoneName)) {
       results.push(outOfZoneResult(record, zoneName));
       continue;
@@ -60,8 +60,8 @@ class CloudflareProvider {
     return zone.result?.name || this.zoneName || zoneId;
   }
 
-  async upsert(record) {
-    const zoneId = await this.zoneId();
+  async upsert(record, domain) {
+    const zoneId = await this.zoneId(record, domain);
     const existing = await this.listRecords(zoneId, record);
     const match = pickExisting(record, existing);
     const payload = {
@@ -101,15 +101,26 @@ class CloudflareProvider {
     return response.result || [];
   }
 
-  async zoneId() {
-    if (this.credentials.zoneId) return this.credentials.zoneId;
-    if (!this.zoneName) throw new Error('Cloudflare 需要 zoneName 或 zoneId。');
-    const response = await this.request(`/zones?name=${encodeURIComponent(this.zoneName)}`);
+  async zoneId(record, domain) {
+    const targetZoneName = this.zoneNameFor(record, domain);
+    if (this.credentials.zoneId && (!targetZoneName || sameZone(targetZoneName, this.zoneName))) {
+      return this.credentials.zoneId;
+    }
+    if (!targetZoneName) throw new Error('Cloudflare 需要 zoneName、zoneId 或发信域名。');
+    const response = await this.request(`/zones?name=${encodeURIComponent(targetZoneName)}`);
     const zone = response.result?.[0];
-    if (!zone?.id) throw new Error(`Cloudflare 未找到 Zone ${this.zoneName}。`);
+    if (!zone?.id) throw new Error(`Cloudflare 未找到 Zone ${targetZoneName}。`);
     return zone.id;
   }
 
+  zoneNameFor(record, domain) {
+    const host = record?.host || '';
+    const domainName = domain?.domain || '';
+    if (this.zoneName && (!host || isHostInZone(host, this.zoneName))) return this.zoneName;
+    if (domainName && (!host || isHostInZone(host, domainName))) return domainName;
+    return this.zoneName || domainName;
+  }
+
   async request(path, options = {}) {
     if (!this.credentials.apiToken) throw new Error('Cloudflare API Token 不能为空。');
     const response = await fetch(`${CLOUDFLARE_API}${path}`, {
@@ -325,6 +336,17 @@ function isHostInZone(host, zoneName) {
   return Boolean(cleanHost && cleanZone && (cleanHost === cleanZone || cleanHost.endsWith(`.${cleanZone}`)));
 }
 
+function effectiveZoneName(credential, domain, record) {
+  if (credential.provider !== 'cloudflare') return credential.zoneName || '';
+  const configuredZone = credential.zoneName || '';
+  if (configuredZone && isHostInZone(record.host, configuredZone)) return configuredZone;
+  return domain?.domain || configuredZone;
+}
+
+function sameZone(left, right) {
+  return String(left || '').replace(/\.$/, '').toLowerCase() === String(right || '').replace(/\.$/, '').toLowerCase();
+}
+
 function outOfZoneResult(record, zoneName) {
   const base = {
     key: record.key,

+ 1 - 1
src/frontend/App.tsx

@@ -463,7 +463,7 @@ function MailHubConsole() {
       );
     }
     if (activeView === 'logs') {
-      return <SendingLogs events={data.events} domains={data.domains} />;
+      return <SendingLogs events={data.events} domains={data.domains} onCopy={copy} />;
     }
     if (activeView === 'settings') {
       return (

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

@@ -163,6 +163,15 @@ const messages = {
     'logs.domainPlaceholder': '发信域名',
     'logs.statusPlaceholder': '状态',
     'logs.recipientPlaceholder': '搜索收件人',
+    'logs.detailTitle': '发送详情',
+    'logs.deliveryLog': '完整投递日志',
+    'logs.copyDeliveryLog': '复制投递日志',
+    'logs.noDeliveryLog': '暂无结构化投递日志,可能是历史发送记录。',
+    'logs.sender': '发件人',
+    'logs.subject': '主题',
+    'logs.messageId': 'Message ID',
+    'logs.finalResponse': '最终响应',
+    'logs.messageBytes': '邮件字节数',
     'smtp.connectionTitle': 'SMTP 连接信息',
     'smtp.updateTitle': '更新 SMTP 凭据',
     'smtp.usernameRequired': '请输入 SMTP Username',
@@ -175,6 +184,7 @@ const messages = {
     'dnsApi.editTitle': '编辑',
     'dnsApi.zone': 'Zone / 根域名',
     'dnsApi.zoneRequired': '请输入 Zone 或根域名',
+    'dnsApi.cloudflareZoneExtra': 'Cloudflare 可填写任一可访问 Zone 用于测试;一键配置时会按当前发信域名自动匹配 Zone。',
     'dnsApi.tokenExtra': '需要 Zone DNS Edit 权限。',
     'dnsApi.keepSecret': '留空表示保留原密钥。',
     'dnsApi.save': '保存修改',
@@ -418,6 +428,15 @@ const messages = {
     'logs.domainPlaceholder': 'Sending domain',
     'logs.statusPlaceholder': 'Status',
     'logs.recipientPlaceholder': 'Search recipient',
+    'logs.detailTitle': 'Sending detail',
+    'logs.deliveryLog': 'Full delivery log',
+    'logs.copyDeliveryLog': 'Copy delivery log',
+    'logs.noDeliveryLog': 'No structured delivery log. This may be a historical event.',
+    'logs.sender': 'Sender',
+    'logs.subject': 'Subject',
+    'logs.messageId': 'Message ID',
+    'logs.finalResponse': 'Final response',
+    'logs.messageBytes': 'Message bytes',
     'smtp.connectionTitle': 'SMTP connection',
     'smtp.updateTitle': 'Update SMTP credential',
     'smtp.usernameRequired': 'Enter SMTP username',
@@ -430,6 +449,7 @@ const messages = {
     'dnsApi.editTitle': 'Edit',
     'dnsApi.zone': 'Zone / root domain',
     'dnsApi.zoneRequired': 'Enter the Zone or root domain',
+    'dnsApi.cloudflareZoneExtra': 'For Cloudflare, use any accessible Zone for testing. One-click DNS matches the current sending domain automatically.',
     'dnsApi.tokenExtra': 'Requires Zone DNS Edit permission.',
     'dnsApi.keepSecret': 'Leave empty to keep the existing secret.',
     'dnsApi.save': 'Save changes',

+ 22 - 0
src/frontend/styles.css

@@ -261,6 +261,28 @@ body {
   white-space: pre-wrap;
 }
 
+.delivery-log-card .ant-timeline {
+  margin-top: 6px;
+}
+
+.delivery-log-entry {
+  display: grid;
+  gap: 8px;
+  min-width: 0;
+}
+
+.delivery-log-line {
+  align-items: start;
+  display: grid;
+  gap: 8px;
+  grid-template-columns: 18px minmax(0, 1fr);
+}
+
+.delivery-log-line .ant-typography {
+  overflow-wrap: anywhere;
+  white-space: pre-wrap;
+}
+
 .drawer-footer {
   align-items: center;
   display: flex;

+ 13 - 0
src/frontend/types.ts

@@ -117,6 +117,18 @@ export interface ApiToken {
   createdAt: string;
 }
 
+export interface DeliveryLogEntry {
+  at: string;
+  phase: 'connect' | 'smtp' | 'auth' | 'envelope' | 'data' | 'queue' | 'quit' | 'error' | string;
+  direction?: 'client' | 'server' | 'system' | string;
+  command?: string;
+  response?: string;
+  message?: string;
+  code?: number;
+  ok?: boolean;
+  messageBytes?: number;
+}
+
 export interface SendEvent {
   id: number;
   userId: number;
@@ -127,6 +139,7 @@ export interface SendEvent {
   subject: string;
   status: string;
   detail: string;
+  deliveryLog?: DeliveryLogEntry[];
   createdAt: string;
 }
 

+ 87 - 13
src/mailer.js

@@ -80,32 +80,106 @@ export function signMessageForDomain(rawMessage, domain) {
 }
 
 export async function sendViaSmtp({ host, port, secure, username, password, helo, mailFrom, recipients, rawMessage }) {
-  if (!host) throw new Error('SMTP_HOST is not configured.');
-  const client = await SmtpClient.connect({ host, port, secure });
+  const deliveryLog = [];
+  const addLog = (entry) => deliveryLog.push({ at: new Date().toISOString(), ...entry });
+  if (!host) {
+    const error = new Error('SMTP_HOST is not configured.');
+    error.deliveryLog = deliveryLog;
+    throw error;
+  }
+  let client;
   try {
-    await client.expect([220]);
-    let response = await client.command(`EHLO ${helo || 'mailhub.local'}`, [250, 502, 500]);
+    addLog({
+      phase: 'connect',
+      direction: 'system',
+      message: `Connecting to ${host}:${Number(port || 25)}${secure ? ' with TLS' : ''}`
+    });
+    client = await SmtpClient.connect({ host, port, secure });
+    addLog({
+      phase: 'connect',
+      direction: 'system',
+      message: `Connected to ${host}:${Number(port || 25)}`,
+      ok: true
+    });
+    await expectResponse(client, [220], 'connect', addLog);
+    let response = await runCommand(client, `EHLO ${helo || 'mailhub.local'}`, [250, 502, 500], 'smtp', addLog);
     if (![250].includes(response.code)) {
-      await client.command(`HELO ${helo || 'mailhub.local'}`, [250]);
+      await runCommand(client, `HELO ${helo || 'mailhub.local'}`, [250], 'smtp', addLog);
     }
     if (username || password) {
       const auth = Buffer.from(`\u0000${username || ''}\u0000${password || ''}`).toString('base64');
-      await client.command(`AUTH PLAIN ${auth}`, [235]);
+      await runCommand(client, `AUTH PLAIN ${auth}`, [235], 'auth', addLog);
     }
-    await client.command(`MAIL FROM:<${extractAddress(mailFrom)}>`, [250]);
+    await runCommand(client, `MAIL FROM:<${extractAddress(mailFrom)}>`, [250], 'envelope', addLog);
     for (const recipient of recipients) {
-      await client.command(`RCPT TO:<${recipient}>`, [250, 251]);
+      await runCommand(client, `RCPT TO:<${recipient}>`, [250, 251], 'envelope', addLog);
     }
-    await client.command('DATA', [354]);
+    await runCommand(client, 'DATA', [354], 'data', addLog);
+    addLog({
+      phase: 'data',
+      direction: 'client',
+      message: 'Message content transmitted',
+      messageBytes: Buffer.byteLength(rawMessage || '', 'utf8'),
+      ok: true
+    });
     await client.writeData(dotStuff(rawMessage));
-    const dataResponse = await client.expect([250]);
-    await client.command('QUIT', [221]).catch(() => null);
-    return dataResponse;
+    const dataResponse = await expectResponse(client, [250], 'queue', addLog);
+    await runCommand(client, 'QUIT', [221], 'quit', addLog).catch((error) => {
+      addLog({
+        phase: 'quit',
+        direction: 'system',
+        message: error.message,
+        ok: false
+      });
+    });
+    return { ...dataResponse, deliveryLog };
+  } catch (error) {
+    addLog({
+      phase: 'error',
+      direction: 'system',
+      message: error.message,
+      ok: false
+    });
+    error.deliveryLog = deliveryLog;
+    throw error;
   } finally {
-    client.close();
+    client?.close();
   }
 }
 
+async function runCommand(client, command, expectedCodes, phase, addLog) {
+  addLog({
+    phase,
+    direction: 'client',
+    command: sanitizeSmtpCommand(command)
+  });
+  const response = await client.command(command, expectedCodes);
+  addLog({
+    phase,
+    direction: 'server',
+    code: response.code,
+    response: response.message,
+    ok: true
+  });
+  return response;
+}
+
+async function expectResponse(client, expectedCodes, phase, addLog) {
+  const response = await client.expect(expectedCodes);
+  addLog({
+    phase,
+    direction: 'server',
+    code: response.code,
+    response: response.message,
+    ok: true
+  });
+  return response;
+}
+
+function sanitizeSmtpCommand(command) {
+  return String(command || '').replace(/^AUTH\s+(\S+)(?:\s+.*)?$/i, 'AUTH $1 <redacted>');
+}
+
 function sanitizeHeader(value) {
   return String(value || '').replace(/[\r\n]+/g, ' ').trim();
 }

+ 6 - 1
src/pages/DnsApi.tsx

@@ -85,7 +85,12 @@ export default function DnsApi({ credentials, loading, onSave, onTest, onDelete
                 ]}
               />
             </Form.Item>
-            <Form.Item name="zoneName" label={t('dnsApi.zone')} rules={[{ required: true, message: t('dnsApi.zoneRequired') }]}>
+            <Form.Item
+              name="zoneName"
+              label={t('dnsApi.zone')}
+              rules={[{ required: true, message: t('dnsApi.zoneRequired') }]}
+              extra={provider === 'cloudflare' ? t('dnsApi.cloudflareZoneExtra') : undefined}
+            >
               <Input placeholder="example.com" />
             </Form.Item>
             <Form.Item name="defaultTtl" label="TTL">

+ 179 - 50
src/pages/SendingLogs.tsx

@@ -1,24 +1,26 @@
-import { SearchOutlined } from '@ant-design/icons';
-import { Button, Card, DatePicker, Input, Select, Space, Table, Tag } from 'antd';
+import { CopyOutlined, SearchOutlined } from '@ant-design/icons';
+import { Button, Card, DatePicker, Descriptions, Drawer, Empty, Input, Select, Space, Table, Tag, Timeline, Typography } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
 import { useMemo, useState } from 'react';
 
 import { useI18n } from '../frontend/i18n/react';
-import type { Domain, SendEvent } from '../frontend/types';
+import type { DeliveryLogEntry, Domain, SendEvent } from '../frontend/types';
 
 const { RangePicker } = DatePicker;
 
 interface SendingLogsProps {
   events: SendEvent[];
   domains: Domain[];
+  onCopy: (value: string) => void;
 }
 
-export default function SendingLogs({ events, domains }: SendingLogsProps) {
+export default function SendingLogs({ events, domains, onCopy }: SendingLogsProps) {
   const { t } = useI18n();
   const [domain, setDomain] = useState<string>();
   const [status, setStatus] = useState<string>();
   const [recipient, setRecipient] = useState('');
   const [range, setRange] = useState<[number, number] | null>(null);
+  const [selected, setSelected] = useState<SendEvent | null>(null);
 
   const filtered = useMemo(() => {
     return events.filter((event) => {
@@ -39,54 +41,181 @@ export default function SendingLogs({ events, domains }: SendingLogsProps) {
     { title: t('common.status'), dataIndex: 'status', render: (value) => <Tag color={value === 'queued' ? 'success' : 'error'}>{value}</Tag>, width: 110 },
     { title: 'Message ID', dataIndex: 'id', render: (value) => <span>mh-{value}</span>, width: 140 },
     { title: t('logs.errorReason'), dataIndex: 'detail', ellipsis: true },
-    { title: t('domains.actions'), render: () => <Button>{t('logs.viewDetail')}</Button>, width: 120 }
+    { title: t('domains.actions'), render: (_, event) => <Button onClick={() => setSelected(event)}>{t('logs.viewDetail')}</Button>, width: 120 }
   ];
 
   return (
-    <Space direction="vertical" size={16} className="full-width">
-      <Card>
-        <div className="page-toolbar">
-          <Space wrap>
-            <RangePicker
-              showTime
-              onChange={(value) => {
-                if (!value?.[0] || !value?.[1]) return setRange(null);
-                setRange([value[0].valueOf(), value[1].valueOf()]);
-              }}
-            />
-            <Select
-              allowClear
-              placeholder={t('logs.domainPlaceholder')}
-              value={domain}
-              onChange={setDomain}
-              options={domains.map((item) => ({ value: item.domain, label: item.domain }))}
-              className="toolbar-select"
-            />
-            <Select
-              allowClear
-              placeholder={t('logs.statusPlaceholder')}
-              value={status}
-              onChange={setStatus}
-              options={[
-                { value: 'queued', label: 'queued' },
-                { value: 'failed', label: 'failed' }
-              ]}
-              className="toolbar-select"
-            />
-            <Input
-              allowClear
-              prefix={<SearchOutlined />}
-              placeholder={t('logs.recipientPlaceholder')}
-              value={recipient}
-              onChange={(event) => setRecipient(event.target.value)}
-              className="toolbar-search"
-            />
-          </Space>
-        </div>
-      </Card>
-      <Card title={t('logs.title')}>
-        <Table rowKey="id" columns={columns} dataSource={filtered} scroll={{ x: 1300 }} />
-      </Card>
-    </Space>
+    <>
+      <Space direction="vertical" size={16} className="full-width">
+        <Card>
+          <div className="page-toolbar">
+            <Space wrap>
+              <RangePicker
+                showTime
+                onChange={(value) => {
+                  if (!value?.[0] || !value?.[1]) return setRange(null);
+                  setRange([value[0].valueOf(), value[1].valueOf()]);
+                }}
+              />
+              <Select
+                allowClear
+                placeholder={t('logs.domainPlaceholder')}
+                value={domain}
+                onChange={setDomain}
+                options={domains.map((item) => ({ value: item.domain, label: item.domain }))}
+                className="toolbar-select"
+              />
+              <Select
+                allowClear
+                placeholder={t('logs.statusPlaceholder')}
+                value={status}
+                onChange={setStatus}
+                options={[
+                  { value: 'queued', label: 'queued' },
+                  { value: 'failed', label: 'failed' }
+                ]}
+                className="toolbar-select"
+              />
+              <Input
+                allowClear
+                prefix={<SearchOutlined />}
+                placeholder={t('logs.recipientPlaceholder')}
+                value={recipient}
+                onChange={(event) => setRecipient(event.target.value)}
+                className="toolbar-search"
+              />
+            </Space>
+          </div>
+        </Card>
+        <Card title={t('logs.title')}>
+          <Table rowKey="id" columns={columns} dataSource={filtered} scroll={{ x: 1300 }} />
+        </Card>
+      </Space>
+      <DeliveryLogDrawer
+        event={selected}
+        onClose={() => setSelected(null)}
+        onCopy={onCopy}
+      />
+    </>
   );
+
+  function DeliveryLogDrawer({
+    event,
+    onClose,
+    onCopy
+  }: {
+    event: SendEvent | null;
+    onClose: () => void;
+    onCopy: (value: string) => void;
+  }) {
+    const deliveryLog = event?.deliveryLog || [];
+    return (
+      <Drawer
+        title={event ? `${t('logs.detailTitle')} · mh-${event.id}` : t('logs.detailTitle')}
+        open={Boolean(event)}
+        width={760}
+        onClose={onClose}
+        extra={event ? (
+          <Button icon={<CopyOutlined />} onClick={() => onCopy(formatDeliveryLog(event))}>
+            {t('logs.copyDeliveryLog')}
+          </Button>
+        ) : null}
+      >
+        {event ? (
+          <Space direction="vertical" size={16} className="full-width">
+            <Descriptions bordered size="small" column={1}>
+              <Descriptions.Item label={t('logs.time')}>{new Date(event.createdAt).toLocaleString()}</Descriptions.Item>
+              <Descriptions.Item label={t('logs.sender')}>{event.sender}</Descriptions.Item>
+              <Descriptions.Item label={t('logs.recipient')}>{event.recipients.join(', ')}</Descriptions.Item>
+              <Descriptions.Item label={t('logs.domain')}>{event.domain || '-'}</Descriptions.Item>
+              <Descriptions.Item label={t('logs.subject')}>{event.subject || '-'}</Descriptions.Item>
+              <Descriptions.Item label={t('common.status')}>
+                <Tag color={event.status === 'queued' ? 'success' : 'error'}>{event.status}</Tag>
+              </Descriptions.Item>
+              <Descriptions.Item label={t('logs.messageId')}>mh-{event.id}</Descriptions.Item>
+              <Descriptions.Item label={t('logs.finalResponse')}>
+                <Typography.Text code className="inline-code-value">{event.detail || '-'}</Typography.Text>
+              </Descriptions.Item>
+            </Descriptions>
+            <Card size="small" title={t('logs.deliveryLog')} className="delivery-log-card">
+              {deliveryLog.length ? (
+                <Timeline
+                  items={deliveryLog.map((entry, index) => ({
+                    key: `${entry.at}-${index}`,
+                    color: timelineColor(entry),
+                    children: <DeliveryLogTimelineItem entry={entry} />
+                  }))}
+                />
+              ) : (
+                <Empty description={t('logs.noDeliveryLog')} />
+              )}
+            </Card>
+          </Space>
+        ) : null}
+      </Drawer>
+    );
+  }
+
+  function DeliveryLogTimelineItem({ entry }: { entry: DeliveryLogEntry }) {
+    return (
+      <div className="delivery-log-entry">
+        <Space wrap size={8}>
+          <Typography.Text strong>{entry.phase}</Typography.Text>
+          {entry.direction ? <Tag>{entry.direction}</Tag> : null}
+          {entry.code ? <Tag color={entry.ok === false ? 'red' : 'blue'}>{entry.code}</Tag> : null}
+          <Typography.Text type="secondary">{entry.at ? new Date(entry.at).toLocaleString() : '-'}</Typography.Text>
+        </Space>
+        {entry.command ? <LogLine label="C" value={entry.command} /> : null}
+        {entry.response ? <LogLine label="S" value={entry.response} /> : null}
+        {entry.message ? <LogLine label="-" value={entry.message} /> : null}
+        {entry.messageBytes !== undefined ? (
+          <Typography.Text type="secondary">{t('logs.messageBytes')}: {entry.messageBytes}</Typography.Text>
+        ) : null}
+      </div>
+    );
+  }
+
+  function LogLine({ label, value }: { label: string; value: string }) {
+    return (
+      <div className="delivery-log-line">
+        <Typography.Text type="secondary">{label}</Typography.Text>
+        <Typography.Text code>{value}</Typography.Text>
+      </div>
+    );
+  }
+
+  function formatDeliveryLog(event: SendEvent) {
+    const lines = [
+      `${t('logs.messageId')}: mh-${event.id}`,
+      `${t('logs.time')}: ${new Date(event.createdAt).toLocaleString()}`,
+      `${t('logs.sender')}: ${event.sender}`,
+      `${t('logs.recipient')}: ${event.recipients.join(', ')}`,
+      `${t('logs.subject')}: ${event.subject || '-'}`,
+      `${t('common.status')}: ${event.status}`,
+      `${t('logs.finalResponse')}: ${event.detail || '-'}`,
+      ''
+    ];
+    const entries = event.deliveryLog?.length ? event.deliveryLog : [{
+      at: event.createdAt,
+      phase: 'legacy',
+      direction: 'system',
+      message: event.detail || t('logs.noDeliveryLog')
+    }];
+    for (const entry of entries) {
+      lines.push(`[${entry.at || '-'}] ${entry.phase}${entry.direction ? `/${entry.direction}` : ''}`);
+      if (entry.command) lines.push(`C: ${entry.command}`);
+      if (entry.response) lines.push(`S: ${entry.response}`);
+      if (entry.message) lines.push(`- ${entry.message}`);
+      if (entry.messageBytes !== undefined) lines.push(`${t('logs.messageBytes')}: ${entry.messageBytes}`);
+      lines.push('');
+    }
+    return lines.join('\n').trim();
+  }
+
+  function timelineColor(entry: DeliveryLogEntry) {
+    if (entry.ok === false || entry.phase === 'error') return 'red';
+    if (entry.phase === 'queue') return 'green';
+    if (entry.phase === 'auth') return 'gold';
+    return 'blue';
+  }
 }

+ 15 - 2
src/server.js

@@ -432,7 +432,8 @@ async function sendMailFromBody(body, user) {
       recipients,
       subject: body.subject || '(no subject)',
       status: 'queued',
-      detail: smtpResult.message
+      detail: smtpResult.message,
+      deliveryLog: smtpResult.deliveryLog
     });
     return { queued: true, domain: domain.domain, recipients, smtp: smtpResult.message };
   } catch (error) {
@@ -443,12 +444,24 @@ async function sendMailFromBody(body, user) {
       recipients,
       subject: body.subject || '(no subject)',
       status: 'failed',
-      detail: error.message
+      detail: error.message,
+      deliveryLog: deliveryLogFromError(error)
     });
     throw error;
   }
 }
 
+function deliveryLogFromError(error) {
+  if (Array.isArray(error?.deliveryLog)) return error.deliveryLog;
+  return [{
+    at: new Date().toISOString(),
+    phase: 'error',
+    direction: 'system',
+    message: error?.message || 'Unknown SMTP delivery error',
+    ok: false
+  }];
+}
+
 async function handleRegister(req, res) {
   const body = await readJson(req);
   try {

+ 15 - 2
src/submission.js

@@ -300,7 +300,8 @@ class SubmissionSession {
         recipients: this.recipients,
         subject,
         status: 'queued',
-        detail: `submission ${this.remoteAddress}; ${smtpResult.message}`
+        detail: `submission ${this.remoteAddress}; ${smtpResult.message}`,
+        deliveryLog: smtpResult.deliveryLog
       });
       this.resetEnvelope(false);
       return this.write(250, 'Message queued');
@@ -312,7 +313,8 @@ class SubmissionSession {
         recipients: this.recipients,
         subject,
         status: 'failed',
-        detail: `submission ${this.remoteAddress}; ${error.message}`
+        detail: `submission ${this.remoteAddress}; ${error.message}`,
+        deliveryLog: deliveryLogFromError(error)
       });
       return this.write(451, 'Temporary local delivery error');
     }
@@ -378,3 +380,14 @@ function decodeBase64(value) {
     return '';
   }
 }
+
+function deliveryLogFromError(error) {
+  if (Array.isArray(error?.deliveryLog)) return error.deliveryLog;
+  return [{
+    at: new Date().toISOString(),
+    phase: 'error',
+    direction: 'system',
+    message: error?.message || 'Unknown SMTP delivery error',
+    ok: false
+  }];
+}

+ 37 - 0
test/db.test.js

@@ -172,6 +172,43 @@ test('summarizes send analytics by user', () => {
   assert.equal(analytics.recentFailures[0].detail, 'relay rejected');
 });
 
+test('stores and returns structured delivery logs for send events', () => {
+  initDatabase(tempDataDir(), 'test-secret');
+  const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
+  const domain = createDomain(alice.id, domainFixture('alice.example'));
+  const deliveryLog = [
+    {
+      at: '2026-07-08T00:00:00.000Z',
+      phase: 'connect',
+      direction: 'system',
+      message: 'Connected to relay.test:25',
+      ok: true
+    },
+    {
+      at: '2026-07-08T00:00:01.000Z',
+      phase: 'queue',
+      direction: 'server',
+      code: 250,
+      response: '250 queued as ABC123',
+      ok: true
+    }
+  ];
+
+  logSendEvent({
+    userId: alice.id,
+    domainId: domain.id,
+    sender: 'noreply@alice.example',
+    recipients: ['user@example.com'],
+    subject: 'Delivery log',
+    status: 'queued',
+    detail: '250 queued as ABC123',
+    deliveryLog
+  });
+
+  const [event] = listSendEvents(alice.id);
+  assert.deepEqual(event.deliveryLog, deliveryLog);
+});
+
 function domainFixture(domain) {
   return {
     domain,

+ 33 - 0
test/dns-providers.test.js

@@ -120,6 +120,39 @@ test('one-click dns setup only applies records under the user domain zone', asyn
   assert.equal(calls.filter((call) => call.method === 'POST').length, 1);
 });
 
+test('cloudflare one-click dns can use the current domain zone with a multi-zone token', async () => {
+  const calls = [];
+  globalThis.fetch = async (url, options = {}) => {
+    calls.push({ url: String(url), method: options.method || 'GET' });
+    if (String(url).includes('/zones?name=other.com')) {
+      return json({ success: true, result: [{ id: 'zone-other', name: 'other.com' }] });
+    }
+    if (String(url).includes('/dns_records?')) return json({ success: true, result: [] });
+    return json({ success: true, result: { id: 'ok' } });
+  };
+
+  const result = await applyDnsSetup(
+    { ...domainFixture(), domain: 'other.com', senderHost: 'mail.other.com' },
+    cloudflareCredential(),
+    {
+      records: [
+        {
+          key: 'verification',
+          host: '_mailhub.other.com',
+          type: 'TXT',
+          value: 'mailhub-verification=token',
+          status: 'missing'
+        }
+      ]
+    }
+  );
+
+  assert.equal(result.ok, true);
+  assert.equal(result.results[0].ok, true);
+  assert.ok(calls.some((call) => call.url.includes('/zones?name=other.com')));
+  assert.ok(calls.some((call) => call.method === 'POST' && call.url.includes('/zones/zone-other/dns_records')));
+});
+
 function cloudflareCredential() {
   return {
     provider: 'cloudflare',

+ 98 - 0
test/mailer-delivery-log.test.js

@@ -0,0 +1,98 @@
+import assert from 'node:assert/strict';
+import net from 'node:net';
+import { test } from 'node:test';
+
+import { sendViaSmtp } from '../src/mailer.js';
+
+test('records a sanitized SMTP delivery log without storing credentials or message body', async () => {
+  const capturedCommands = [];
+  const server = net.createServer((socket) => {
+    socket.setEncoding('utf8');
+    socket.write('220 relay.test ESMTP ready\r\n');
+    let buffer = '';
+    let dataMode = false;
+
+    socket.on('data', (chunk) => {
+      buffer += chunk;
+      let index;
+      while ((index = buffer.indexOf('\n')) !== -1) {
+        const line = buffer.slice(0, index).replace(/\r$/, '');
+        buffer = buffer.slice(index + 1);
+
+        if (dataMode) {
+          if (line === '.') {
+            dataMode = false;
+            socket.write('250 2.0.0 queued as ABC123\r\n');
+          }
+          continue;
+        }
+
+        capturedCommands.push(line);
+        if (line.startsWith('EHLO')) {
+          socket.write('250-relay.test\r\n250 AUTH PLAIN\r\n');
+        } else if (line.startsWith('AUTH PLAIN')) {
+          socket.write('235 2.7.0 authentication successful\r\n');
+        } else if (line.startsWith('MAIL FROM')) {
+          socket.write('250 2.1.0 sender ok\r\n');
+        } else if (line.startsWith('RCPT TO')) {
+          socket.write('250 2.1.5 recipient ok\r\n');
+        } else if (line === 'DATA') {
+          dataMode = true;
+          socket.write('354 end with dot\r\n');
+        } else if (line === 'QUIT') {
+          socket.write('221 bye\r\n');
+          socket.end();
+        }
+      }
+    });
+  });
+
+  await listen(server);
+  try {
+    const { port } = server.address();
+    const result = await sendViaSmtp({
+      host: '127.0.0.1',
+      port,
+      secure: false,
+      username: 'smtp-user',
+      password: 'super-secret',
+      helo: 'mail.example.com',
+      mailFrom: 'sender@example.com',
+      recipients: ['rcpt@example.net'],
+      rawMessage: 'Subject: Private\r\n\r\nThis body must not be logged'
+    });
+
+    assert.equal(result.code, 250);
+    assert.ok(Array.isArray(result.deliveryLog));
+    assert.ok(result.deliveryLog.length >= 10);
+
+    const serialized = JSON.stringify(result.deliveryLog);
+    assert.match(serialized, /EHLO mail\.example\.com/);
+    assert.match(serialized, /AUTH PLAIN <redacted>/);
+    assert.match(serialized, /MAIL FROM:<sender@example\.com>/);
+    assert.match(serialized, /RCPT TO:<rcpt@example\.net>/);
+    assert.match(serialized, /DATA/);
+    assert.match(serialized, /queued as ABC123/);
+    assert.match(serialized, /messageBytes/);
+    assert.doesNotMatch(serialized, /super-secret/);
+    assert.doesNotMatch(serialized, /This body must not be logged/);
+
+    assert.ok(capturedCommands.some((command) => command.startsWith('AUTH PLAIN ')));
+  } finally {
+    await close(server);
+  }
+});
+
+function listen(server) {
+  return new Promise((resolve, reject) => {
+    server.once('error', reject);
+    server.listen(0, '127.0.0.1', () => {
+      server.off('error', reject);
+      resolve();
+    });
+  });
+}
+
+function close(server) {
+  return new Promise((resolve) => server.close(resolve));
+}

Деякі файли не було показано, через те що забагато файлів було змінено