From c1decadb590c4d79d92bcc4df7772119a5c91244 Mon Sep 17 00:00:00 2001 From: Gab Virebent Date: Sat, 22 Aug 2026 20:36:56 +0200 Subject: Initial Aegis Usenet client release --- internal/smtpclient/client.go | 139 +++++++++++++++++++++++++++++++++++++ internal/smtpclient/client_test.go | 17 +++++ 2 files changed, 156 insertions(+) create mode 100644 internal/smtpclient/client.go create mode 100644 internal/smtpclient/client_test.go (limited to 'internal/smtpclient') diff --git a/internal/smtpclient/client.go b/internal/smtpclient/client.go new file mode 100644 index 0000000..e294323 --- /dev/null +++ b/internal/smtpclient/client.go @@ -0,0 +1,139 @@ +// Package smtpclient sends classic RFC 5322 messages to mail2news gateways. +package smtpclient + +import ( + "crypto/tls" + "errors" + "fmt" + "net" + "net/mail" + "net/smtp" + "strings" + "time" + + "golang.org/x/net/proxy" +) + +type Config struct { + Host string + Port string + Mode string + Username string + Password string + InsecureSkipVerify bool + ProxyType string + ProxyAddress string + Timeout time.Duration +} + +func Send(cfg Config, from string, recipients []string, message []byte) error { + if err := validate(cfg, from, recipients); err != nil { + return err + } + timeout := cfg.Timeout + if timeout <= 0 { + timeout = 30 * time.Second + } + target := net.JoinHostPort(cfg.Host, cfg.Port) + dialer := &net.Dialer{Timeout: timeout, KeepAlive: 30 * time.Second} + var conn net.Conn + var err error + if strings.EqualFold(cfg.ProxyType, "SOCKS5") { + socks, proxyErr := proxy.SOCKS5("tcp", cfg.ProxyAddress, nil, dialer) + if proxyErr != nil { + return fmt.Errorf("configure SMTP SOCKS5 proxy: %w", proxyErr) + } + conn, err = socks.Dial("tcp", target) + } else { + conn, err = dialer.Dial("tcp", target) + } + if err != nil { + return fmt.Errorf("connect SMTP server: %w", err) + } + defer conn.Close() + tlsConfig := &tls.Config{ServerName: cfg.Host, MinVersion: tls.VersionTLS12, InsecureSkipVerify: cfg.InsecureSkipVerify} // #nosec G402, explicit user opt-in + if strings.EqualFold(cfg.Mode, "TLS") { + tlsConn := tls.Client(conn, tlsConfig) + if err := tlsConn.Handshake(); err != nil { + return fmt.Errorf("SMTP TLS handshake: %w", err) + } + conn = tlsConn + } + client, err := smtp.NewClient(conn, cfg.Host) + if err != nil { + return fmt.Errorf("initialize SMTP client: %w", err) + } + defer client.Close() + if strings.EqualFold(cfg.Mode, "STARTTLS") { + if ok, _ := client.Extension("STARTTLS"); !ok { + return errors.New("SMTP server does not advertise STARTTLS") + } + if err := client.StartTLS(tlsConfig); err != nil { + return fmt.Errorf("SMTP STARTTLS: %w", err) + } + } + if cfg.Username != "" { + state, ok := client.TLSConnectionState() + if !ok || !state.HandshakeComplete { + return errors.New("SMTP authentication requires TLS") + } + if err := client.Auth(smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)); err != nil { + return fmt.Errorf("SMTP authentication: %w", err) + } + } + if err := client.Mail(from); err != nil { + return fmt.Errorf("SMTP MAIL FROM: %w", err) + } + for _, recipient := range recipients { + if err := client.Rcpt(recipient); err != nil { + return fmt.Errorf("SMTP RCPT TO: %w", err) + } + } + writer, err := client.Data() + if err != nil { + return fmt.Errorf("SMTP DATA: %w", err) + } + if _, err := writer.Write(message); err != nil { + _ = writer.Close() + return fmt.Errorf("write SMTP message: %w", err) + } + if err := writer.Close(); err != nil { + return fmt.Errorf("finish SMTP message: %w", err) + } + return client.Quit() +} + +func validate(cfg Config, from string, recipients []string) error { + if strings.TrimSpace(cfg.Host) == "" || strings.TrimSpace(cfg.Port) == "" { + return errors.New("SMTP host and port are required") + } + if cfg.Mode != "" && cfg.Mode != "TLS" && cfg.Mode != "STARTTLS" { + return errors.New("SMTP mode must be cleartext, TLS or STARTTLS") + } + if cfg.ProxyType != "DIRECT" && cfg.ProxyType != "SOCKS5" { + return errors.New("SMTP proxy type must be DIRECT or SOCKS5") + } + if strings.HasSuffix(strings.ToLower(cfg.Host), ".onion") && cfg.ProxyType != "SOCKS5" { + return errors.New(".onion SMTP server requires SOCKS5") + } + if cfg.ProxyType == "SOCKS5" { + if _, _, err := net.SplitHostPort(cfg.ProxyAddress); err != nil { + return fmt.Errorf("invalid SMTP SOCKS5 address: %w", err) + } + } + if cfg.Username != "" && cfg.Mode == "" { + return errors.New("SMTP authentication requires TLS or STARTTLS") + } + if _, err := mail.ParseAddress(from); err != nil { + return fmt.Errorf("invalid SMTP sender: %w", err) + } + if len(recipients) == 0 { + return errors.New("at least one SMTP recipient is required") + } + for _, recipient := range recipients { + if _, err := mail.ParseAddress(recipient); err != nil { + return fmt.Errorf("invalid SMTP recipient: %w", err) + } + } + return nil +} diff --git a/internal/smtpclient/client_test.go b/internal/smtpclient/client_test.go new file mode 100644 index 0000000..6572f9a --- /dev/null +++ b/internal/smtpclient/client_test.go @@ -0,0 +1,17 @@ +package smtpclient + +import "testing" + +func TestValidateOnionRequiresProxy(t *testing.T) { + err := validate(Config{Host: "mail.example.onion", Port: "465", Mode: "TLS", ProxyType: "DIRECT"}, "a@example.org", []string{"news@example.org"}) + if err == nil { + t.Fatal("expected onion proxy validation error") + } +} + +func TestValidateAllowsCleartextOnion(t *testing.T) { + err := validate(Config{Host: "mail.example.onion", Port: "25", Mode: "", ProxyType: "SOCKS5", ProxyAddress: "127.0.0.1:9050"}, "a@example.org", []string{"news@example.org"}) + if err != nil { + t.Fatalf("cleartext onion SMTP should be allowed: %v", err) + } +} -- cgit v1.2.3