/forgot mails a link to set a new password when the cluster's mail settings say how - SMTP relay, sender, and the public base_url the link points at, never the request's Host - and says the same whether or not the address has an account. /reset sets it once, closing every session. Links are kept by their hash in password_resets, good for an hour, a newer one replacing an older. Without mail, /forgot says to ask an operator, and llmgw users reset-link makes the link to hand over. Single sign-on accounts get none. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
126 lines
3.9 KiB
Go
126 lines
3.9 KiB
Go
// 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 <llmgw@example.com>".
|
|
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 <a@b>".
|
|
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)
|
|
}
|