Kaynağa Gözat

fix: track postfix delivery outcomes

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

+ 2 - 0
.env.example

@@ -32,6 +32,8 @@ SMTP_HOST=postfix
 SMTP_PORT=25
 SMTP_HELO=in.ss5.xyz
 SEND_REQUIRES_VERIFIED=false
+DELIVERY_TRACKING_ENABLED=true
+POSTFIX_LOG_POLL_INTERVAL_MS=5000
 
 # DMARC defaults.
 DMARC_POLICY=none

+ 5 - 0
docker-compose.yml

@@ -9,6 +9,7 @@ services:
     environment:
       PORT: 3000
       DATA_DIR: /data
+      POSTFIX_LOG_FILE: /data/postfix-logs/mail.log
     ports:
       - "127.0.0.1:${APP_PORT:-3025}:3000"
       - "${SUBMISSION_BIND:-0.0.0.0}:25:25"
@@ -35,7 +36,11 @@ services:
     restart: unless-stopped
     env_file:
       - .env
+    environment:
+      POSTFIX_LOG_FILE: /var/log/mailhub/mail.log
     hostname: ${MAIL_HOSTNAME:-ali.ss5.xyz}
+    volumes:
+      - ./data/postfix-logs:/var/log/mailhub
     healthcheck:
       test: ["CMD-SHELL", "postfix status >/dev/null 2>&1 || exit 1"]
       interval: 30s

+ 9 - 1
docker/postfix/entrypoint.sh

@@ -3,6 +3,14 @@ set -euo pipefail
 
 MAIL_HOSTNAME="${MAIL_HOSTNAME:-ali.ss5.xyz}"
 MAIL_ORIGIN_DOMAIN="${MAIL_ORIGIN_DOMAIN:-${MAIL_HOSTNAME#*.}}"
+POSTFIX_LOG_FILE="${POSTFIX_LOG_FILE:-/dev/stdout}"
+
+if [[ "${POSTFIX_LOG_FILE}" != "/dev/stdout" ]]; then
+  mkdir -p "$(dirname "${POSTFIX_LOG_FILE}")"
+  touch "${POSTFIX_LOG_FILE}"
+  chmod 666 "${POSTFIX_LOG_FILE}"
+  tail -n 0 -F "${POSTFIX_LOG_FILE}" &
+fi
 
 postconf -e "myhostname = ${MAIL_HOSTNAME}"
 postconf -e "myorigin = ${MAIL_ORIGIN_DOMAIN}"
@@ -18,7 +26,7 @@ postconf -e "smtp_helo_name = ${MAIL_HOSTNAME}"
 postconf -e "disable_vrfy_command = yes"
 postconf -e "maximal_queue_lifetime = 2d"
 postconf -e "bounce_queue_lifetime = 2d"
-postconf -e "maillog_file = /dev/stdout"
+postconf -e "maillog_file = ${POSTFIX_LOG_FILE}"
 
 newaliases || true
 exec postfix start-fg

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


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


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
public/assets/styles-BzBORaN9.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-CDcbky2i.js"></script>
-    <link rel="modulepreload" crossorigin href="/assets/styles-C5VOfA7x.js">
+    <script type="module" crossorigin src="/assets/index-2fgog3BM.js"></script>
+    <link rel="modulepreload" crossorigin href="/assets/styles-BzBORaN9.js">
     <link rel="stylesheet" crossorigin href="/assets/styles-B6t-ADxX.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-CxIl1qG4.js"></script>
-    <link rel="modulepreload" crossorigin href="/assets/styles-C5VOfA7x.js">
+    <script type="module" crossorigin src="/assets/login-Dw1J1na6.js"></script>
+    <link rel="modulepreload" crossorigin href="/assets/styles-BzBORaN9.js">
     <link rel="stylesheet" crossorigin href="/assets/styles-B6t-ADxX.css">
   </head>
   <body>

+ 117 - 4
src/db.js

@@ -53,7 +53,10 @@ export function initDatabase(dataDir, secret = '') {
       subject TEXT NOT NULL,
       status TEXT NOT NULL,
       detail TEXT NOT NULL DEFAULT '',
+      queue_id TEXT NOT NULL DEFAULT '',
       delivery_log_json TEXT NOT NULL DEFAULT '[]',
+      delivery_attempts_json TEXT NOT NULL DEFAULT '[]',
+      delivered_at TEXT,
       created_at TEXT NOT NULL,
       FOREIGN KEY(domain_id) REFERENCES domains(id) ON DELETE SET NULL
     );
@@ -105,12 +108,17 @@ 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', 'queue_id', "TEXT NOT NULL DEFAULT ''");
   ensureColumn('send_events', 'delivery_log_json', "TEXT NOT NULL DEFAULT '[]'");
+  ensureColumn('send_events', 'delivery_attempts_json', "TEXT NOT NULL DEFAULT '[]'");
+  ensureColumn('send_events', 'delivered_at', 'TEXT');
   ensureColumn('smtp_credentials', 'password_secret', "TEXT NOT NULL DEFAULT ''");
   db.exec(`
     CREATE INDEX IF NOT EXISTS idx_domains_user_id ON domains(user_id);
     CREATE INDEX IF NOT EXISTS idx_events_user_id ON send_events(user_id);
+    CREATE INDEX IF NOT EXISTS idx_events_queue_id ON send_events(queue_id);
   `);
+  normalizeSendEventQueueIds();
   normalizeDkimPublicKeys();
   return db;
 }
@@ -326,10 +334,14 @@ export function deleteDomain(id, userId) {
 }
 
 export function logSendEvent(event) {
+  const queueId = normalizeQueueId(event.queueId || extractQueueIdFromText(event.detail));
   const result = requireDb()
     .prepare(`
-      INSERT INTO send_events (user_id, domain_id, sender, recipients, subject, status, detail, delivery_log_json, created_at)
-      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+      INSERT INTO send_events (
+        user_id, domain_id, sender, recipients, subject, status, detail, queue_id,
+        delivery_log_json, delivery_attempts_json, delivered_at, created_at
+      )
+      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
     `)
     .run(
       event.userId ?? null,
@@ -339,12 +351,37 @@ export function logSendEvent(event) {
       event.subject,
       event.status,
       event.detail ?? '',
+      queueId,
       JSON.stringify(Array.isArray(event.deliveryLog) ? event.deliveryLog : []),
+      JSON.stringify(Array.isArray(event.deliveryAttempts) ? event.deliveryAttempts : []),
+      event.deliveredAt ?? null,
       now()
     );
   return result.lastInsertRowid;
 }
 
+export function updateSendEventDelivery(queueId, attempt) {
+  const cleanQueueId = normalizeQueueId(queueId || attempt?.queueId);
+  if (!cleanQueueId) return false;
+  const row = requireDb().prepare('SELECT * FROM send_events WHERE queue_id = ? ORDER BY id DESC LIMIT 1').get(cleanQueueId);
+  if (!row) return false;
+  const normalizedAttempt = normalizeDeliveryAttempt(attempt, cleanQueueId);
+  const attempts = safeJson(row.delivery_attempts_json, []);
+  if (attempts.some((item) => deliveryAttemptKey(item) === deliveryAttemptKey(normalizedAttempt))) return true;
+  const nextAttempts = [...attempts, normalizedAttempt];
+  const recipients = safeJson(row.recipients, []);
+  const nextStatus = deliveryStatusForEvent(recipients, nextAttempts, row.status);
+  const deliveredAt = nextStatus === 'sent' ? normalizedAttempt.at : row.delivered_at;
+  requireDb()
+    .prepare(`
+      UPDATE send_events
+      SET status = ?, detail = ?, delivery_attempts_json = ?, delivered_at = ?
+      WHERE id = ?
+    `)
+    .run(nextStatus, deliveryAttemptDetail(normalizedAttempt), JSON.stringify(nextAttempts), deliveredAt, row.id);
+  return true;
+}
+
 export function listSendEvents(userId, limit = 30) {
   return requireDb()
     .prepare(`
@@ -366,7 +403,10 @@ export function listSendEvents(userId, limit = 30) {
       subject: row.subject,
       status: row.status,
       detail: row.detail,
+      queueId: row.queue_id,
       deliveryLog: safeJson(row.delivery_log_json, []),
+      deliveryAttempts: safeJson(row.delivery_attempts_json, []),
+      deliveredAt: row.delivered_at,
       createdAt: row.created_at
     }));
 }
@@ -414,7 +454,7 @@ export function getSendAnalytics(userId, { days = 30 } = {}) {
     const createdAt = new Date(row.created_at);
     const dayKey = row.created_at.slice(0, 10);
     const hour = Number.isInteger(createdAt.getUTCHours()) ? createdAt.getUTCHours() : 0;
-    const isQueued = status === 'queued';
+    const isQueued = ['queued', 'sent'].includes(status);
 
     recipients += recipientCount;
     queued += isQueued ? 1 : 0;
@@ -451,7 +491,7 @@ export function getSendAnalytics(userId, { days = 30 } = {}) {
 
   const recentFailures = [...rows]
     .reverse()
-    .filter((row) => row.status !== 'queued')
+    .filter((row) => isDeliveryFailureStatus(row.status))
     .slice(0, 8)
     .map((row) => ({
       id: row.id,
@@ -723,6 +763,18 @@ function normalizeDkimPublicKeys() {
   }
 }
 
+function normalizeSendEventQueueIds() {
+  if (!tableExists('send_events') || !columnExists('send_events', 'queue_id')) return;
+  const rows = requireDb()
+    .prepare("SELECT id, detail FROM send_events WHERE queue_id = '' OR queue_id IS NULL")
+    .all();
+  const update = requireDb().prepare('UPDATE send_events SET queue_id = ? WHERE id = ?');
+  for (const row of rows) {
+    const queueId = extractQueueIdFromText(row.detail);
+    if (queueId) update.run(queueId, row.id);
+  }
+}
+
 function publicUser(row) {
   if (!row) return null;
   return {
@@ -877,6 +929,67 @@ function safeJson(value, fallback) {
   }
 }
 
+function normalizeQueueId(value) {
+  return String(value || '').trim().toUpperCase();
+}
+
+function extractQueueIdFromText(value) {
+  return String(value || '').match(/\bqueued as\s+([A-Z0-9]{5,})\b/i)?.[1]?.toUpperCase() || '';
+}
+
+function normalizeDeliveryAttempt(attempt, queueId) {
+  return {
+    at: attempt?.at || now(),
+    source: attempt?.source || 'postfix',
+    queueId,
+    recipient: String(attempt?.recipient || '').toLowerCase(),
+    relay: String(attempt?.relay || ''),
+    dsn: String(attempt?.dsn || ''),
+    status: String(attempt?.status || 'unknown').toLowerCase(),
+    response: String(attempt?.response || ''),
+    raw: String(attempt?.raw || '')
+  };
+}
+
+function deliveryAttemptKey(attempt) {
+  return attempt.raw || [
+    attempt.queueId,
+    attempt.recipient,
+    attempt.status,
+    attempt.dsn,
+    attempt.response
+  ].join('|');
+}
+
+function deliveryStatusForEvent(recipients, attempts, currentStatus) {
+  const byRecipient = new Map();
+  for (const attempt of attempts) {
+    if (attempt.recipient) byRecipient.set(String(attempt.recipient).toLowerCase(), attempt.status);
+  }
+  const normalizedRecipients = recipients.map((recipient) => String(recipient || '').toLowerCase()).filter(Boolean);
+  const statuses = normalizedRecipients.map((recipient) => byRecipient.get(recipient)).filter(Boolean);
+  if (normalizedRecipients.length && statuses.length === normalizedRecipients.length && statuses.every((status) => status === 'sent')) {
+    return 'sent';
+  }
+  if (statuses.includes('deferred')) return 'deferred';
+  if (statuses.includes('bounced')) return 'bounced';
+  return currentStatus || attempts.at(-1)?.status || 'queued';
+}
+
+function deliveryAttemptDetail(attempt) {
+  const parts = [
+    attempt.status,
+    attempt.recipient ? `to ${attempt.recipient}` : '',
+    attempt.relay ? `via ${attempt.relay}` : '',
+    attempt.dsn ? `dsn=${attempt.dsn}` : ''
+  ].filter(Boolean);
+  return `${parts.join(' ')}${attempt.response ? `; ${attempt.response}` : ''}`;
+}
+
+function isDeliveryFailureStatus(status) {
+  return ['deferred', 'bounced', 'failed'].includes(String(status || '').toLowerCase());
+}
+
 function now() {
   return new Date().toISOString();
 }

+ 114 - 0
src/delivery-tracker.js

@@ -0,0 +1,114 @@
+import { open, stat } from 'node:fs/promises';
+import { updateSendEventDelivery } from './db.js';
+
+const queueIdPattern = /\bqueued as\s+([A-Z0-9]{5,})\b/i;
+const deliveryServicePattern = /postfix\/(?:smtp|lmtp|local|virtual|pipe)\[\d+\]:\s+([A-Z0-9]{5,}):\s+(.+)$/i;
+
+export function extractQueueIdFromSmtpResponse(message) {
+  const match = String(message || '').match(queueIdPattern);
+  return match ? match[1].toUpperCase() : '';
+}
+
+export function parsePostfixLogLine(line) {
+  const serviceMatch = String(line || '').match(deliveryServicePattern);
+  if (!serviceMatch) return null;
+  const queueId = serviceMatch[1].toUpperCase();
+  const body = serviceMatch[2] || '';
+  const status = fieldValue(body, 'status');
+  if (!status) return null;
+  const recipient = bracketFieldValue(body, 'to');
+  const relay = fieldValue(body, 'relay');
+  const dsn = fieldValue(body, 'dsn');
+  const response = body.match(/\bstatus=[a-z]+\s+\((.*)\)\s*$/i)?.[1] || '';
+  return {
+    at: new Date().toISOString(),
+    source: 'postfix',
+    queueId,
+    recipient,
+    relay,
+    dsn,
+    status: normalizePostfixStatus(status),
+    response,
+    raw: String(line || '')
+  };
+}
+
+export function startPostfixDeliveryTracker({
+  enabled = true,
+  logFile,
+  pollIntervalMs = 5000,
+  onDelivery = updateSendEventDelivery,
+  logger = console
+} = {}) {
+  if (!enabled || !logFile) return null;
+  const state = {
+    offset: 0,
+    carry: '',
+    stopped: false,
+    polling: false
+  };
+
+  async function poll() {
+    if (state.stopped || state.polling) return;
+    state.polling = true;
+    try {
+      const chunk = await readNewChunk(logFile, state);
+      if (!chunk) return;
+      const lines = `${state.carry}${chunk}`.split(/\r?\n/);
+      state.carry = lines.pop() || '';
+      for (const line of lines) {
+        const event = parsePostfixLogLine(line);
+        if (event) onDelivery(event.queueId, event);
+      }
+    } catch (error) {
+      logger.warn?.(`Delivery tracker could not read Postfix log: ${error.message}`);
+    } finally {
+      state.polling = false;
+    }
+  }
+
+  const timer = setInterval(poll, pollIntervalMs);
+  timer.unref?.();
+  poll();
+  return {
+    stop() {
+      state.stopped = true;
+      clearInterval(timer);
+    },
+    poll
+  };
+}
+
+async function readNewChunk(logFile, state) {
+  const info = await stat(logFile).catch((error) => {
+    if (error.code === 'ENOENT') return null;
+    throw error;
+  });
+  if (!info || !info.isFile()) return '';
+  if (info.size < state.offset) state.offset = 0;
+  if (info.size === state.offset) return '';
+  const length = info.size - state.offset;
+  const buffer = Buffer.alloc(length);
+  const handle = await open(logFile, 'r');
+  try {
+    await handle.read(buffer, 0, length, state.offset);
+  } finally {
+    await handle.close();
+  }
+  state.offset = info.size;
+  return buffer.toString('utf8');
+}
+
+function fieldValue(body, key) {
+  return String(body || '').match(new RegExp(`\\b${key}=([^,\\s]+)`, 'i'))?.[1] || '';
+}
+
+function bracketFieldValue(body, key) {
+  return String(body || '').match(new RegExp(`\\b${key}=<([^>]+)>`, 'i'))?.[1] || '';
+}
+
+function normalizePostfixStatus(status) {
+  const value = String(status || '').toLowerCase();
+  if (['sent', 'deferred', 'bounced'].includes(value)) return value;
+  return value || 'unknown';
+}

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

@@ -172,6 +172,15 @@ const messages = {
     'logs.messageId': 'Message ID',
     'logs.finalResponse': '最终响应',
     'logs.messageBytes': '邮件字节数',
+    'logs.queueId': 'Postfix 队列 ID',
+    'logs.deliveredAt': '最终投递时间',
+    'logs.deliveryAttempts': '投递尝试',
+    'logs.noDeliveryAttempts': '暂无最终投递回执,可能仍在队列中或属于历史记录。',
+    'logs.statusQueued': '队列中',
+    'logs.statusSent': '已送达',
+    'logs.statusDeferred': '暂时失败',
+    'logs.statusBounced': '已退信',
+    'logs.statusFailed': '失败',
     'smtp.connectionTitle': 'SMTP 连接信息',
     'smtp.updateTitle': '更新 SMTP 凭据',
     'smtp.usernameRequired': '请输入 SMTP Username',
@@ -437,6 +446,15 @@ const messages = {
     'logs.messageId': 'Message ID',
     'logs.finalResponse': 'Final response',
     'logs.messageBytes': 'Message bytes',
+    'logs.queueId': 'Postfix Queue ID',
+    'logs.deliveredAt': 'Delivered at',
+    'logs.deliveryAttempts': 'Delivery attempts',
+    'logs.noDeliveryAttempts': 'No final delivery receipt yet. The message may still be queued or historical.',
+    'logs.statusQueued': 'Queued',
+    'logs.statusSent': 'Sent',
+    'logs.statusDeferred': 'Deferred',
+    'logs.statusBounced': 'Bounced',
+    'logs.statusFailed': 'Failed',
     'smtp.connectionTitle': 'SMTP connection',
     'smtp.updateTitle': 'Update SMTP credential',
     'smtp.usernameRequired': 'Enter SMTP username',

+ 15 - 0
src/frontend/types.ts

@@ -129,6 +129,18 @@ export interface DeliveryLogEntry {
   messageBytes?: number;
 }
 
+export interface DeliveryAttempt {
+  at: string;
+  source?: string;
+  queueId: string;
+  recipient?: string;
+  relay?: string;
+  dsn?: string;
+  status: string;
+  response?: string;
+  raw?: string;
+}
+
 export interface SendEvent {
   id: number;
   userId: number;
@@ -139,7 +151,10 @@ export interface SendEvent {
   subject: string;
   status: string;
   detail: string;
+  queueId?: string;
   deliveryLog?: DeliveryLogEntry[];
+  deliveryAttempts?: DeliveryAttempt[];
+  deliveredAt?: string;
   createdAt: string;
 }
 

+ 6 - 1
src/mailer.js

@@ -1,6 +1,7 @@
 import net from 'node:net';
 import tls from 'node:tls';
 import crypto from 'node:crypto';
+import { extractQueueIdFromSmtpResponse } from './delivery-tracker.js';
 import { signDkim } from './dkim.js';
 
 export function parseAddressList(value) {
@@ -132,7 +133,11 @@ export async function sendViaSmtp({ host, port, secure, username, password, helo
         ok: false
       });
     });
-    return { ...dataResponse, deliveryLog };
+    return {
+      ...dataResponse,
+      queueId: extractQueueIdFromSmtpResponse(dataResponse.message),
+      deliveryLog
+    };
   } catch (error) {
     addLog({
       phase: 'error',

+ 14 - 3
src/pages/Dashboard.tsx

@@ -56,7 +56,7 @@ export default function Dashboard({ analytics, domains, events, config, smtpCred
     {
       title: t('common.status'),
       dataIndex: 'status',
-      render: (value) => <Tag color={value === 'queued' ? 'success' : 'error'}>{statusLabel(value, t)}</Tag>
+      render: (value) => <Tag color={statusColor(value)}>{statusLabel(value, t)}</Tag>
     }
   ];
 
@@ -211,11 +211,22 @@ export default function Dashboard({ analytics, domains, events, config, smtpCred
 }
 
 function statusLabel(status: string, t: (key: string) => string) {
-  if (status === 'queued') return t('dashboard.statusQueued');
-  if (status === 'failed') return t('dashboard.statusFailed');
+  if (status === 'queued') return t('logs.statusQueued');
+  if (status === 'sent') return t('logs.statusSent');
+  if (status === 'deferred') return t('logs.statusDeferred');
+  if (status === 'bounced') return t('logs.statusBounced');
+  if (status === 'failed') return t('logs.statusFailed');
   return status || t('dashboard.statusUnknown');
 }
 
+function statusColor(status: string) {
+  if (status === 'queued') return 'processing';
+  if (status === 'sent') return 'success';
+  if (status === 'deferred') return 'warning';
+  if (status === 'bounced' || status === 'failed') return 'error';
+  return 'default';
+}
+
 function domainHealthLabel(status: string, t: (key: string) => string) {
   if (status === 'success') return t('domains.healthy');
   if (status === 'warning') return t('domains.waitingDns');

+ 18 - 1
src/pages/Domains/DomainDetail.tsx

@@ -340,7 +340,7 @@ function SendingLogsTab({ events }: { events: SendEvent[] }) {
     { title: t('logs.time'), dataIndex: 'createdAt', render: (value) => new Date(value).toLocaleString() },
     { title: t('logs.recipient'), dataIndex: 'recipients', render: (value: string[]) => value.join(', ') },
     { title: 'Subject', dataIndex: 'subject', ellipsis: true },
-    { title: t('common.status'), dataIndex: 'status', render: (value) => <Tag color={value === 'queued' ? 'success' : 'error'}>{value}</Tag> },
+    { title: t('common.status'), dataIndex: 'status', render: (value) => <Tag color={sendStatusColor(value)}>{sendStatusLabel(value, t)}</Tag> },
     { title: t('logs.errorReason'), dataIndex: 'detail', ellipsis: true }
   ];
   return <Table rowKey="id" columns={columns} dataSource={events} scroll={{ x: 900 }} />;
@@ -390,6 +390,23 @@ function orderedRecords(records: DnsRecord[]) {
   return [...records].sort((a, b) => order.indexOf(a.key) - order.indexOf(b.key));
 }
 
+function sendStatusLabel(status: string, t: (key: string) => string) {
+  if (status === 'queued') return t('logs.statusQueued');
+  if (status === 'sent') return t('logs.statusSent');
+  if (status === 'deferred') return t('logs.statusDeferred');
+  if (status === 'bounced') return t('logs.statusBounced');
+  if (status === 'failed') return t('logs.statusFailed');
+  return status || t('dashboard.statusUnknown');
+}
+
+function sendStatusColor(status: string) {
+  if (status === 'queued') return 'processing';
+  if (status === 'sent') return 'success';
+  if (status === 'deferred') return 'warning';
+  if (status === 'bounced' || status === 'failed') return 'error';
+  return 'default';
+}
+
 function liveLabel(key: string, t: (key: string) => string) {
   return {
     rootTxt: t('dnsRecord.rootTxt'),

+ 86 - 6
src/pages/SendingLogs.tsx

@@ -4,7 +4,7 @@ import type { ColumnsType } from 'antd/es/table';
 import { useMemo, useState } from 'react';
 
 import { useI18n } from '../frontend/i18n/react';
-import type { DeliveryLogEntry, Domain, SendEvent } from '../frontend/types';
+import type { DeliveryAttempt, DeliveryLogEntry, Domain, SendEvent } from '../frontend/types';
 
 const { RangePicker } = DatePicker;
 
@@ -38,7 +38,7 @@ export default function SendingLogs({ events, domains, onCopy }: SendingLogsProp
     { title: t('logs.recipient'), dataIndex: 'recipients', render: (value: string[]) => value.join(', '), ellipsis: true },
     { title: t('logs.domain'), dataIndex: 'domain', width: 180 },
     { title: 'Subject', dataIndex: 'subject', ellipsis: true },
-    { title: t('common.status'), dataIndex: 'status', render: (value) => <Tag color={value === 'queued' ? 'success' : 'error'}>{value}</Tag>, width: 110 },
+    { title: t('common.status'), dataIndex: 'status', render: (value) => <StatusTag status={value} />, width: 120 },
     { 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: (_, event) => <Button onClick={() => setSelected(event)}>{t('logs.viewDetail')}</Button>, width: 120 }
@@ -71,8 +71,11 @@ export default function SendingLogs({ events, domains, onCopy }: SendingLogsProp
                 value={status}
                 onChange={setStatus}
                 options={[
-                  { value: 'queued', label: 'queued' },
-                  { value: 'failed', label: 'failed' }
+                  { value: 'queued', label: t('logs.statusQueued') },
+                  { value: 'sent', label: t('logs.statusSent') },
+                  { value: 'deferred', label: t('logs.statusDeferred') },
+                  { value: 'bounced', label: t('logs.statusBounced') },
+                  { value: 'failed', label: t('logs.statusFailed') }
                 ]}
                 className="toolbar-select"
               />
@@ -130,13 +133,32 @@ export default function SendingLogs({ events, domains, onCopy }: SendingLogsProp
               <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>
+                <StatusTag status={event.status} />
               </Descriptions.Item>
               <Descriptions.Item label={t('logs.messageId')}>mh-{event.id}</Descriptions.Item>
+              <Descriptions.Item label={t('logs.queueId')}>
+                <Typography.Text code>{event.queueId || '-'}</Typography.Text>
+              </Descriptions.Item>
+              <Descriptions.Item label={t('logs.deliveredAt')}>
+                {event.deliveredAt ? new Date(event.deliveredAt).toLocaleString() : '-'}
+              </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.deliveryAttempts')} className="delivery-log-card">
+              {event.deliveryAttempts?.length ? (
+                <Timeline
+                  items={event.deliveryAttempts.map((attempt, index) => ({
+                    key: `${attempt.raw || attempt.at}-${index}`,
+                    color: deliveryAttemptColor(attempt.status),
+                    children: <DeliveryAttemptTimelineItem attempt={attempt} />
+                  }))}
+                />
+              ) : (
+                <Empty description={t('logs.noDeliveryAttempts')} />
+              )}
+            </Card>
             <Card size="small" title={t('logs.deliveryLog')} className="delivery-log-card">
               {deliveryLog.length ? (
                 <Timeline
@@ -175,6 +197,21 @@ export default function SendingLogs({ events, domains, onCopy }: SendingLogsProp
     );
   }
 
+  function DeliveryAttemptTimelineItem({ attempt }: { attempt: DeliveryAttempt }) {
+    return (
+      <div className="delivery-log-entry">
+        <Space wrap size={8}>
+          <StatusTag status={attempt.status} />
+          {attempt.dsn ? <Tag>dsn {attempt.dsn}</Tag> : null}
+          <Typography.Text type="secondary">{attempt.at ? new Date(attempt.at).toLocaleString() : '-'}</Typography.Text>
+        </Space>
+        {attempt.recipient ? <LogLine label="To" value={attempt.recipient} /> : null}
+        {attempt.relay ? <LogLine label="MX" value={attempt.relay} /> : null}
+        {attempt.response ? <LogLine label="S" value={attempt.response} /> : null}
+      </div>
+    );
+  }
+
   function LogLine({ label, value }: { label: string; value: string }) {
     return (
       <div className="delivery-log-line">
@@ -191,10 +228,22 @@ export default function SendingLogs({ events, domains, onCopy }: SendingLogsProp
       `${t('logs.sender')}: ${event.sender}`,
       `${t('logs.recipient')}: ${event.recipients.join(', ')}`,
       `${t('logs.subject')}: ${event.subject || '-'}`,
-      `${t('common.status')}: ${event.status}`,
+      `${t('common.status')}: ${statusLabel(event.status)}`,
+      `${t('logs.queueId')}: ${event.queueId || '-'}`,
+      `${t('logs.deliveredAt')}: ${event.deliveredAt ? new Date(event.deliveredAt).toLocaleString() : '-'}`,
       `${t('logs.finalResponse')}: ${event.detail || '-'}`,
       ''
     ];
+    if (event.deliveryAttempts?.length) {
+      lines.push(t('logs.deliveryAttempts'));
+      for (const attempt of event.deliveryAttempts) {
+        lines.push(`[${attempt.at || '-'}] ${statusLabel(attempt.status)} ${attempt.recipient || ''}`);
+        if (attempt.relay) lines.push(`MX: ${attempt.relay}`);
+        if (attempt.dsn) lines.push(`DSN: ${attempt.dsn}`);
+        if (attempt.response) lines.push(`S: ${attempt.response}`);
+        lines.push('');
+      }
+    }
     const entries = event.deliveryLog?.length ? event.deliveryLog : [{
       at: event.createdAt,
       phase: 'legacy',
@@ -218,4 +267,35 @@ export default function SendingLogs({ events, domains, onCopy }: SendingLogsProp
     if (entry.phase === 'auth') return 'gold';
     return 'blue';
   }
+
+  function StatusTag({ status }: { status: string }) {
+    return <Tag color={statusColor(status)}>{statusLabel(status)}</Tag>;
+  }
+
+  function statusLabel(status: string) {
+    return {
+      queued: t('logs.statusQueued'),
+      sent: t('logs.statusSent'),
+      deferred: t('logs.statusDeferred'),
+      bounced: t('logs.statusBounced'),
+      failed: t('logs.statusFailed')
+    }[status] || status;
+  }
+
+  function statusColor(status: string) {
+    return {
+      queued: 'processing',
+      sent: 'success',
+      deferred: 'warning',
+      bounced: 'error',
+      failed: 'error'
+    }[status] || 'default';
+  }
+
+  function deliveryAttemptColor(status: string) {
+    if (status === 'sent') return 'green';
+    if (status === 'deferred') return 'gold';
+    if (status === 'bounced' || status === 'failed') return 'red';
+    return 'blue';
+  }
 }

+ 12 - 1
src/server.js

@@ -40,6 +40,7 @@ import {
   verifyApiToken
 } from './db.js';
 import { applyDnsSetup, testDnsCredential } from './dns-providers.js';
+import { startPostfixDeliveryTracker } from './delivery-tracker.js';
 import { buildDnsGuide } from './dns-guide.js';
 import { createDkimKeyPair } from './dkim.js';
 import {
@@ -72,6 +73,9 @@ const envConfig = {
   smtpUser: process.env.SMTP_USERNAME || '',
   smtpPassword: process.env.SMTP_PASSWORD || '',
   smtpHelo: process.env.SMTP_HELO || process.env.MAIL_HOSTNAME || 'mailhub.local',
+  postfixLogFile: process.env.POSTFIX_LOG_FILE || path.join(process.env.DATA_DIR || path.join(process.cwd(), 'data'), 'postfix-logs', 'mail.log'),
+  postfixLogPollIntervalMs: Number(process.env.POSTFIX_LOG_POLL_INTERVAL_MS || 5000),
+  deliveryTrackingEnabled: String(process.env.DELIVERY_TRACKING_ENABLED || 'true').toLowerCase() !== 'false',
   submissionEnabled: String(process.env.SUBMISSION_ENABLED || 'true').toLowerCase() !== 'false',
   submissionHost: process.env.SUBMISSION_HOST || process.env.APP_BASE_URL?.replace(/^https?:\/\//, '') || 'localhost',
   submissionListeners: parseSubmissionListeners(process.env.SUBMISSION_PORTS),
@@ -105,6 +109,12 @@ const admin = seedAdminUser({
 claimLegacyData(admin.id);
 seedSmtpCredential(admin.id, envConfig.submissionUsername, envConfig.submissionPassword);
 
+startPostfixDeliveryTracker({
+  enabled: envConfig.deliveryTrackingEnabled,
+  logFile: envConfig.postfixLogFile,
+  pollIntervalMs: envConfig.postfixLogPollIntervalMs
+});
+
 const server = http.createServer(async (req, res) => {
   try {
     setSecurityHeaders(res);
@@ -433,9 +443,10 @@ async function sendMailFromBody(body, user) {
       subject: body.subject || '(no subject)',
       status: 'queued',
       detail: smtpResult.message,
+      queueId: smtpResult.queueId,
       deliveryLog: smtpResult.deliveryLog
     });
-    return { queued: true, domain: domain.domain, recipients, smtp: smtpResult.message };
+    return { queued: true, domain: domain.domain, recipients, smtp: smtpResult.message, queueId: smtpResult.queueId };
   } catch (error) {
     logSendEvent({
       userId: user.id,

+ 1 - 0
src/submission.js

@@ -301,6 +301,7 @@ class SubmissionSession {
         subject,
         status: 'queued',
         detail: `submission ${this.remoteAddress}; ${smtpResult.message}`,
+        queueId: smtpResult.queueId,
         deliveryLog: smtpResult.deliveryLog
       });
       this.resetEnvelope(false);

+ 29 - 0
test/db.test.js

@@ -172,6 +172,35 @@ test('summarizes send analytics by user', () => {
   assert.equal(analytics.recentFailures[0].detail, 'relay rejected');
 });
 
+test('excludes queued and sent messages from recent delivery failures', () => {
+  initDatabase(tempDataDir(), 'test-secret');
+  const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
+  const domain = createDomain(alice.id, domainFixture('alice.example'));
+  for (const event of [
+    { subject: 'Queued', status: 'queued', detail: 'accepted by postfix' },
+    { subject: 'Sent', status: 'sent', detail: '250 OK' },
+    { subject: 'Deferred', status: 'deferred', detail: 'temporary failure' },
+    { subject: 'Bounced', status: 'bounced', detail: '550 user unknown' },
+    { subject: 'Failed', status: 'failed', detail: 'relay rejected' }
+  ]) {
+    logSendEvent({
+      userId: alice.id,
+      domainId: domain.id,
+      sender: 'noreply@alice.example',
+      recipients: ['user@example.com'],
+      subject: event.subject,
+      status: event.status,
+      detail: event.detail
+    });
+  }
+
+  const analytics = getSendAnalytics(alice.id, { days: 7 });
+  assert.deepEqual(
+    analytics.recentFailures.map((event) => event.subject),
+    ['Failed', 'Bounced', 'Deferred']
+  );
+});
+
 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' });

+ 104 - 0
test/delivery-tracker.test.js

@@ -0,0 +1,104 @@
+import assert from 'node:assert/strict';
+import { mkdtempSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { test } from 'node:test';
+
+import {
+  extractQueueIdFromSmtpResponse,
+  parsePostfixLogLine
+} from '../src/delivery-tracker.js';
+import {
+  createDomain,
+  createUser,
+  initDatabase,
+  listSendEvents,
+  logSendEvent,
+  updateSendEventDelivery
+} from '../src/db.js';
+
+test('extracts postfix queue ids from SMTP queue responses', () => {
+  assert.equal(extractQueueIdFromSmtpResponse('250 2.0.0 Ok: queued as 1DAEBC3EC8'), '1DAEBC3EC8');
+  assert.equal(extractQueueIdFromSmtpResponse('250 OK queued as ABC123'), 'ABC123');
+  assert.equal(extractQueueIdFromSmtpResponse('250 message accepted'), '');
+});
+
+test('parses postfix delivery status lines', () => {
+  const event = parsePostfixLogLine(
+    'Jul 08 04:15:21 in postfix/smtp[300]: 1DAEBC3EC8: to=<chendeben@qq.com>, relay=mx3.qq.com[203.205.219.57]:25, delay=3.4, delays=0.04/0.11/1.7/1.6, dsn=2.0.0, status=sent (250 OK: queued as.)'
+  );
+
+  assert.equal(event.queueId, '1DAEBC3EC8');
+  assert.equal(event.recipient, 'chendeben@qq.com');
+  assert.equal(event.relay, 'mx3.qq.com[203.205.219.57]:25');
+  assert.equal(event.dsn, '2.0.0');
+  assert.equal(event.status, 'sent');
+  assert.equal(event.response, '250 OK: queued as.');
+});
+
+test('updates send events from postfix delivery attempts', () => {
+  initDatabase(tempDataDir(), 'test-secret');
+  const user = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });
+  const domain = createDomain(user.id, domainFixture('sender.example.com'));
+  logSendEvent({
+    userId: user.id,
+    domainId: domain.id,
+    sender: 'noreply@sender.example.com',
+    recipients: ['chendeben@qq.com'],
+    subject: 'Tracked',
+    status: 'queued',
+    detail: '250 2.0.0 Ok: queued as 1DAEBC3EC8'
+  });
+
+  const updated = updateSendEventDelivery('1DAEBC3EC8', {
+    at: '2026-07-08T04:15:21.000Z',
+    queueId: '1DAEBC3EC8',
+    recipient: 'chendeben@qq.com',
+    relay: 'mx3.qq.com[203.205.219.57]:25',
+    dsn: '2.0.0',
+    status: 'sent',
+    response: '250 OK: queued as.',
+    raw: 'raw postfix line'
+  });
+
+  assert.equal(updated, true);
+  const [event] = listSendEvents(user.id);
+  assert.equal(event.queueId, '1DAEBC3EC8');
+  assert.equal(event.status, 'sent');
+  assert.equal(event.deliveredAt, '2026-07-08T04:15:21.000Z');
+  assert.equal(event.deliveryAttempts.length, 1);
+  assert.equal(event.deliveryAttempts[0].status, 'sent');
+  assert.equal(event.deliveryAttempts[0].recipient, 'chendeben@qq.com');
+
+  updateSendEventDelivery('1DAEBC3EC8', {
+    at: '2026-07-08T04:15:21.000Z',
+    queueId: '1DAEBC3EC8',
+    recipient: 'chendeben@qq.com',
+    relay: 'mx3.qq.com[203.205.219.57]:25',
+    dsn: '2.0.0',
+    status: 'sent',
+    response: '250 OK: queued as.',
+    raw: 'raw postfix line'
+  });
+
+  assert.equal(listSendEvents(user.id)[0].deliveryAttempts.length, 1);
+});
+
+function domainFixture(domain) {
+  return {
+    domain,
+    selector: 'mh202607',
+    verificationToken: 'token',
+    dkimPublic: 'public',
+    dkimPrivate: 'private',
+    senderHost: `mail.${domain}`,
+    sendingIp: '127.0.0.1',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  };
+}
+
+function tempDataDir() {
+  return mkdtempSync(path.join(tmpdir(), 'mailhub-test-'));
+}

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

@@ -63,6 +63,7 @@ test('records a sanitized SMTP delivery log without storing credentials or messa
     });
 
     assert.equal(result.code, 250);
+    assert.equal(result.queueId, 'ABC123');
     assert.ok(Array.isArray(result.deliveryLog));
     assert.ok(result.deliveryLog.length >= 10);
 

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