summaryrefslogtreecommitdiffstats
path: root/internal/smtpclient/client.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/smtpclient/client.go')
-rw-r--r--internal/smtpclient/client.go141
1 files changed, 109 insertions, 32 deletions
diff --git a/internal/smtpclient/client.go b/internal/smtpclient/client.go
index d4ee686..a0abc06 100644
--- a/internal/smtpclient/client.go
+++ b/internal/smtpclient/client.go
@@ -4,11 +4,13 @@ import (
"bufio"
"context"
"crypto/tls"
+ "encoding/base64"
"fmt"
"io"
"net"
"net/textproto"
"strings"
+ "sync"
"time"
)
@@ -19,6 +21,8 @@ type Config struct {
Port int
Recipient string
EnvelopeFrom string
+ Username string
+ Password string
HELO string
TLSServerName string
RequireTLS bool
@@ -30,6 +34,7 @@ type Config struct {
type Client struct {
cfg Config
dial DialContextFunc
+ mu sync.Mutex
}
type Message struct {
@@ -45,22 +50,90 @@ 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
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ conn, err := c.connect(ctx)
+ if err != nil {
+ return err
+ }
+ defer conn.close()
+ session := conn.session
+
+ 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
+}
+
+// Check verifies the complete SOCKS/SMTP/TLS/AUTH path without issuing a mail
+// transaction or sending message data.
+func (c *Client) Check(ctx context.Context) error {
+ if c.cfg.DryRun {
+ return nil
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ conn, err := c.connect(ctx)
+ if err != nil {
+ return err
+ }
+ defer conn.close()
+ _, _, _ = conn.session.cmd(221, "QUIT\r\n")
+ return nil
+}
+
+type smtpConnection struct {
+ conn net.Conn
+ session *session
+ cancel context.CancelFunc
+}
+
+func (c *smtpConnection) close() {
+ c.cancel()
+ _ = c.conn.Close()
+}
+
+func (c *Client) connect(ctx context.Context) (*smtpConnection, error) {
+ dial := c.dial
+ if dial == nil {
+ 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)
+ conn, err := dial(ctx, "tcp", addr)
if err != nil {
- return fmt.Errorf("dial smtp: %w", err)
+ cancel()
+ return nil, fmt.Errorf("dial smtp: %w", err)
+ }
+ fail := func(err error) (*smtpConnection, error) {
+ cancel()
+ _ = conn.Close()
+ return nil, err
}
- defer conn.Close()
if deadline, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(deadline)
}
@@ -70,58 +143,51 @@ func (c *Client) Send(ctx context.Context, msg Message) error {
MinVersion: tls.VersionTLS12,
})
if err := tlsConn.HandshakeContext(ctx); err != nil {
- return fmt.Errorf("implicit tls handshake: %w", err)
+ return fail(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)
+ return fail(fmt.Errorf("smtp greeting: %w", err))
}
if err := session.ehlo(c.cfg.HELO); err != nil {
- return err
+ return fail(err)
}
if c.cfg.RequireTLS && !c.cfg.ImplicitTLS {
if _, _, err := session.cmd(220, "STARTTLS\r\n"); err != nil {
- return fmt.Errorf("starttls: %w", err)
+ return fail(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)
+ return fail(fmt.Errorf("tls handshake: %w", err))
}
+ conn = tlsConn
session = newSession(tlsConn)
if err := session.ehlo(c.cfg.HELO); err != nil {
- return err
+ return fail(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)
+ if c.cfg.Username != "" || c.cfg.Password != "" {
+ if c.cfg.Username == "" || c.cfg.Password == "" {
+ return fail(fmt.Errorf("smtp authentication credentials are incomplete"))
+ }
+ if !c.cfg.RequireTLS && !c.cfg.ImplicitTLS {
+ return fail(fmt.Errorf("smtp authentication requires TLS"))
+ }
+ if err := session.authPlain(c.cfg.Username, c.cfg.Password); err != nil {
+ return fail(err)
+ }
}
- _, _, _ = session.cmd(221, "QUIT\r\n")
- return nil
+
+ return &smtpConnection{conn: conn, session: session, cancel: cancel}, nil
}
type session struct {
@@ -151,6 +217,17 @@ func (s *session) ehlo(helo string) error {
return nil
}
+func (s *session) authPlain(username, password string) error {
+ if strings.ContainsRune(username, '\x00') || strings.ContainsRune(password, '\x00') {
+ return fmt.Errorf("smtp authentication credentials contain invalid data")
+ }
+ payload := base64.StdEncoding.EncodeToString([]byte("\x00" + username + "\x00" + password))
+ if _, _, err := s.cmd(235, "AUTH PLAIN %s\r\n", payload); err != nil {
+ return fmt.Errorf("smtp authentication rejected: %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