// 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 }