// Package mail sends the gateway's few emails — a password reset link — by // SMTP, when the cluster's settings say where through. package mail import ( "context" "crypto/tls" "errors" "fmt" "net" "net/smtp" "strings" "time" "gitea.mynodes.xyz/ilker/llmgw/internal/secrets" ) // Config is where mail goes out. type Config struct { // SMTP is the relay, host:port: 587 with STARTTLS, 465 with TLS from the // start, 25 as the relay allows. Empty sends nothing. SMTP string `koanf:"smtp"` // From is the sender, llmgw@example.com or "llmgw ". From string `koanf:"from"` // Username and PasswordFile log in to the relay, when it wants that. // The password is a secret reference: a file, env:NAME or vault:path. Username string `koanf:"username"` PasswordFile string `koanf:"password_file"` // BaseURL is the gateway's public address, which links in mail point // at: https://llm.example.com. Never taken from a request, whose Host a // sender chooses and could point a reset link at their own site. BaseURL string `koanf:"base_url"` } // Enabled is whether mail can be sent. func (c Config) Enabled() bool { return c.SMTP != "" && c.From != "" && c.BaseURL != "" } // Validate refuses a half-set configuration. func (c Config) Validate() error { if c.SMTP == "" && c.From == "" && c.BaseURL == "" { return nil } if _, _, err := net.SplitHostPort(c.SMTP); err != nil { return fmt.Errorf("mail.smtp %q: host:port, like smtp.example.com:587", c.SMTP) } if c.From == "" || !strings.Contains(c.From, "@") { return errors.New("mail.from: the sender's address") } if !strings.HasPrefix(c.BaseURL, "https://") && !strings.HasPrefix(c.BaseURL, "http://") { return fmt.Errorf("mail.base_url %q: the gateway's public address, https://…", c.BaseURL) } return nil } // Send sends one plain-text message. func (c Config) Send(ctx context.Context, to, subject, body string) error { if !c.Enabled() { return errors.New("no mail relay is set (mail.smtp, mail.from, mail.base_url)") } host, port, _ := net.SplitHostPort(c.SMTP) ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() d := net.Dialer{} var conn net.Conn var err error if port == "465" { conn, err = (&tls.Dialer{NetDialer: &d, Config: &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}}).DialContext(ctx, "tcp", c.SMTP) } else { conn, err = d.DialContext(ctx, "tcp", c.SMTP) } if err != nil { return fmt.Errorf("reaching %s: %w", c.SMTP, err) } if dl, ok := ctx.Deadline(); ok { _ = conn.SetDeadline(dl) } cl, err := smtp.NewClient(conn, host) if err != nil { _ = conn.Close() return err } defer cl.Close() if ok, _ := cl.Extension("STARTTLS"); ok && port != "465" { if err := cl.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil { return fmt.Errorf("STARTTLS with %s: %w", c.SMTP, err) } } if c.Username != "" { pass, err := secrets.ReadString(ctx, c.PasswordFile) if err != nil { return fmt.Errorf("mail.password_file: %w", err) } if err := cl.Auth(smtp.PlainAuth("", c.Username, pass, host)); err != nil { return fmt.Errorf("logging in to %s: %w", c.SMTP, err) } } if err := cl.Mail(address(c.From)); err != nil { return err } if err := cl.Rcpt(to); err != nil { return err } w, err := cl.Data() if err != nil { return err } msg := "From: " + c.From + "\r\nTo: " + to + "\r\nSubject: " + subject + "\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\nDate: " + time.Now().UTC().Format(time.RFC1123Z) + "\r\n\r\n" + strings.ReplaceAll(body, "\n", "\r\n") if _, err := w.Write([]byte(msg)); err != nil { return err } if err := w.Close(); err != nil { return err } return cl.Quit() } // address is the bare address in "Name ". func address(from string) string { if i, j := strings.LastIndexByte(from, '<'), strings.LastIndexByte(from, '>'); i >= 0 && j > i { return from[i+1 : j] } return strings.TrimSpace(from) }