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
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
package outbound
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
|
||||
"github.com/emersion/go-msgauth/dkim"
|
||||
)
|
||||
|
||||
// SignDKIM signs a raw RFC 5322 message with the domain's DKIM private key.
|
||||
// When the private key is empty the message is returned unchanged (unsigned).
|
||||
// The signature covers the standard header fields present in the message.
|
||||
func SignDKIM(raw []byte, domainName, selector, privateKeyPEM string) ([]byte, error) {
|
||||
if privateKeyPEM == "" {
|
||||
return raw, nil
|
||||
}
|
||||
if domainName == "" {
|
||||
return raw, fmt.Errorf("DKIM domain is empty")
|
||||
}
|
||||
if selector == "" {
|
||||
selector = "default"
|
||||
}
|
||||
|
||||
block, _ := pem.Decode([]byte(privateKeyPEM))
|
||||
if block == nil {
|
||||
return raw, fmt.Errorf("invalid DKIM private key PEM for %s", domainName)
|
||||
}
|
||||
|
||||
signer, err := parseSigner(block)
|
||||
if err != nil {
|
||||
return raw, fmt.Errorf("parse DKIM private key for %s: %v", domainName, err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
options := &dkim.SignOptions{
|
||||
Domain: domainName,
|
||||
Selector: selector,
|
||||
Signer: signer,
|
||||
}
|
||||
if err := dkim.Sign(&out, bytes.NewReader(raw), options); err != nil {
|
||||
return raw, fmt.Errorf("DKIM signing for %s failed: %v", domainName, err)
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// parseSigner parses an RSA private key PEM block into a crypto.Signer.
|
||||
// Both PKCS#1 ("RSA PRIVATE KEY") and PKCS#8 ("PRIVATE KEY") are supported.
|
||||
func parseSigner(block *pem.Block) (crypto.Signer, error) {
|
||||
// PKCS#1 is what the domain management form stores.
|
||||
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// PKCS#8 fallback for externally generated keys.
|
||||
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
signer, ok := parsed.(crypto.Signer)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unsupported private key type %T", parsed)
|
||||
}
|
||||
return signer, nil
|
||||
}
|
||||
Reference in New Issue
Block a user