Repository: gogs/gogs
File: internal/email/message.go (Go)

Code before:
	toHeaders := msg.header["To"]
	if len(toHeaders) == 0 {
		return errors.New("missing To header")
	}

	parsedAddrs, err := mail.ParseAddressList(strings.Join(toHeaders, ","))
	if err != nil {
		return errors.Wrap(err, "parse To addresses")
	}

	to := make([]string, 0, len(parsedAddrs))
	for _, a := range parsedAddrs {
		to = append(to, a.Address)
	}

	return sender.Send(addr.Address, to, msg)
}

func processMailQueue() {
	sender := &smtpSender{}
	for msg := range mailQueue {
		log.Trace("New e-mail sending request %s: %s", msg.getHeader("To"), msg.info)
		if err := sendMessage(sender, msg); err != nil {
			log.Error("Failed to send emails %s: %s - %v", msg.getHeader("To"), msg.info, err)
		} else {
			log.Trace("E-mails sent %s: %s", msg.getHeader("To"), msg.info)
		}
		msg.confirmChan <- struct{}{}
	}
}

var mailQueue chan *message

// NewContext initializes settings for mailer.
func NewContext() {
	// Need to check if mailQueue is nil because in during reinstall (user had installed
	// before but switched install lock off), this function will be called again
	// while mail queue is already processing tasks, and produces a race condition.
	if !conf.Email.Enabled || mailQueue != nil {
		return
	}

	mailQueue = make(chan *message, 1000)
	go processMailQueue()
}

// Send puts new message object into mail queue.
// It returns without confirmation (mail processed asynchronously) in normal cases,
// but waits/blocks under hook mode to make sure mail has been sent.
func Send(msg *message) {
	if !conf.Email.Enabled {

Review comment (suggestion):
The log format strings use `%s` with `msg.getHeader("To")`, but `getHeader` returns a `[]string`, so the logs will print `%!s([]string=...)` instead of the recipient list. Use `%v` or join the slice into a string (e.g., `strings.Join(..., ", ")`) for `Trace`/`Error` logging here.

Code after:
package email

import (
	"crypto/tls"
	"net"
	"strconv"
	"strings"

	"github.com/cockroachdb/errors"
	"github.com/inbucket/html2text"
	gomail "github.com/wneessen/go-mail"
	log "unknwon.dev/clog/v2"

	"gogs.io/gogs/internal/conf"
)

type message struct {
	info        string
	msg         *gomail.Msg
	confirmChan chan struct{}
}

func newMessageFrom(to []string, from, subject, htmlBody string) (*message, error) {
	log.Trace("NewMessageFrom (htmlBody):\n%s", htmlBody)

	m := gomail.NewMsg()
	if err := m.From(from); err != nil {
		return nil, errors.Wrapf(err, "set From address %q", from)
	}
	if err := m.To(to...); err != nil {
		return nil, errors.Wrap(err, "set To addresses")
	}
	m.Subject(conf.Email.SubjectPrefix + subject)
	m.SetDate()

	if conf.Email.UsePlainText || conf.Email.AddPlainTextAlt {
		plainBody, err := html2text.FromString(htmlBody)
		if err != nil {
			return nil, errors.Wrap(err, "convert HTML to plain text")
		}
		if conf.Email.UsePlainText {
			m.SetBodyString(gomail.TypeTextPlain, plainBody)
		} else {
			m.SetBodyString(gomail.TypeTextPlain, plainBody)
			m.AddAlternativeString(gomail.TypeTextHTML, htmlBody)
		}
	} else {
		m.SetBodyString(gomail.TypeTextHTML, htmlBody)
	}

	return &message{
		msg:         m,
		confirmChan: make(chan struct{}),
	}, nil
}

func newMessage(to []string, subject, body string) (*message, error) {
	return newMessageFrom(to, conf.Email.From, subject, body)
}

func newSMTPClient() (*gomail.Client, error) {
	opts := conf.Email

	host, portStr, err := net.SplitHostPort(opts.Host)
	if err != nil {
		return nil, err
	}
	port, err := strconv.Atoi(portStr)
	if err != nil {
		return nil, err
	}

	clientOpts := []gomail.Option{
		gomail.WithPort(port),
	}

	if port == 465 {
		clientOpts = append(clientOpts, gomail.WithSSL())
	} else {
		clientOpts = append(clientOpts, gomail.WithTLSPolicy(gomail.TLSOpportunistic))
	}

	if opts.HELOHostname != "" {
		clientOpts = append(clientOpts, gomail.WithHELO(opts.HELOHostname))
	}

	tlsconfig := &tls.Config{
		InsecureSkipVerify: opts.SkipVerify,
		ServerName:         host,
	}
	if opts.UseCertificate {
		cert, err := tls.LoadX509KeyPair(opts.CertFile, opts.KeyFile)
		if err != nil {
			return nil, err
		}
		tlsconfig.Certificates = []tls.Certificate{cert}
	}
	clientOpts = append(clientOpts, gomail.WithTLSConfig(tlsconfig))

	if len(opts.User) > 0 {
		clientOpts = append(clientOpts,
			gomail.WithSMTPAuth(gomail.SMTPAuthAutoDiscover),
			gomail.WithUsername(opts.User),
			gomail.WithPassword(opts.Password),
		)
	}

	return gomail.NewClient(host, clientOpts...)
}

func sendMessage(msg *message) error {
	client, err := newSMTPClient()
	if err != nil {
		return err
	}
	return client.DialAndSend(msg.msg)
}

func processMailQueue() {
	for msg := range mailQueue {
		to := strings.Join(msg.msg.GetToString(), ", ")
		log.Trace("New e-mail sending request %s: %s", to, msg.info)
		if err := sendMessage(msg); err != nil {
			log.Error("Failed to send emails %s: %s - %v", to, msg.info, err)
		} else {
			log.Trace("E-mails sent %s: %s", to, msg.info)
		}
		msg.confirmChan <- struct{}{}
	}
}

var mailQueue chan *message

// NewContext initializes settings for mailer.
func NewContext() {
	// Need to check if mailQueue is nil because in during reinstall (user had installed
	// before but switched install lock off), this function will be called again
	// while mail queue is already processing tasks, and produces a race condition.
	if !conf.Email.Enabled || mailQueue != nil {
		return
	}

	mailQueue = make(chan *message, 1000)
	go processMailQueue()
}

// send puts new message object into mail queue.
// It returns without confirmation (mail processed asynchronously) in normal cases,
// but waits/blocks under hook mode to make sure mail has been sent.
func send(msg *message) {
	if !conf.Email.Enabled {
		return
	}

	mailQueue <- msg

	if conf.HookMode {
		<-msg.confirmChan
		return
	}

	go func() {
		<-msg.confirmChan
	}()
}