Files
dsh 5eb9bc2c71 feat: 实现对外邮件投递(外发队列 + MX 直投 + DKIM + 退信 + 管理后台)
- 新增 internal/outbound 模块:MX 查询、SMTP 出站客户端(EHLO/STARTTLS/
  MAIL/RCPT/DATA/QUIT)、4xx 临时失败与 5xx 永久失败分类、8BITMIME 支持
- 新增 outbound_messages 队列表与 OutboundStore,后台 worker 指数退避重试
- 永久失败/超限退信到发件人收件箱,包含原因与目标收件人
- 外发邮件使用域名 DKIM 私钥签名(go-msgauth)
- SMTP 提交集成:认证用户可发外部收件人,MAIL FROM 必须等于登录用户邮箱,
  未认证外部投递明确拒绝(防开放中继)
- Web 发信集成:外部收件人自动入队,附件以 multipart/mixed + base64 编码
  加入邮件正文
- 每用户每分钟/每日发送限速(max_per_day=0 可禁用外部投递)
- 管理后台新增外发队列页面:状态统计、失败原因、手动重试/取消
- 新增 [outbound] 配置段并更新 README / todo.md
2026-08-15 17:18:54 -04:00

119 lines
6.7 KiB
HTML

{{define "admin_outbound"}}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>外发队列 - MailGo</title>
{{template "styles" .}}
</head>
<body>
{{template "navbar" .}}
<div class="container">
<div class="clearfix">
<div class="sidebar">
<a href="/inbox">返回邮箱</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
</div>
<div class="content">
<h2 style="margin-bottom:24px;">外发队列</h2>
<div style="margin-bottom:24px;">
<div class="stat-card">
<h3>{{index .statCounts "pending"}}</h3>
<p>待发送</p>
</div>
<div class="stat-card">
<h3>{{index .statCounts "deferred"}}</h3>
<p>等待重试</p>
</div>
<div class="stat-card">
<h3>{{index .statCounts "sent"}}</h3>
<p>已送达</p>
</div>
<div class="stat-card">
<h3>{{index .statCounts "failed"}}</h3>
<p>失败</p>
</div>
</div>
<div class="card">
<div style="margin-bottom:12px;">
<a href="/admin/outbound" class="btn btn-sm {{if eq .status ""}}btn-primary{{end}}" style="background:{{if eq .status ""}}#3498db{{else}}#ecf0f1{{end}};color:{{if eq .status ""}}#fff{{else}}#333{{end}};">全部</a>
<a href="/admin/outbound?status=pending" class="btn btn-sm">待发送</a>
<a href="/admin/outbound?status=deferred" class="btn btn-sm">等待重试</a>
<a href="/admin/outbound?status=sent" class="btn btn-sm">已送达</a>
<a href="/admin/outbound?status=failed" class="btn btn-sm">失败</a>
<a href="/admin/outbound?status=canceled" class="btn btn-sm">已取消</a>
</div>
<table>
<thead>
<tr>
<th>ID</th>
<th>发件人</th>
<th>收件人</th>
<th>状态</th>
<th>尝试次数</th>
<th>下次重试</th>
<th>最后响应 / 错误</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{{range .items}}
<tr>
<td>{{.ID}}</td>
<td>{{.FromAddr}}</td>
<td>{{.ToAddr}}</td>
<td>
{{if eq .Status "pending"}}<span class="badge" style="background:#f39c12;color:#fff;">{{call $.statusText .Status}}</span>
{{else if eq .Status "deferred"}}<span class="badge" style="background:#e67e22;color:#fff;">{{call $.statusText .Status}}</span>
{{else if eq .Status "sent"}}<span class="badge" style="background:#27ae60;color:#fff;">{{call $.statusText .Status}}</span>
{{else if eq .Status "failed"}}<span class="badge badge-unread">{{call $.statusText .Status}}</span>
{{else}}<span class="badge" style="background:#95a5a6;color:#fff;">{{call $.statusText .Status}}</span>{{end}}
</td>
<td>{{.Attempts}}</td>
<td>{{if or (eq .Status "pending") (eq .Status "deferred")}}{{.NextAttemptAt.Format "2006-01-02 15:04"}}{{else}}—{{end}}</td>
<td style="max-width:280px;word-break:break-all;">{{if .LastResponse}}{{.LastResponse}}{{else}}{{.LastError}}{{end}}</td>
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
<td>
{{if or (eq .Status "failed") (eq .Status "deferred") (eq .Status "canceled") (eq .Status "pending")}}
<form method="POST" action="/admin/outbound/{{.ID}}/retry" style="display:inline;">
<button type="submit" class="btn btn-sm btn-primary">重试</button>
</form>
{{end}}
{{if or (eq .Status "pending") (eq .Status "deferred")}}
<form method="POST" action="/admin/outbound/{{.ID}}/cancel" style="display:inline;" onsubmit="return confirm('确认取消该投递任务?');">
<button type="submit" class="btn btn-sm btn-danger">取消</button>
</form>
{{end}}
</td>
</tr>
{{else}}
<tr><td colspan="9" style="text-align:center;color:#7f8c8d;">队列为空</td></tr>
{{end}}
</tbody>
</table>
{{if gt .totalPages 1}}
<div class="pagination">
{{if gt .page 1}}<a href="/admin/outbound?page={{sub .page 1}}&status={{.status}}">上一页</a>{{end}}
<span class="current">第 {{.page}} / {{.totalPages}} 页</span>
{{if lt .page .totalPages}}<a href="/admin/outbound?page={{add .page 1}}&status={{.status}}">下一页</a>{{end}}
</div>
{{end}}
</div>
</div>
</div>
</div>
</body>
</html>
{{end}}