// Package mail sends outgoing email (verification codes and password // reset links) over SMTP with STARTTLS — the scheme smtp.gmail.com // requires on port 587. It's built on net/smtp + crypto/tls directly // rather than a third-party mailer library, matching the rest of // enkly's minimal-dependencies stance (see go.mod). package mailkit import ( "crypto/tls" "fmt" "net" "net/mail" "net/smtp" "strings" "time" ) // Config holds SMTP connection details, normally sourced from the // ENKLY_SMTP_* environment variables (see cmd/server/main.go and // .env.example). Host/Port default to Gmail's submission endpoint; // Username/Password should be a Gmail *App Password* // (https://myaccount.google.com/apppasswords), not the account's normal // login password — Gmail rejects plain-password SMTP auth for accounts // with 2FA, and discourages it even without 2FA. type Config struct { Host string // e.g. "smtp.gmail.com" Port string // e.g. "587" Username string // full Gmail address Password string // Gmail App Password // From is used for the message's From: header, and may be either a // bare address ("noreply@example.com") or a full RFC 5322 mailbox // with a display name ("enkly ") — Send picks // the bare address back out of either form for the SMTP envelope // (see envelopeAddress), since MAIL FROM must never be given a // display name. Defaults to Username if empty. From string } // Mailer sends email via a configured SMTP server. type Mailer struct { cfg Config } // New builds a Mailer from cfg, filling in Gmail's defaults for any // blank Host/Port so a minimal .env (just username + app password) // works out of the box. Every field is also trimmed of surrounding // whitespace, including a stray trailing "\r" — a real failure mode // when an operator's .env or docker-compose.yml was saved with CRLF // line endings and the value reaches os.Getenv un-normalized: an // embedded "\r" in an SMTP command line reads to the server as a // premature line break, and Gmail answers with a "555 Syntax error" // that gives no hint the cause was whitespace. func New(cfg Config) *Mailer { cfg.Host = strings.TrimSpace(cfg.Host) cfg.Port = strings.TrimSpace(cfg.Port) cfg.Username = strings.TrimSpace(cfg.Username) cfg.Password = strings.TrimSpace(cfg.Password) cfg.From = strings.TrimSpace(cfg.From) if cfg.Host == "" { cfg.Host = "smtp.gmail.com" } if cfg.Port == "" { cfg.Port = "587" } if cfg.From == "" { cfg.From = cfg.Username } return &Mailer{cfg: cfg} } // Configured reports whether enough SMTP settings are present to // actually send mail. main.go logs a warning instead of failing to // start when this is false, so the rest of the app (chat, RAG) keeps // working even if email hasn't been set up yet — signup will just fail // with a clear error until it is. func (m *Mailer) Configured() bool { return m.cfg.Username != "" && m.cfg.Password != "" } // Send delivers a plain-text email to a single recipient, using // STARTTLS on the configured host/port (Gmail requires this on 587; // plaintext SMTP is refused). Uses net/smtp's low-level Client instead // of smtp.SendMail because SendMail only supports implicit TLS (port // 465) or no TLS at all, not STARTTLS-then-auth. func (m *Mailer) Send(to, subject, body string) error { if !m.Configured() { return fmt.Errorf("mail: not configured (set ENKLY_SMTP_USERNAME / ENKLY_SMTP_PASSWORD)") } addr := net.JoinHostPort(m.cfg.Host, m.cfg.Port) conn, err := net.DialTimeout("tcp", addr, 10*time.Second) if err != nil { return fmt.Errorf("mail: dial %s: %w", addr, err) } defer conn.Close() client, err := smtp.NewClient(conn, m.cfg.Host) if err != nil { return fmt.Errorf("mail: smtp handshake: %w", err) } defer client.Close() if ok, _ := client.Extension("STARTTLS"); ok { tlsConfig := &tls.Config{ServerName: m.cfg.Host} if err := client.StartTLS(tlsConfig); err != nil { return fmt.Errorf("mail: starttls: %w", err) } } auth := smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host) if ok, _ := client.Extension("AUTH"); ok { if err := client.Auth(auth); err != nil { return fmt.Errorf("mail: auth: %w", err) } } if err := client.Mail(envelopeAddress(m.cfg.From)); err != nil { return fmt.Errorf("mail: MAIL FROM: %w", err) } if err := client.Rcpt(envelopeAddress(to)); err != nil { return fmt.Errorf("mail: RCPT TO: %w", err) } w, err := client.Data() if err != nil { return fmt.Errorf("mail: DATA: %w", err) } msg := buildMessage(m.cfg.From, to, subject, body) if _, err := w.Write([]byte(msg)); err != nil { return fmt.Errorf("mail: write body: %w", err) } if err := w.Close(); err != nil { return fmt.Errorf("mail: close body: %w", err) } return client.Quit() } // buildMessage assembles a minimal RFC 5322 message. Headers are kept // deliberately simple (plain text, no MIME multipart) since enkly only // ever sends short verification emails. func buildMessage(from, to, subject, body string) string { var b strings.Builder fmt.Fprintf(&b, "From: %s\r\n", from) fmt.Fprintf(&b, "To: %s\r\n", to) fmt.Fprintf(&b, "Subject: %s\r\n", subject) b.WriteString("MIME-Version: 1.0\r\n") b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n") b.WriteString("\r\n") b.WriteString(body) b.WriteString("\r\n") return b.String() } // envelopeAddress reduces addr to the bare mailbox SMTP's envelope // commands require (RFC 5321 §4.1.2 reverse-/forward-path: an address // only, never a display name or angle brackets). addr may already be // bare ("user@example.com") or a full RFC 5322 mailbox with a display // name ("enkly ") — either way client.Mail/client.Rcpt // wrap it in its own "<...>", so passing a mailbox that already // contains a display name and brackets there produces something like // "MAIL FROM:>", which Gmail rejects with // exactly "555 5.5.2 Syntax error, cannot decode response" — the bug // this function exists to prevent. If addr doesn't parse as an address // at all, it's returned unchanged so the SMTP server's own error (not a // swallowed one here) explains what's wrong with it. func envelopeAddress(addr string) string { if parsed, err := mail.ParseAddress(addr); err == nil { return parsed.Address } return addr }