Files

119 lines
3.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package mail 提供邮件发送抽象。M1 提供基础直发(net/smtp + STARTTLS);
// M3 引入 QueuedMailermail_logs 队列 + 失败重试,见 queue.go),
// SMTP 未配置时退化为 LogMailer(仅打印,不阻断业务——OTP 邮件失败不阻断 CLI 通道)。
package mail
import (
"context"
"crypto/tls"
"encoding/base64"
"fmt"
"log/slog"
"net"
"net/smtp"
"strconv"
"strings"
"ws_usernode/internal/config"
)
// Mailer 邮件发送接口。
type Mailer interface {
// Send 发送一封纯文本邮件;失败返回错误,由调用方决定是否阻断。
Send(ctx context.Context, to, subject, body string) error
}
// LogMailer 在 SMTP 未配置时替代实现:把邮件内容打到日志。
// 生产部署必须配置 SMTP,此时日志不落邮件正文。
type LogMailer struct {
log *slog.Logger
}
// NewLogMailer 创建日志邮件实现。
func NewLogMailer(log *slog.Logger) *LogMailer {
return &LogMailer{log: log}
}
func (m *LogMailer) Send(_ context.Context, to, subject, body string) error {
m.log.Warn("mail: smtp 未配置,邮件内容仅写入日志",
"to", to, "subject", subject, "body", body)
return nil
}
// SMTPMailer 基于 net/smtp 的基础直发(STARTTLS + 可选 AUTH LOGIN/PLAIN)。
type SMTPMailer struct {
cfg config.SMTPConfig
}
// NewSMTPMailer 创建 SMTP 邮件实现。
func NewSMTPMailer(cfg config.SMTPConfig) *SMTPMailer {
return &SMTPMailer{cfg: cfg}
}
// New 按配置选择实现:SMTP host 为空时返回 LogMailer。
func New(cfg config.SMTPConfig, log *slog.Logger) Mailer {
if cfg.Host == "" {
return NewLogMailer(log)
}
return NewSMTPMailer(cfg)
}
func (m *SMTPMailer) Send(ctx context.Context, to, subject, body string) error {
addr := net.JoinHostPort(m.cfg.Host, strconv.Itoa(m.cfg.Port))
var d net.Dialer
conn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return fmt.Errorf("mail: dial %s: %w", addr, err)
}
defer conn.Close()
c, err := smtp.NewClient(conn, m.cfg.Host)
if err != nil {
return fmt.Errorf("mail: smtp client: %w", err)
}
defer c.Close()
if err := c.StartTLS(&tls.Config{ServerName: m.cfg.Host}); err != nil {
return fmt.Errorf("mail: starttls: %w", err)
}
if m.cfg.Username != "" {
if err := c.Auth(smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host)); err != nil {
return fmt.Errorf("mail: auth: %w", err)
}
}
if err := c.Mail(m.cfg.From); err != nil {
return fmt.Errorf("mail: mail from: %w", err)
}
if err := c.Rcpt(to); err != nil {
return fmt.Errorf("mail: rcpt: %w", err)
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("mail: data: %w", err)
}
msg := buildMessage(m.cfg.From, to, subject, body)
if _, err := w.Write([]byte(msg)); err != nil {
w.Close()
return fmt.Errorf("mail: write body: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("mail: close body: %w", err)
}
return c.Quit()
}
// buildMessage 构造 RFC 5322 消息体(UTF-8 主题 base64 编码,正文 UTF-8)。
func buildMessage(from, to, subject, body string) string {
var b strings.Builder
b.WriteString("From: " + from + "\r\n")
b.WriteString("To: " + to + "\r\n")
b.WriteString("Subject: =?UTF-8?B?" + base64.StdEncoding.EncodeToString([]byte(subject)) + "?=\r\n")
b.WriteString("MIME-Version: 1.0\r\n")
b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
b.WriteString("Content-Transfer-Encoding: 8bit\r\n")
b.WriteString("\r\n")
b.WriteString(body + "\r\n")
return b.String()
}