package smtp import ( "bytes" "context" "crypto/tls" "encoding/base64" "errors" "fmt" "io" "mime" "net" "net/smtp" "os" "path/filepath" "strings" "time" "code.yun.ink/pkg/mailx/interfaces" ) const ( MaxAttachmentSize = 25 * 1024 * 1024 // 25MB DefaultTimeout = 30 * time.Second MaxRetries = 3 ) // 邮件发送的封装 // 1. 支持文本 // 2. 支持文件 type Smtp struct { interfaces.DefaultEmail auth smtp.Auth timeout time.Duration } func NewSmtp() *Smtp { smtp := &Smtp{ timeout: DefaultTimeout, } smtp.Options = interfaces.DefaultOptions() smtp.EmailType = interfaces.EmailTypeSmtp return smtp } func (l *Smtp) SetOption(ctx context.Context, opt ...interfaces.Option) error { for _, o := range opt { o(&l.Options) } if l.Options.Smtp == nil { return fmt.Errorf("SMTP configuration is required") } // 验证配置 if err := l.validateConfig(); err != nil { return fmt.Errorf("invalid SMTP config: %w", err) } // 初始化认证 l.auth = smtp.PlainAuth("", l.Options.Smtp.Username, l.Options.Smtp.Password, l.Options.Smtp.Host) // 安全日志输出 l.Options.Logger.Infof(ctx, "SMTP configured - Host:%s Port:%s Username:%s", l.Options.Smtp.Host, l.Options.Smtp.Port, l.Options.Smtp.Username) l.IsSet = true return nil } func (l *Smtp) Send(ctx context.Context, message interfaces.Message) error { if !l.IsSet { return fmt.Errorf("SMTP not initialized") } // 验证消息 if err := l.validateMessage(message); err != nil { return fmt.Errorf("invalid message: %w", err) } // 重试机制 var lastErr error for i := 0; i < MaxRetries; i++ { if i > 0 { l.Options.Logger.Infof(ctx, "Retrying email send, attempt %d/%d", i+1, MaxRetries) time.Sleep(time.Duration(i) * time.Second) } // 根据端口和SSL配置选择连接方式 switch l.Options.Smtp.Port { case "465": lastErr = l.sendWithTLS(ctx, message) case "587": lastErr = l.sendWithSTARTTLS(ctx, message) default: if l.Options.Smtp.IsSSL { lastErr = l.sendWithTLS(ctx, message) } else { lastErr = l.sendPlain(ctx, message) } } if lastErr == nil { l.Options.Logger.Infof(ctx, "Email sent successfully to %v", message.To) return nil } l.Options.Logger.Errorf(ctx, "Send attempt %d failed: %v", i+1, lastErr) } return fmt.Errorf("failed to send email after %d attempts: %w", MaxRetries, lastErr) } func (l *Smtp) sendPlain(ctx context.Context, message interfaces.Message) error { // .Auth() buffer := bytes.NewBuffer(nil) boundary := "YunBoundaryYun" Header := make(map[string]string) // Header["From"] = "BOP<" + message.Form + ">" if message.Form != "" { Header["From"] = message.Form } else { Header["From"] = l.Options.Smtp.Username } if len(message.To) > 0 { Header["To"] = l.formatEmailAddresses(message.To) } if len(message.Cc) > 0 { Header["Cc"] = l.formatEmailAddresses(message.Cc) } if len(message.Bcc) > 0 { Header["Bcc"] = l.formatEmailAddresses(message.Bcc) } Header["Subject"] = message.Subject Header["Content-Type"] = "multipart/mixed; charset=UTF-8; boundary=" + boundary Header["Date"] = time.Now().Format(time.RFC1123Z) Header["Reply-To"] = message.ReplyTo Header["X-Priority"] = "3" l.writeHeader(buffer, Header) body := "--" + boundary + "\r\n" // body += "Content-Type: text/plain; charset=UTF-8 \r\n" body += "Content-Type: text/html;charset=utf-8\r\n" body += "Content-Transfer-Encoding:quoted-printable\r\n\r\n" // body += "

huang

xin

\r\n" // body += "" + message.Body + "\r\n" body += message.Body + "\r\n" // body += "--" + boundary + "--\r\n\r\n" buffer.WriteString(body) for _, value := range message.Attachment { if err := l.writeAttachment(buffer, boundary, value, ctx); err != nil { l.Options.Logger.Errorf(ctx, "Failed to process attachment %s: %v", value.Name, err) continue } } buffer.WriteString("\r\n--" + boundary + "--\r\n") b := buffer.Bytes() // 合并所有收件人 allRecipients := append(append(message.To, message.Cc...), message.Bcc...) err := smtp.SendMail(l.Options.Smtp.Host+":"+l.Options.Smtp.Port, l.auth, l.Options.Smtp.Username, allRecipients, b) return err } func (l *Smtp) sendWithTLS(ctx context.Context, message interfaces.Message) error { // 构建完整邮件内容 buffer := bytes.NewBuffer(nil) boundary := "YunBoundaryYun" // 构建邮件头 headers := l.buildHeaders(message, boundary) l.writeHeader(buffer, headers) // 构建邮件体 l.writeBody(buffer, boundary, message) // 处理内嵌图片 for _, inlineImg := range message.InlineImage { if err := l.writeAttachment(buffer, boundary, inlineImg, ctx); err != nil { l.Options.Logger.Errorf(ctx, "Failed to process inline image %s: %v", inlineImg.Name, err) continue } } // 处理普通附件 for _, attachment := range message.Attachment { if err := l.writeAttachment(buffer, boundary, attachment, ctx); err != nil { l.Options.Logger.Errorf(ctx, "Failed to process attachment %s: %v", attachment.Name, err) continue } } buffer.WriteString("\r\n--" + boundary + "--\r\n") // 建立TLS连接 if l.Options.Smtp.Port == "" { l.Options.Smtp.Port = "465" } host := l.Options.Smtp.Host + ":" + l.Options.Smtp.Port conn, err := tls.DialWithDialer(&net.Dialer{Timeout: l.timeout}, "tcp", host, &tls.Config{ InsecureSkipVerify: false, ServerName: l.Options.Smtp.Host, MinVersion: tls.VersionTLS12, }) if err != nil { return fmt.Errorf("TLS connection failed: %w", err) } defer conn.Close() client, err := smtp.NewClient(conn, l.Options.Smtp.Host) if err != nil { return fmt.Errorf("SMTP client creation failed: %w", err) } defer client.Quit() if err := client.Auth(l.auth); err != nil { return fmt.Errorf("authentication failed: %w", err) } if err := client.Mail(l.Options.Smtp.Username); err != nil { return fmt.Errorf("set sender failed: %w", err) } // 添加所有收件人 allRecipients := append(append(message.To, message.Cc...), message.Bcc...) for _, addr := range allRecipients { if err := client.Rcpt(addr); err != nil { return fmt.Errorf("set recipient %s failed: %w", addr, err) } } w, err := client.Data() if err != nil { return fmt.Errorf("data command failed: %w", err) } if _, err := w.Write(buffer.Bytes()); err != nil { return fmt.Errorf("write message failed: %w", err) } return w.Close() } // STARTTLS方式发送(587端口) func (l *Smtp) sendWithSTARTTLS(ctx context.Context, message interfaces.Message) error { // 构建完整邮件内容 buffer := bytes.NewBuffer(nil) boundary := "YunBoundaryYun" // 构建邮件头 headers := l.buildHeaders(message, boundary) l.writeHeader(buffer, headers) // 构建邮件体 l.writeBody(buffer, boundary, message) // 处理内嵌图片 for _, inlineImg := range message.InlineImage { if err := l.writeAttachment(buffer, boundary, inlineImg, ctx); err != nil { l.Options.Logger.Errorf(ctx, "Failed to process inline image %s: %v", inlineImg.Name, err) continue } } // 处理普通附件 for _, attachment := range message.Attachment { if err := l.writeAttachment(buffer, boundary, attachment, ctx); err != nil { l.Options.Logger.Errorf(ctx, "Failed to process attachment %s: %v", attachment.Name, err) continue } } buffer.WriteString("\r\n--" + boundary + "--\r\n") // 建立普通TCP连接 if l.Options.Smtp.Port == "" { l.Options.Smtp.Port = "587" } host := l.Options.Smtp.Host + ":" + l.Options.Smtp.Port conn, err := net.DialTimeout("tcp", host, l.timeout) if err != nil { return fmt.Errorf("TCP connection failed: %w", err) } defer conn.Close() client, err := smtp.NewClient(conn, l.Options.Smtp.Host) if err != nil { return fmt.Errorf("SMTP client creation failed: %w", err) } defer client.Quit() // 启动TLS tlsConfig := &tls.Config{ InsecureSkipVerify: false, ServerName: l.Options.Smtp.Host, MinVersion: tls.VersionTLS12, } if err := client.StartTLS(tlsConfig); err != nil { return fmt.Errorf("STARTTLS failed: %w", err) } if err := client.Auth(l.auth); err != nil { return fmt.Errorf("authentication failed: %w", err) } if err := client.Mail(l.Options.Smtp.Username); err != nil { return fmt.Errorf("set sender failed: %w", err) } // 添加所有收件人 allRecipients := append(append(message.To, message.Cc...), message.Bcc...) for _, addr := range allRecipients { if err := client.Rcpt(addr); err != nil { return fmt.Errorf("set recipient %s failed: %w", addr, err) } } w, err := client.Data() if err != nil { return fmt.Errorf("data command failed: %w", err) } if _, err := w.Write(buffer.Bytes()); err != nil { return fmt.Errorf("write message failed: %w", err) } return w.Close() } // 验证配置 func (l *Smtp) validateConfig() error { if l.Options.Smtp.Host == "" { return errors.New("host is required") } if l.Options.Smtp.Username == "" { return errors.New("username is required") } if l.Options.Smtp.Password == "" { return errors.New("password is required") } return nil } // 验证消息 func (l *Smtp) validateMessage(message interfaces.Message) error { if len(message.To) == 0 { return errors.New("at least one recipient is required") } if message.Subject == "" { return errors.New("subject is required") } return nil } // 格式化邮件地址 func (l *Smtp) formatEmailAddresses(addresses []string) string { if len(addresses) == 0 { return "" } var parts []string for _, addr := range addresses { name := strings.Split(addr, "@")[0] parts = append(parts, fmt.Sprintf("%s<%s>", name, addr)) } return strings.Join(parts, ",") } // 构建邮件头 func (l *Smtp) buildHeaders(message interfaces.Message, boundary string) map[string]string { headers := make(map[string]string) if message.Form != "" { headers["From"] = message.Form } else { headers["From"] = l.Options.Smtp.Username } if len(message.To) > 0 { headers["To"] = l.formatEmailAddresses(message.To) } if len(message.Cc) > 0 { headers["Cc"] = l.formatEmailAddresses(message.Cc) } if len(message.Bcc) > 0 { headers["Bcc"] = l.formatEmailAddresses(message.Bcc) } headers["Subject"] = message.Subject headers["Date"] = time.Now().Format(time.RFC1123Z) headers["MIME-Version"] = "1.0" // 根据内容类型和附件情况选择Content-Type if len(message.InlineImage) > 0 { headers["Content-Type"] = "multipart/related; charset=UTF-8; boundary=" + boundary } else if len(message.Attachment) > 0 { headers["Content-Type"] = "multipart/mixed; charset=UTF-8; boundary=" + boundary } else { headers["Content-Type"] = "multipart/alternative; charset=UTF-8; boundary=" + boundary } if message.ReplyTo != "" { headers["Reply-To"] = message.ReplyTo } return headers } // 写入邮件头 func (l *Smtp) writeHeader(buffer *bytes.Buffer, headers map[string]string) { for key, value := range headers { if value != "" { buffer.WriteString(fmt.Sprintf("%s: %s\r\n", key, value)) } } buffer.WriteString("\r\n") } // 写入邮件体 func (l *Smtp) writeBody(buffer *bytes.Buffer, boundary string, message interfaces.Message) { // 检测内容类型 bodyType := message.BodyType if bodyType == interfaces.ContentTypeAuto { bodyType = l.detectContentType(message.Body) } // 如果有纯文本版本,创建 multipart/alternative if message.TextBody != "" && bodyType == interfaces.ContentTypeHTML { altBoundary := "alt-" + boundary buffer.WriteString(fmt.Sprintf("--%s\r\n", boundary)) buffer.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=%s\r\n\r\n", altBoundary)) // 纯文本版本 buffer.WriteString(fmt.Sprintf("--%s\r\n", altBoundary)) buffer.WriteString("Content-Type: text/plain; charset=utf-8\r\n") buffer.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n") buffer.WriteString(message.TextBody + "\r\n") // HTML版本 buffer.WriteString(fmt.Sprintf("--%s\r\n", altBoundary)) buffer.WriteString("Content-Type: text/html; charset=utf-8\r\n") buffer.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n") buffer.WriteString(message.Body + "\r\n") buffer.WriteString(fmt.Sprintf("--%s--\r\n", altBoundary)) } else { // 单一内容类型 buffer.WriteString(fmt.Sprintf("--%s\r\n", boundary)) if bodyType == interfaces.ContentTypeHTML { buffer.WriteString("Content-Type: text/html; charset=utf-8\r\n") } else { buffer.WriteString("Content-Type: text/plain; charset=utf-8\r\n") } buffer.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n") buffer.WriteString(message.Body + "\r\n") } } // 检测内容类型 func (l *Smtp) detectContentType(body string) interfaces.ContentType { if strings.Contains(body, "") || strings.Contains(body, "") || strings.Contains(body, "") || strings.Contains(body, "") || strings.Contains(body, "
") || strings.Contains(body, "

") || strings.Contains(body, "
") || strings.Contains(body, " 0 { data = attachment.Data fileName = attachment.Name } else { data, err = l.readAttachmentFile(attachment.Content) if err != nil { return err } fileName = filepath.Base(attachment.Content) } if attachment.Name != "" { fileName = attachment.Name } // 检测 MIME 类型 mimeType := attachment.ContentType if mimeType == "" { mimeType = mime.TypeByExtension(filepath.Ext(fileName)) if mimeType == "" { mimeType = "application/octet-stream" } } buffer.WriteString(fmt.Sprintf("--%s\r\n", boundary)) buffer.WriteString("Content-Transfer-Encoding: base64\r\n") buffer.WriteString(fmt.Sprintf("Content-Disposition: attachment; filename=%s\r\n", fileName)) buffer.WriteString(fmt.Sprintf("Content-Type: %s; name=%s\r\n\r\n", mimeType, fileName)) // base64编码 encoded := base64.StdEncoding.EncodeToString(data) for i := 0; i < len(encoded); i += 76 { end := i + 76 if end > len(encoded) { end = len(encoded) } buffer.WriteString(encoded[i:end] + "\r\n") } return nil } // 写入内嵌图片 func (l *Smtp) writeInlineImage(buffer *bytes.Buffer, boundary string, attachment interfaces.Attachment, ctx context.Context) error { var data []byte var err error var fileName string // 获取数据 if len(attachment.Data) > 0 { data = attachment.Data fileName = attachment.Name } else { data, err = l.readAttachmentFile(attachment.Content) if err != nil { return err } fileName = filepath.Base(attachment.Content) } if attachment.Name != "" { fileName = attachment.Name } // 检测 MIME 类型 mimeType := attachment.ContentType if mimeType == "" { mimeType = mime.TypeByExtension(filepath.Ext(fileName)) if mimeType == "" { mimeType = "image/jpeg" // 默认图片类型 } } // 生成 Content-ID cid := attachment.CID if cid == "" { cid = fmt.Sprintf("img_%d_%s", time.Now().UnixNano(), strings.ReplaceAll(fileName, ".", "_")) } buffer.WriteString(fmt.Sprintf("--%s\r\n", boundary)) buffer.WriteString("Content-Transfer-Encoding: base64\r\n") buffer.WriteString(fmt.Sprintf("Content-Disposition: inline; filename=%s\r\n", fileName)) buffer.WriteString(fmt.Sprintf("Content-Type: %s; name=%s\r\n", mimeType, fileName)) buffer.WriteString(fmt.Sprintf("Content-ID: <%s>\r\n\r\n", cid)) // base64编码 encoded := base64.StdEncoding.EncodeToString(data) for i := 0; i < len(encoded); i += 76 { end := i + 76 if end > len(encoded) { end = len(encoded) } buffer.WriteString(encoded[i:end] + "\r\n") } return nil } // 读取附件文件 func (l *Smtp) readAttachmentFile(fileName string) ([]byte, error) { stat, err := os.Stat(fileName) if err != nil { return nil, fmt.Errorf("file stat failed: %w", err) } if stat.Size() > MaxAttachmentSize { return nil, fmt.Errorf("attachment too large: %d bytes (max: %d)", stat.Size(), MaxAttachmentSize) } return os.ReadFile(fileName) } // 行包装器,用于base64编码时的换行 type lineWrapper struct { w io.Writer count int } func (lw *lineWrapper) Write(p []byte) (n int, err error) { for i, b := range p { if lw.count == 76 { if _, err := lw.w.Write([]byte("\r\n")); err != nil { return i, err } lw.count = 0 } if _, err := lw.w.Write([]byte{b}); err != nil { return i, err } lw.count++ } return len(p), nil }