spark-server/internal/infrastructure/smtp/mailer.go
2026-09-22 17:07:26 +08:00

141 lines
3.7 KiB
Go

// Package smtp sends OTP mail via Aliyun DirectMail SMTP.
package smtp
import (
"bytes"
"context"
"crypto/tls"
"fmt"
"net"
"net/smtp"
"strings"
"time"
smsentity "spark-server/internal/module/sms/entity"
"spark-server/internal/pkg/errs"
"spark-server/internal/pkg/i18n"
)
// Options configures the DirectMail SMTP relay.
type Options struct {
Host string
Port int
Username string
Password string
From string
}
// Mailer implements smslogic.EmailSender.
type Mailer struct {
host string
addr string
username string
password string
from string
}
func New(opts Options) *Mailer {
host := strings.TrimSpace(opts.Host)
port := opts.Port
if port <= 0 {
port = 465
}
from := strings.TrimSpace(opts.From)
if from == "" {
from = strings.TrimSpace(opts.Username)
}
return &Mailer{
host: host,
addr: fmt.Sprintf("%s:%d", host, port),
username: strings.TrimSpace(opts.Username),
password: opts.Password,
from: from,
}
}
func (m *Mailer) Ready() bool {
return m != nil && m.host != "" && m.username != "" && m.password != ""
}
func (m *Mailer) SendOTP(ctx context.Context, to string, purpose smsentity.Purpose, code, lang string) error {
if m == nil || !m.Ready() {
return errs.ErrAvailable
}
to = strings.TrimSpace(to)
if to == "" {
return errs.ErrInvalidArgument
}
subject, body := mailCopy(purpose, code, i18n.Parse(lang))
msg := buildMessage(m.from, to, subject, body)
return m.send(ctx, to, msg)
}
func (m *Mailer) send(ctx context.Context, to string, msg []byte) error {
dialer := &net.Dialer{Timeout: 15 * time.Second}
conn, err := tls.DialWithDialer(dialer, "tcp", m.addr, &tls.Config{ServerName: m.host, MinVersion: tls.VersionTLS12})
if err != nil {
return errs.ErrOperatFail.WithError("smtp-dial", err)
}
defer conn.Close()
if err := conn.SetDeadline(deadline(ctx)); err != nil {
return errs.ErrOperatFail.WithError("smtp-deadline", err)
}
c, err := smtp.NewClient(conn, m.host)
if err != nil {
return errs.ErrOperatFail.WithError("smtp-client", err)
}
defer c.Close()
auth := smtp.PlainAuth("", m.username, m.password, m.host)
if err := c.Auth(auth); err != nil {
return errs.ErrOperatFail.WithError("smtp-auth", err)
}
if err := c.Mail(m.username); err != nil {
return errs.ErrOperatFail.WithError("smtp-mail-from", err)
}
if err := c.Rcpt(to); err != nil {
return errs.ErrOperatFail.WithError("smtp-rcpt", err)
}
w, err := c.Data()
if err != nil {
return errs.ErrOperatFail.WithError("smtp-data", err)
}
if _, err := w.Write(msg); err != nil {
return errs.ErrOperatFail.WithError("smtp-write", err)
}
if err := w.Close(); err != nil {
return errs.ErrOperatFail.WithError("smtp-data-close", err)
}
return c.Quit()
}
func deadline(ctx context.Context) time.Time {
if d, ok := ctx.Deadline(); ok {
return d
}
return time.Now().Add(30 * time.Second)
}
func buildMessage(from, to, subject, body string) []byte {
var buf bytes.Buffer
buf.WriteString("From: " + from + "\r\n")
buf.WriteString("To: " + to + "\r\n")
buf.WriteString("Subject: " + subject + "\r\n")
buf.WriteString("MIME-Version: 1.0\r\n")
buf.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
buf.WriteString("\r\n")
buf.WriteString(body)
return buf.Bytes()
}
func mailCopy(purpose smsentity.Purpose, code string, lang i18n.Lang) (subject, body string) {
switch purpose {
case smsentity.PurposeAccountDelete:
subject = i18n.T(lang, i18n.KeySMSEmailSubjectDelete, nil)
body = i18n.T(lang, i18n.KeySMSEmailBodyDelete, map[string]string{"code": code})
default:
subject = i18n.T(lang, i18n.KeySMSEmailSubjectLogin, nil)
body = i18n.T(lang, i18n.KeySMSEmailBodyLogin, map[string]string{"code": code})
}
return subject, body
}