diff options
author | Runxi Yu <me@runxiyu.org> | 2025-09-14 22:28:12 +0800 |
---|---|---|
committer | Runxi Yu <me@runxiyu.org> | 2025-09-14 22:28:12 +0800 |
commit | 01e4fd482ebcc827d3c76c00910529abbf666454 (patch) | |
tree | 300156b866864a07a0ed7680e6572ae97c20e325 /forged/internal/unsorted/smtp_relay.go | |
parent | Update dependencies (diff) | |
download | forge-01e4fd482ebcc827d3c76c00910529abbf666454.tar.gz forge-01e4fd482ebcc827d3c76c00910529abbf666454.tar.zst forge-01e4fd482ebcc827d3c76c00910529abbf666454.zip |
Add basic mailing listspre-refactor
Diffstat (limited to 'forged/internal/unsorted/smtp_relay.go')
-rw-r--r-- | forged/internal/unsorted/smtp_relay.go | 185 |
1 files changed, 185 insertions, 0 deletions
diff --git a/forged/internal/unsorted/smtp_relay.go b/forged/internal/unsorted/smtp_relay.go new file mode 100644 index 0000000..b90c8bd --- /dev/null +++ b/forged/internal/unsorted/smtp_relay.go @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org> + +package unsorted + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "log/slog" + "net" + stdsmtp "net/smtp" + "time" + + "github.com/emersion/go-sasl" + "github.com/emersion/go-smtp" +) + +// relayMailingListMessage connects to the configured SMTP relay and sends the +// raw message to all subscribers of the given list. The message is written verbatim +// from this point on and there is no modification of any headers or whatever, +func (s *Server) relayMailingListMessage(ctx context.Context, listID int, envelopeFrom string, raw []byte) error { + rows, err := s.database.Query(ctx, `SELECT email FROM mailing_list_subscribers WHERE list_id = $1`, listID) + if err != nil { + return err + } + defer rows.Close() + var recipients []string + for rows.Next() { + var email string + if err = rows.Scan(&email); err != nil { + return err + } + recipients = append(recipients, email) + } + if err = rows.Err(); err != nil { + return err + } + if len(recipients) == 0 { + slog.Info("mailing list has no subscribers", "list_id", listID) + return nil + } + + netw := s.config.SMTP.Net + if netw == "" { + netw = "tcp" + } + if s.config.SMTP.Addr == "" { + return errors.New("smtp relay addr not configured") + } + helloName := s.config.SMTP.HelloName + if helloName == "" { + helloName = s.config.LMTP.Domain + } + transport := s.config.SMTP.Transport + if transport == "" { + transport = "plain" + } + + switch transport { + case "plain", "tls": + d := net.Dialer{Timeout: 30 * time.Second} + var conn net.Conn + var err error + if transport == "tls" { + tlsCfg := &tls.Config{ServerName: hostFromAddr(s.config.SMTP.Addr), InsecureSkipVerify: s.config.SMTP.TLSInsecure} + conn, err = tls.DialWithDialer(&d, netw, s.config.SMTP.Addr, tlsCfg) + } else { + conn, err = d.DialContext(ctx, netw, s.config.SMTP.Addr) + } + if err != nil { + return fmt.Errorf("dial smtp: %w", err) + } + defer conn.Close() + + c := smtp.NewClient(conn) + defer c.Close() + + if err := c.Hello(helloName); err != nil { + return fmt.Errorf("smtp hello: %w", err) + } + + if s.config.SMTP.Username != "" { + mech := sasl.NewPlainClient("", s.config.SMTP.Username, s.config.SMTP.Password) + if err := c.Auth(mech); err != nil { + return fmt.Errorf("smtp auth: %w", err) + } + } + + if err := c.Mail(envelopeFrom, &smtp.MailOptions{}); err != nil { + return fmt.Errorf("smtp mail from: %w", err) + } + for _, rcpt := range recipients { + if err := c.Rcpt(rcpt, &smtp.RcptOptions{}); err != nil { + return fmt.Errorf("smtp rcpt %s: %w", rcpt, err) + } + } + wc, err := c.Data() + if err != nil { + return fmt.Errorf("smtp data: %w", err) + } + if _, err := wc.Write(raw); err != nil { + _ = wc.Close() + return fmt.Errorf("smtp write: %w", err) + } + if err := wc.Close(); err != nil { + return fmt.Errorf("smtp data close: %w", err) + } + if err := c.Quit(); err != nil { + return fmt.Errorf("smtp quit: %w", err) + } + return nil + case "starttls": + d := net.Dialer{Timeout: 30 * time.Second} + conn, err := d.DialContext(ctx, netw, s.config.SMTP.Addr) + if err != nil { + return fmt.Errorf("dial smtp: %w", err) + } + defer conn.Close() + + host := hostFromAddr(s.config.SMTP.Addr) + c, err := stdsmtp.NewClient(conn, host) + if err != nil { + return fmt.Errorf("smtp new client: %w", err) + } + defer c.Close() + + if err := c.Hello(helloName); err != nil { + return fmt.Errorf("smtp hello: %w", err) + } + if ok, _ := c.Extension("STARTTLS"); !ok { + return errors.New("smtp server does not support STARTTLS") + } + tlsCfg := &tls.Config{ServerName: host, InsecureSkipVerify: s.config.SMTP.TLSInsecure} // #nosec G402 + if err := c.StartTLS(tlsCfg); err != nil { + return fmt.Errorf("starttls: %w", err) + } + + // seems like ehlo is required after starttls + if err := c.Hello(helloName); err != nil { + return fmt.Errorf("smtp hello (post-starttls): %w", err) + } + + if s.config.SMTP.Username != "" { + auth := stdsmtp.PlainAuth("", s.config.SMTP.Username, s.config.SMTP.Password, host) + if err := c.Auth(auth); err != nil { + return fmt.Errorf("smtp auth: %w", err) + } + } + if err := c.Mail(envelopeFrom); err != nil { + return fmt.Errorf("smtp mail from: %w", err) + } + for _, rcpt := range recipients { + if err := c.Rcpt(rcpt); err != nil { + return fmt.Errorf("smtp rcpt %s: %w", rcpt, err) + } + } + wc, err := c.Data() + if err != nil { + return fmt.Errorf("smtp data: %w", err) + } + if _, err := wc.Write(raw); err != nil { + _ = wc.Close() + return fmt.Errorf("smtp write: %w", err) + } + if err := wc.Close(); err != nil { + return fmt.Errorf("smtp data close: %w", err) + } + if err := c.Quit(); err != nil { + return fmt.Errorf("smtp quit: %w", err) + } + return nil + default: + return fmt.Errorf("unknown smtp transport: %q", transport) + } +} + +func hostFromAddr(addr string) string { + host, _, err := net.SplitHostPort(addr) + if err != nil || host == "" { + return addr + } + return host +} |