170 lines
5.5 KiB
Go
170 lines
5.5 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/mail"
|
|
"net/textproto"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type PasswordResetSender interface {
|
|
SendPasswordReset(ctx context.Context, recipient, resetURL string) error
|
|
}
|
|
|
|
type SMTPConfig struct {
|
|
Host string
|
|
Port int
|
|
Username string
|
|
Password string
|
|
From string
|
|
TLSMode string
|
|
ServerName string
|
|
}
|
|
|
|
type smtpPasswordResetSender struct {
|
|
config SMTPConfig
|
|
from *mail.Address
|
|
}
|
|
|
|
func NewSMTPPasswordResetSender(config SMTPConfig) (PasswordResetSender, error) {
|
|
config.Host = strings.TrimSpace(config.Host)
|
|
config.Username = strings.TrimSpace(config.Username)
|
|
config.From = strings.TrimSpace(config.From)
|
|
config.TLSMode = strings.ToLower(strings.TrimSpace(config.TLSMode))
|
|
config.ServerName = strings.TrimSpace(config.ServerName)
|
|
if config.Host == "" || config.From == "" {
|
|
return nil, errors.New("SMTP host and sender are required")
|
|
}
|
|
if config.Port <= 0 || config.Port > 65535 {
|
|
return nil, errors.New("SMTP port must be between 1 and 65535")
|
|
}
|
|
if config.TLSMode == "" {
|
|
config.TLSMode = "starttls"
|
|
}
|
|
if config.TLSMode != "starttls" && config.TLSMode != "tls" && config.TLSMode != "none" {
|
|
return nil, errors.New("SMTP TLS mode must be starttls, tls or none")
|
|
}
|
|
if config.TLSMode == "none" && (config.Username != "" || config.Password != "") {
|
|
return nil, errors.New("SMTP authentication requires TLS")
|
|
}
|
|
from, err := mail.ParseAddress(config.From)
|
|
if err != nil || strings.ContainsAny(config.From, "\r\n") {
|
|
return nil, errors.New("SMTP sender is invalid")
|
|
}
|
|
if config.ServerName == "" {
|
|
config.ServerName = config.Host
|
|
}
|
|
return &smtpPasswordResetSender{config: config, from: from}, nil
|
|
}
|
|
|
|
func (s *smtpPasswordResetSender) SendPasswordReset(ctx context.Context, recipient, resetURL string) error {
|
|
to, err := mail.ParseAddress(strings.TrimSpace(recipient))
|
|
if err != nil || strings.ContainsAny(recipient, "\r\n") {
|
|
return errors.New("password reset recipient is invalid")
|
|
}
|
|
if !strings.HasPrefix(resetURL, "https://") && !strings.HasPrefix(resetURL, "http://") {
|
|
return errors.New("password reset URL is invalid")
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
|
defer cancel()
|
|
|
|
address := net.JoinHostPort(s.config.Host, strconv.Itoa(s.config.Port))
|
|
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
|
var conn net.Conn
|
|
if s.config.TLSMode == "tls" {
|
|
conn, err = tls.DialWithDialer(dialer, "tcp", address, &tls.Config{MinVersion: tls.VersionTLS12, ServerName: s.config.ServerName})
|
|
if err != nil {
|
|
return fmt.Errorf("connect to SMTP over TLS: %w", err)
|
|
}
|
|
} else {
|
|
conn, err = dialer.DialContext(ctx, "tcp", address)
|
|
if err != nil {
|
|
return fmt.Errorf("connect to SMTP: %w", err)
|
|
}
|
|
}
|
|
defer conn.Close()
|
|
if deadline, ok := ctx.Deadline(); ok {
|
|
_ = conn.SetDeadline(deadline)
|
|
}
|
|
text := textproto.NewConn(conn)
|
|
if _, _, err := text.ReadResponse(220); err != nil {
|
|
return fmt.Errorf("read SMTP greeting: %w", err)
|
|
}
|
|
ehlo, err := smtpCommand(text, 250, "EHLO rmm.local")
|
|
if err != nil {
|
|
return fmt.Errorf("SMTP EHLO: %w", err)
|
|
}
|
|
if s.config.TLSMode == "starttls" {
|
|
if !strings.Contains(strings.ToUpper(ehlo), "STARTTLS") {
|
|
return errors.New("SMTP server does not support STARTTLS")
|
|
}
|
|
if _, err := smtpCommand(text, 220, "STARTTLS"); err != nil {
|
|
return fmt.Errorf("start SMTP TLS: %w", err)
|
|
}
|
|
tlsConn := tls.Client(conn, &tls.Config{MinVersion: tls.VersionTLS12, ServerName: s.config.ServerName})
|
|
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
|
return fmt.Errorf("negotiate SMTP TLS: %w", err)
|
|
}
|
|
conn = tlsConn
|
|
text = textproto.NewConn(conn)
|
|
if _, err := smtpCommand(text, 250, "EHLO rmm.local"); err != nil {
|
|
return fmt.Errorf("SMTP EHLO after TLS: %w", err)
|
|
}
|
|
}
|
|
if s.config.Username != "" {
|
|
credentials := base64.StdEncoding.EncodeToString([]byte("\x00" + s.config.Username + "\x00" + s.config.Password))
|
|
if _, err := smtpCommand(text, 235, "AUTH PLAIN %s", credentials); err != nil {
|
|
return fmt.Errorf("authenticate to SMTP: %w", err)
|
|
}
|
|
}
|
|
if _, err := smtpCommand(text, 250, "MAIL FROM:<%s>", s.from.Address); err != nil {
|
|
return fmt.Errorf("set SMTP sender: %w", err)
|
|
}
|
|
if _, err := smtpCommand(text, 250, "RCPT TO:<%s>", to.Address); err != nil {
|
|
return fmt.Errorf("set SMTP recipient: %w", err)
|
|
}
|
|
if _, err := smtpCommand(text, 354, "DATA"); err != nil {
|
|
return fmt.Errorf("open SMTP message: %w", err)
|
|
}
|
|
w := text.DotWriter()
|
|
message := "From: " + s.from.String() + "\r\n" +
|
|
"To: " + to.String() + "\r\n" +
|
|
"Subject: OpenWrt RMM password reset\r\n" +
|
|
"MIME-Version: 1.0\r\n" +
|
|
"Content-Type: text/plain; charset=UTF-8\r\n" +
|
|
"Content-Transfer-Encoding: 8bit\r\n\r\n" +
|
|
"A password reset was requested for your OpenWrt RMM account.\r\n\r\n" +
|
|
resetURL + "\r\n\r\n" +
|
|
"The link expires in 30 minutes. If you did not request this, ignore this message.\r\n"
|
|
if _, err := w.Write([]byte(message)); err != nil {
|
|
w.Close()
|
|
return fmt.Errorf("write SMTP message: %w", err)
|
|
}
|
|
if err := w.Close(); err != nil {
|
|
return fmt.Errorf("send SMTP message: %w", err)
|
|
}
|
|
if _, _, err := text.ReadResponse(250); err != nil {
|
|
return fmt.Errorf("confirm SMTP message: %w", err)
|
|
}
|
|
_, _ = smtpCommand(text, 221, "QUIT")
|
|
return nil
|
|
}
|
|
|
|
func smtpCommand(conn *textproto.Conn, expectedCode int, format string, args ...any) (string, error) {
|
|
id, err := conn.Cmd(format, args...)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
conn.StartResponse(id)
|
|
defer conn.EndResponse(id)
|
|
_, message, err := conn.ReadResponse(expectedCode)
|
|
return message, err
|
|
}
|