From cd58d593789c6facb1d05b7f255bc2c5f2e46010 Mon Sep 17 00:00:00 2001 From: Gab Virebent Date: Mon, 3 Aug 2026 17:13:50 +0200 Subject: Initial import: n2usenet HTTPS/Nym Usenet gateway --- internal/smtpclient/client.go | 208 +++++++++++++++++++++++++++++++++++++ internal/smtpclient/client_test.go | 93 +++++++++++++++++ 2 files changed, 301 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..d4ee686 --- /dev/null +++ b/internal/smtpclient/client.go @@ -0,0 +1,208 @@ +package smtpclient + +import ( + "bufio" + "context" + "crypto/tls" + "fmt" + "io" + "net" + "net/textproto" + "strings" + "time" +) + +type DialContextFunc func(ctx context.Context, network, address string) (net.Conn, error) + +type Config struct { + Host string + Port int + Recipient string + EnvelopeFrom string + HELO string + TLSServerName string + RequireTLS bool + ImplicitTLS bool + Timeout time.Duration + DryRun bool +} + +type Client struct { + cfg Config + dial DialContextFunc +} + +type Message struct { + EnvelopeFrom string + Raw string +} + +func New(cfg Config, dial DialContextFunc) *Client { + return &Client{cfg: cfg, dial: dial} +} + +func (c *Client) Send(ctx context.Context, msg Message) error { + if c.cfg.DryRun { + return nil + } + if c.dial == nil { + c.dial = (&net.Dialer{}).DialContext + } + timeout := c.cfg.Timeout + if timeout <= 0 { + timeout = 90 * time.Second + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + addr := net.JoinHostPort(c.cfg.Host, fmt.Sprintf("%d", c.cfg.Port)) + conn, err := c.dial(ctx, "tcp", addr) + if err != nil { + return fmt.Errorf("dial smtp: %w", err) + } + defer conn.Close() + if deadline, ok := ctx.Deadline(); ok { + _ = conn.SetDeadline(deadline) + } + if c.cfg.ImplicitTLS { + tlsConn := tls.Client(conn, &tls.Config{ + ServerName: c.cfg.TLSServerName, + MinVersion: tls.VersionTLS12, + }) + if err := tlsConn.HandshakeContext(ctx); err != nil { + return fmt.Errorf("implicit tls handshake: %w", err) + } + conn = tlsConn + } + + session := newSession(conn) + if _, _, err := session.read(220); err != nil { + return fmt.Errorf("smtp greeting: %w", err) + } + + if err := session.ehlo(c.cfg.HELO); err != nil { + return err + } + + if c.cfg.RequireTLS && !c.cfg.ImplicitTLS { + if _, _, err := session.cmd(220, "STARTTLS\r\n"); err != nil { + return fmt.Errorf("starttls: %w", err) + } + tlsConn := tls.Client(conn, &tls.Config{ + ServerName: c.cfg.TLSServerName, + MinVersion: tls.VersionTLS12, + }) + if err := tlsConn.HandshakeContext(ctx); err != nil { + return fmt.Errorf("tls handshake: %w", err) + } + session = newSession(tlsConn) + if err := session.ehlo(c.cfg.HELO); err != nil { + return err + } + } + + envelopeFrom := sanitizeEnvelope(c.cfg.EnvelopeFrom) + if envelopeFrom == "" { + envelopeFrom = sanitizeEnvelope(msg.EnvelopeFrom) + } + if _, _, err := session.cmd(250, "MAIL FROM:<%s>\r\n", envelopeFrom); err != nil { + return fmt.Errorf("mail from rejected: %w", err) + } + if _, _, err := session.cmd(250, "RCPT TO:<%s>\r\n", sanitizeEnvelope(c.cfg.Recipient)); err != nil { + return fmt.Errorf("rcpt to rejected: %w", err) + } + if _, _, err := session.cmd(354, "DATA\r\n"); err != nil { + return fmt.Errorf("data rejected: %w", err) + } + if err := writeSMTPData(session.w, msg.Raw); err != nil { + return err + } + if _, _, err := session.read(250); err != nil { + return fmt.Errorf("message rejected: %w", err) + } + _, _, _ = session.cmd(221, "QUIT\r\n") + return nil +} + +type session struct { + conn net.Conn + tp *textproto.Reader + w *bufio.Writer +} + +func newSession(conn net.Conn) *session { + reader := bufio.NewReader(conn) + return &session{ + conn: conn, + tp: textproto.NewReader(reader), + w: bufio.NewWriter(conn), + } +} + +func (s *session) ehlo(helo string) error { + if helo == "" { + helo = "n2usenet.local" + } + if _, _, err := s.cmd(250, "EHLO %s\r\n", sanitizeAtom(helo)); err != nil { + if _, _, heloErr := s.cmd(250, "HELO %s\r\n", sanitizeAtom(helo)); heloErr != nil { + return fmt.Errorf("ehlo failed: %w", err) + } + } + return nil +} + +func (s *session) cmd(expect int, format string, args ...any) (int, string, error) { + if _, err := fmt.Fprintf(s.w, format, args...); err != nil { + return 0, "", err + } + if err := s.w.Flush(); err != nil { + return 0, "", err + } + return s.read(expect) +} + +func (s *session) read(expect int) (int, string, error) { + code, msg, err := s.tp.ReadResponse(expect) + if err != nil { + return code, msg, err + } + return code, msg, nil +} + +func writeSMTPData(w *bufio.Writer, raw string) error { + raw = strings.ReplaceAll(raw, "\r\n", "\n") + raw = strings.ReplaceAll(raw, "\r", "\n") + for _, line := range strings.Split(raw, "\n") { + if strings.HasPrefix(line, ".") { + line = "." + line + } + if _, err := io.WriteString(w, line+"\r\n"); err != nil { + return fmt.Errorf("write smtp data: %w", err) + } + } + if _, err := io.WriteString(w, ".\r\n"); err != nil { + return fmt.Errorf("write smtp terminator: %w", err) + } + if err := w.Flush(); err != nil { + return fmt.Errorf("flush smtp data: %w", err) + } + return nil +} + +func sanitizeEnvelope(v string) string { + v = strings.TrimSpace(v) + v = strings.ReplaceAll(v, "\r", "") + v = strings.ReplaceAll(v, "\n", "") + v = strings.Trim(v, "<>") + return v +} + +func sanitizeAtom(v string) string { + v = strings.TrimSpace(v) + v = strings.ReplaceAll(v, "\r", "") + v = strings.ReplaceAll(v, "\n", "") + if v == "" { + return "n2usenet.local" + } + return v +} diff --git a/internal/smtpclient/client_test.go b/internal/smtpclient/client_test.go new file mode 100644 index 0000000..d9eec6d --- /dev/null +++ b/internal/smtpclient/client_test.go @@ -0,0 +1,93 @@ +package smtpclient + +import ( + "bufio" + "context" + "net" + "strings" + "testing" + "time" +) + +func TestSendUsesConfiguredEnvelopeFrom(t *testing.T) { + server, client := net.Pipe() + defer client.Close() + + commands := make(chan string, 32) + done := make(chan struct{}) + go func() { + defer close(done) + defer close(commands) + defer server.Close() + + r := bufio.NewReader(server) + w := bufio.NewWriter(server) + _, _ = w.WriteString("220 test\r\n") + _ = w.Flush() + + inData := false + for { + line, err := r.ReadString('\n') + if err != nil { + return + } + commands <- line + + switch { + case inData && line != ".\r\n": + continue + case strings.HasPrefix(line, "EHLO "): + _, _ = w.WriteString("250 test\r\n") + case strings.HasPrefix(line, "MAIL FROM:"): + _, _ = w.WriteString("250 ok\r\n") + case strings.HasPrefix(line, "RCPT TO:"): + _, _ = w.WriteString("250 ok\r\n") + case strings.HasPrefix(line, "DATA"): + inData = true + _, _ = w.WriteString("354 go ahead\r\n") + case line == ".\r\n": + inData = false + _, _ = w.WriteString("250 queued\r\n") + case strings.HasPrefix(line, "QUIT"): + _, _ = w.WriteString("221 bye\r\n") + _ = w.Flush() + return + default: + continue + } + _ = w.Flush() + } + }() + + mailer := New(Config{ + Host: "mail2news.tcpreset.net", + Port: 25, + Recipient: "mail2news@mail2news.tcpreset.net", + EnvelopeFrom: "n2usenet@virebent.art", + HELO: "n2usenet.virebent.art", + RequireTLS: false, + Timeout: 5 * time.Second, + }, func(ctx context.Context, network, address string) (net.Conn, error) { + return client, nil + }) + + if err := mailer.Send(context.Background(), Message{ + EnvelopeFrom: "user@example.invalid", + Raw: "From: User \r\nTo: mail2news@mail2news.tcpreset.net\r\nSubject: Test\r\n\r\nhello", + }); err != nil { + t.Fatalf("Send returned error: %v", err) + } + + var sawConfiguredFrom bool + for line := range commands { + if line == "MAIL FROM:\r\n" { + sawConfiguredFrom = true + break + } + } + <-done + + if !sawConfiguredFrom { + t.Fatal("configured envelope sender was not used") + } +} -- cgit v1.2.3