ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
detections / detections (push) Waiting to run
docs / links (push) Waiting to run
web / web (push) Waiting to run
216 lines
8.4 KiB
Go
216 lines
8.4 KiB
Go
package notify
|
||
|
||
import (
|
||
"context"
|
||
"crypto/tls"
|
||
"encoding/base64"
|
||
"errors"
|
||
"fmt"
|
||
"mime"
|
||
"net"
|
||
"net/smtp"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// emailDialTimeout / emailSessionTimeout 分别约束建连与整段 SMTP 会话。
|
||
// net/smtp 自身没有任何超时机制,不设这两道的话,一个卡住的对端会让
|
||
// 投递 goroutine 永久挂在那里——而 dispatcher 是单 goroutine 串行处理的,
|
||
// 等于整个通知系统停摆。
|
||
const (
|
||
emailDialTimeout = 10 * time.Second
|
||
emailSessionTimeout = 45 * time.Second
|
||
)
|
||
|
||
// emailChannel 实现 SMTP 邮件投递。
|
||
type emailChannel struct{}
|
||
|
||
func (emailChannel) Kind() string { return KindEmail }
|
||
|
||
// 邮件没有平台限流,但不该用它刷屏;给一个宽松的默认值。
|
||
func (emailChannel) DefaultRatePerMin() int { return 60 }
|
||
|
||
// 只掩码密码。SMTP 主机、账号、收件人都不算秘密,掩码它们只会让编辑变麻烦。
|
||
func (emailChannel) SecretKeys() []string { return []string{"password"} }
|
||
|
||
// host/port 决定把密码交给哪台服务器;tls 决定是否加密传输。三者任一变化都
|
||
// 要求重新表态密码——顺带让「关掉 TLS」这一步必须显式带上凭据,而不是顺手一改。
|
||
func (emailChannel) DestinationKeys() []string { return []string{"host", "port", "tls"} }
|
||
|
||
func (emailChannel) Validate(cfg map[string]any) error {
|
||
if cfgString(cfg, "host") == "" {
|
||
return errors.New("缺少 SMTP 服务器地址")
|
||
}
|
||
port := cfgInt(cfg, "port")
|
||
if port <= 0 || port > 65535 {
|
||
return errors.New("SMTP 端口无效(必须在 1-65535 范围内)")
|
||
}
|
||
if cfgString(cfg, "from") == "" {
|
||
return errors.New("缺少发件人地址")
|
||
}
|
||
if len(cfgStrings(cfg, "to")) == 0 {
|
||
return errors.New("至少需要一个收件人地址")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (c emailChannel) Send(ctx context.Context, cfg map[string]any, m Message) (int, error) {
|
||
if err := c.Validate(cfg); err != nil {
|
||
return 0, Permanent(err)
|
||
}
|
||
host := cfgString(cfg, "host")
|
||
port := cfgInt(cfg, "port")
|
||
from := cfgString(cfg, "from")
|
||
to := cfgStrings(cfg, "to")
|
||
username := cfgString(cfg, "username")
|
||
password := cfgString(cfg, "password")
|
||
implicitTLS := cfgBool(cfg, "tls")
|
||
|
||
msg, err := buildEmailMessage(from, to, m)
|
||
if err != nil {
|
||
return 0, Permanent(err)
|
||
}
|
||
|
||
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||
client, err := emailDial(ctx, addr, host, implicitTLS)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
defer client.Close()
|
||
|
||
// STARTTLS:对端支持就升级。明文会话下不能发凭据(见下面的 auth 说明)。
|
||
if !implicitTLS {
|
||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||
if err := client.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil {
|
||
return 0, fmt.Errorf("STARTTLS 失败:%w", err)
|
||
}
|
||
}
|
||
}
|
||
if username != "" {
|
||
if err := client.Auth(smtp.PlainAuth("", username, password, host)); err != nil {
|
||
// smtp.PlainAuth 会拒绝在未加密连接上发送凭据(除非目标是 localhost)。
|
||
// 这是**正确**的安全行为,不能绕过,但需要把原因翻译清楚——
|
||
// 否则使用者只会看到「unencrypted connection」而不知道该怎么办。
|
||
if strings.Contains(err.Error(), "unencrypted connection") {
|
||
return 0, Permanent(fmt.Errorf("拒绝发送凭据:连接未加密。请开启 TLS、使用 465 端口(隐式 TLS),或勾选「使用 TLS」(%w)", err))
|
||
}
|
||
return 0, Permanent(fmt.Errorf("SMTP 认证失败:%w", err))
|
||
}
|
||
}
|
||
if err := client.Mail(from); err != nil {
|
||
return 0, smtpStageError(fmt.Sprintf("发件人地址 %s 被拒绝", from), err)
|
||
}
|
||
for _, rcpt := range to {
|
||
if err := client.Rcpt(rcpt); err != nil {
|
||
return 0, smtpStageError(fmt.Sprintf("收件人地址 %s 被拒绝", rcpt), err)
|
||
}
|
||
}
|
||
w, err := client.Data()
|
||
if err != nil {
|
||
return 0, fmt.Errorf("SMTP DATA 失败:%w", err)
|
||
}
|
||
if _, err := w.Write([]byte(msg)); err != nil {
|
||
return 0, fmt.Errorf("写入邮件正文失败:%w", err)
|
||
}
|
||
if err := w.Close(); err != nil {
|
||
return 0, fmt.Errorf("提交邮件失败:%w", err)
|
||
}
|
||
// Quit 失败不影响「邮件已被服务器接收」这个事实,因此忽略其错误。
|
||
_ = client.Quit()
|
||
// 邮件没有长度截断(HTML 正文全部发送),整批都算送达。
|
||
return len(m.Items), nil
|
||
}
|
||
|
||
// emailDial 建立 SMTP 连接。
|
||
//
|
||
// implicitTLS=true 走 465 这类「连上即 TLS」的方式;false 走 25/587 明文建连后再
|
||
// STARTTLS。两者不能混:对 465 端口发明文 greeting 会被直接断开。
|
||
//
|
||
// 会话期限在**建连处**就设好(而非事后补设),因为 net/smtp 的 Client 把底层
|
||
// 连接藏在未导出字段里,外部拿不到它;连接一旦交出去就只能靠预先设置的 deadline
|
||
// 兜底。这也顺带覆盖了握手阶段的阻塞。
|
||
// Control 挂 blockInternalDial 与 HTTP 系渠道共用同一道守卫。不挂的话 SMTP
|
||
// 就是整套 SSRF 防护的缺口:host 填 169.254.169.254 或 127.0.0.1 能直接连上,
|
||
// 而 smtp.NewClient 握手失败时会把对端返回的那一行包进错误、经 last_error
|
||
// 由投递历史接口回显,构成半盲读原语;「连接被拒 vs 超时」的耗时差异还能
|
||
// 用来探测端口。拨号阶段是最终生效点,也覆盖 DNS 重绑定。
|
||
func emailDial(ctx context.Context, addr, host string, implicitTLS bool) (*smtp.Client, error) {
|
||
d := &net.Dialer{Timeout: emailDialTimeout, Control: blockInternalDial}
|
||
var conn net.Conn
|
||
var err error
|
||
if implicitTLS {
|
||
conn, err = tls.DialWithDialer(d, "tcp", addr, &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12})
|
||
} else {
|
||
conn, err = d.DialContext(ctx, "tcp", addr)
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("连接 SMTP 服务器失败:%w", err)
|
||
}
|
||
_ = conn.SetDeadline(time.Now().Add(emailSessionTimeout))
|
||
client, err := smtp.NewClient(conn, host)
|
||
if err != nil {
|
||
conn.Close()
|
||
return nil, fmt.Errorf("SMTP 握手失败:%w", err)
|
||
}
|
||
return client, nil
|
||
}
|
||
|
||
// smtpStageError 按 SMTP 应答码把某个阶段的失败分成「可重试」与「永久失败」。
|
||
//
|
||
// 为什么必须区分:SMTP 的 4xx 与 5xx 语义完全不同——
|
||
// - 4xx(450 灰名单、451 本地错误、452 存储不足)是**临时**拒绝,
|
||
// 正规做法是稍后重试;尤其是灰名单,几乎每次首次投递都会遇到。
|
||
// - 5xx(550 用户不存在、553 地址非法)是永久拒绝,重试没有意义。
|
||
//
|
||
// 若一律判永久失败,一个启用灰名单的邮件服务器会让**每一条**推送都在第一次
|
||
// 尝试后落入 failed——而这类失败恰恰是自动重试最该发挥作用的场景。
|
||
// 应答码取错误文本的前三位数字;取不到码时按可重试处理(宁可多试一次,
|
||
// 也不要因为解析不出就把可能的瞬时故障判死)。
|
||
func smtpStageError(what string, err error) error {
|
||
code := smtpReplyCode(err.Error())
|
||
if code >= 500 && code < 600 {
|
||
return Permanent(fmt.Errorf("%s: %w", what, err))
|
||
}
|
||
return fmt.Errorf("%s: %w", what, err)
|
||
}
|
||
|
||
// smtpReplyCode 从 SMTP 错误文本里取前导的三位应答码,取不到返回 0。
|
||
// net/smtp 不导出错误码字段,只能从文本里取;格式为「450 4.7.1 ...」。
|
||
func smtpReplyCode(text string) int {
|
||
if len(text) < 3 {
|
||
return 0
|
||
}
|
||
n, err := strconv.Atoi(text[:3])
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
return n
|
||
}
|
||
|
||
// buildEmailMessage 组装完整的 RFC 5322 邮件。
|
||
//
|
||
// 正文用 base64 编码有两个原因:一是 SMTP 规定单行不超过 1000 字节,而 HTML
|
||
// 正文(尤其汇总邮件)很容易出现超长行;二是 base64 天然不会出现以 "." 开头
|
||
// 的行,省去 SMTP 点号转义的麻烦。
|
||
func buildEmailMessage(from string, to []string, m Message) (string, error) {
|
||
var b strings.Builder
|
||
fmt.Fprintf(&b, "From: %s\r\n", from)
|
||
fmt.Fprintf(&b, "To: %s\r\n", strings.Join(to, ", "))
|
||
// 中文主题必须做 RFC 2047 编码,否则会被客户端显示成乱码。
|
||
fmt.Fprintf(&b, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", htmlTitle(m)))
|
||
b.WriteString("MIME-Version: 1.0\r\n")
|
||
b.WriteString("Content-Type: text/html; charset=\"UTF-8\"\r\n")
|
||
b.WriteString("Content-Transfer-Encoding: base64\r\n")
|
||
// 邮件没有长度硬上限,因此不截断正文。
|
||
b.WriteString("\r\n")
|
||
encoded := base64.StdEncoding.EncodeToString([]byte(htmlBody(m, 0)))
|
||
// base64 按 76 字符折行,符合 RFC 2045。
|
||
for len(encoded) > 76 {
|
||
b.WriteString(encoded[:76] + "\r\n")
|
||
encoded = encoded[76:]
|
||
}
|
||
b.WriteString(encoded + "\r\n")
|
||
return b.String(), nil
|
||
}
|