fix: support SMTP LOGIN auth and implicit TLS for port 465 (#243)

Drew Smirnoff created

Change summary

sender/sender.go | 91 +++++++++++++++++++++++++++++++++++++++----------
1 file changed, 72 insertions(+), 19 deletions(-)

Detailed changes

sender/sender.go 🔗

@@ -23,6 +23,31 @@ import (
 	"go.mozilla.org/pkcs7"
 )
 
+// loginAuth implements the SMTP LOGIN authentication mechanism.
+// Some SMTP servers (e.g. Mailo) only support LOGIN and not PLAIN.
+type loginAuth struct {
+	username, password string
+}
+
+func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
+	return "LOGIN", nil, nil
+}
+
+func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
+	if !more {
+		return nil, nil
+	}
+	prompt := strings.TrimSpace(string(fromServer))
+	switch strings.ToLower(prompt) {
+	case "username:":
+		return []byte(a.username), nil
+	case "password:":
+		return []byte(a.password), nil
+	default:
+		return nil, fmt.Errorf("unexpected LOGIN prompt: %s", prompt)
+	}
+}
+
 // generateMessageID creates a unique Message-ID header.
 func generateMessageID(from string) string {
 	buf := make([]byte, 16)
@@ -42,7 +67,8 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody
 		return fmt.Errorf("unsupported or missing service_provider: %s", account.ServiceProvider)
 	}
 
-	auth := smtp.PlainAuth("", account.Email, account.Password, smtpServer)
+	plainAuth := smtp.PlainAuth("", account.Email, account.Password, smtpServer)
+	loginAuthFallback := &loginAuth{username: account.Email, password: account.Password}
 
 	fromHeader := account.FetchEmail
 	if account.Name != "" {
@@ -325,37 +351,64 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody
 
 	addr := fmt.Sprintf("%s:%d", smtpServer, smtpPort)
 
-	// Custom SMTP dialer to support skipping TLS verification for Proton Bridge
-	c, err := smtp.Dial(addr)
-	if err != nil {
-		return err
+	tlsConfig := &tls.Config{
+		ServerName:         smtpServer,
+		InsecureSkipVerify: account.Insecure,
 	}
-	defer c.Close()
 
-	if err = c.Hello("localhost"); err != nil {
-		return err
-	}
+	var c *smtp.Client
 
-	// Trigger STARTTLS if supported
-	if ok, _ := c.Extension("STARTTLS"); ok {
-		tlsConfig := &tls.Config{
-			ServerName:         smtpServer,
-			InsecureSkipVerify: account.Insecure,
+	// Port 465 uses implicit TLS (the connection starts with TLS).
+	// All other ports use plain TCP with optional STARTTLS upgrade.
+	if smtpPort == 465 {
+		conn, err := tls.Dial("tcp", addr, tlsConfig)
+		if err != nil {
+			return err
 		}
-		if err = c.StartTLS(tlsConfig); err != nil {
+		c, err = smtp.NewClient(conn, smtpServer)
+		if err != nil {
+			conn.Close()
 			return err
 		}
+	} else {
+		var err error
+		c, err = smtp.Dial(addr)
+		if err != nil {
+			return err
+		}
+	}
+	defer c.Close()
+
+	if err = c.Hello("localhost"); err != nil {
+		return err
 	}
 
-	// Authenticate
-	if auth != nil {
-		if ok, _ := c.Extension("AUTH"); ok {
-			if err = c.Auth(auth); err != nil {
+	// Trigger STARTTLS if supported (not needed for implicit TLS on port 465)
+	if smtpPort != 465 {
+		if ok, _ := c.Extension("STARTTLS"); ok {
+			if err = c.StartTLS(tlsConfig); err != nil {
 				return err
 			}
 		}
 	}
 
+	// Authenticate using the best available mechanism.
+	// c.Extension("AUTH") returns the list of supported mechanisms.
+	if ok, mechs := c.Extension("AUTH"); ok {
+		mechList := strings.ToUpper(mechs)
+		if strings.Contains(mechList, "PLAIN") {
+			err = c.Auth(plainAuth)
+		} else if strings.Contains(mechList, "LOGIN") {
+			err = c.Auth(loginAuthFallback)
+		} else {
+			// Fall back to PLAIN and let the server decide
+			err = c.Auth(plainAuth)
+		}
+		if err != nil {
+			return err
+		}
+	}
+
 	// Send Envelope
 	if err = c.Mail(account.Email); err != nil {
 		return err