aboutsummaryrefslogtreecommitdiff
path: root/forged/internal/unsorted/smtp_relay.go
diff options
context:
space:
mode:
authorRunxi Yu <me@runxiyu.org>2025-09-14 22:28:12 +0800
committerRunxi Yu <me@runxiyu.org>2025-09-14 22:28:12 +0800
commit01e4fd482ebcc827d3c76c00910529abbf666454 (patch)
tree300156b866864a07a0ed7680e6572ae97c20e325 /forged/internal/unsorted/smtp_relay.go
parentUpdate dependencies (diff)
downloadforge-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.go185
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
+}