This commit is contained in:
173
pkg/mailkit/mail.go
Normal file
173
pkg/mailkit/mail.go
Normal file
@@ -0,0 +1,173 @@
|
||||
// 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 <noreply@example.com>") — 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 <user@example.com>") — 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:<enkly <user@example.com>>", 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
|
||||
}
|
||||
99
pkg/mailkit/mail_test.go
Normal file
99
pkg/mailkit/mail_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package mailkit
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestEnvelopeAddress covers the exact bug this fixes: a configured
|
||||
// From address that includes a display name (a very natural thing to
|
||||
// set ENKLY_SMTP_FROM to, e.g. "enkly <noreply@example.com>") must not
|
||||
// be handed to MAIL FROM/RCPT TO as-is, or Gmail answers with "555
|
||||
// 5.5.2 Syntax error, cannot decode response" because the envelope
|
||||
// command only accepts a bare address (RFC 5321 §4.1.2).
|
||||
func TestEnvelopeAddress(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"user@example.com", "user@example.com"},
|
||||
{"enkly <user@example.com>", "user@example.com"},
|
||||
{" enkly <user@example.com> ", "user@example.com"},
|
||||
{"\"Enkly Notifications\" <user@example.com>", "user@example.com"},
|
||||
// Not parseable as an address at all: returned unchanged so the
|
||||
// SMTP server's own rejection explains the problem, rather than
|
||||
// this function silently swallowing it.
|
||||
{"not-an-address", "not-an-address"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := envelopeAddress(c.in); got != c.want {
|
||||
t.Errorf("envelopeAddress(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewTrimsConfig guards against the same class of bug from a
|
||||
// different angle: SMTP config values arriving from os.Getenv with
|
||||
// stray whitespace or an embedded "\r" (e.g. a CRLF-saved .env or
|
||||
// docker-compose.yml) must not reach the wire as part of an SMTP
|
||||
// command line, or the server sees a corrupted command.
|
||||
func TestNewTrimsConfig(t *testing.T) {
|
||||
m := New(Config{
|
||||
Host: " smtp.gmail.com\r\n",
|
||||
Port: " 587 ",
|
||||
Username: " user@example.com \r",
|
||||
Password: " app-password \r",
|
||||
From: " enkly <user@example.com> \r",
|
||||
})
|
||||
if m.cfg.Host != "smtp.gmail.com" {
|
||||
t.Errorf("Host = %q", m.cfg.Host)
|
||||
}
|
||||
if m.cfg.Port != "587" {
|
||||
t.Errorf("Port = %q", m.cfg.Port)
|
||||
}
|
||||
if m.cfg.Username != "user@example.com" {
|
||||
t.Errorf("Username = %q", m.cfg.Username)
|
||||
}
|
||||
if m.cfg.Password != "app-password" {
|
||||
t.Errorf("Password = %q", m.cfg.Password)
|
||||
}
|
||||
if m.cfg.From != "enkly <user@example.com>" {
|
||||
t.Errorf("From = %q", m.cfg.From)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewDefaults checks the Gmail host/port/from defaults still apply
|
||||
// after adding trimming, so a minimal config keeps working out of the
|
||||
// box.
|
||||
func TestNewDefaults(t *testing.T) {
|
||||
m := New(Config{Username: "user@example.com", Password: "app-password"})
|
||||
if m.cfg.Host != "smtp.gmail.com" {
|
||||
t.Errorf("default Host = %q", m.cfg.Host)
|
||||
}
|
||||
if m.cfg.Port != "587" {
|
||||
t.Errorf("default Port = %q", m.cfg.Port)
|
||||
}
|
||||
if m.cfg.From != "user@example.com" {
|
||||
t.Errorf("default From = %q", m.cfg.From)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigured(t *testing.T) {
|
||||
if New(Config{}).Configured() {
|
||||
t.Fatal("empty config must not report Configured")
|
||||
}
|
||||
if !New(Config{Username: "u", Password: "p"}).Configured() {
|
||||
t.Fatal("username+password must report Configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMessageShape(t *testing.T) {
|
||||
msg := buildMessage("enkly <from@example.com>", "to@example.com", "Subject line", "body text")
|
||||
for _, want := range []string{
|
||||
"From: enkly <from@example.com>\r\n",
|
||||
"To: to@example.com\r\n",
|
||||
"Subject: Subject line\r\n",
|
||||
"\r\nbody text\r\n",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("buildMessage output missing %q; got:\n%s", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user