package auth import ( "context" "crypto/tls" "database/sql" "errors" "fmt" "log" "net" "net/mail" "net/smtp" "os" "strings" "time" ) type EmailConfig struct { Host, Port, Username, Password, From string } func EmailConfigFromEnv() (*EmailConfig, error) { c := &EmailConfig{Host: os.Getenv("SMTP_HOST"), Port: os.Getenv("SMTP_PORT"), Username: os.Getenv("SMTP_USERNAME"), Password: os.Getenv("SMTP_PASSWORD"), From: os.Getenv("SMTP_FROM")} if c.Host == "" && c.Port == "" && c.Username == "" && c.Password == "" && c.From == "" { return nil, nil } address, err := mail.ParseAddress(c.From) if c.Host == "" || c.Port != "465" || c.Username == "" || c.Password == "" || err != nil || address.Address != c.From || strings.ContainsAny(c.Host+c.Username+c.From, "\r\n") { return nil, errors.New("invalid SMTP configuration; TLS port 465 and a plain sender address are required") } return c, nil } func (c *EmailConfig) send(recipient, subject, body string) error { if strings.ContainsAny(recipient+subject, "\r\n") { return errors.New("invalid mail headers") } connection, err := tls.DialWithDialer(&net.Dialer{Timeout: 10 * time.Second}, "tcp", net.JoinHostPort(c.Host, c.Port), &tls.Config{ServerName: c.Host, MinVersion: tls.VersionTLS12}) if err != nil { return err } defer connection.Close() _ = connection.SetDeadline(time.Now().Add(30 * time.Second)) client, err := smtp.NewClient(connection, c.Host) if err != nil { return err } defer client.Close() if err = client.Auth(smtp.PlainAuth("", c.Username, c.Password, c.Host)); err != nil { return err } if err = client.Mail(c.From); err != nil { return err } if err = client.Rcpt(recipient); err != nil { return err } writer, err := client.Data() if err != nil { return err } message := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s\r\n", c.From, recipient, subject, body) if _, err = writer.Write([]byte(message)); err != nil { return err } if err = writer.Close(); err != nil { return err } return client.Quit() } func (c *EmailConfig) Run(ctx context.Context, db *sql.DB) { if c == nil { return } ticker := time.NewTicker(3 * time.Second) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: if err := c.deliver(ctx, db); err != nil && !errors.Is(err, sql.ErrNoRows) { log.Printf("email delivery failed (%T)", err) } } } } func (c *EmailConfig) deliver(ctx context.Context, db *sql.DB) error { var id, lease, recipient, subject, body string var attempts int err := db.QueryRowContext(ctx, ` UPDATE email_outbox SET lease_id=gen_random_uuid(),lease_until=now()+interval '2 minutes',attempts=attempts+1 WHERE id=(SELECT id FROM email_outbox WHERE sent_at IS NULL AND expires_at>now() AND attempts<10 AND available_at<=now() AND (lease_until IS NULL OR lease_until<=now()) ORDER BY available_at,id FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING id::text,lease_id::text,recipient,subject,body,attempts `).Scan(&id, &lease, &recipient, &subject, &body, &attempts) if err != nil { return err } sendErr := c.send(recipient, subject, body) if sendErr == nil { _, err = db.ExecContext(ctx, "UPDATE email_outbox SET sent_at=now(),body='',lease_until=NULL,lease_id=NULL WHERE id=$1 AND lease_id=$2", id, lease) return err } delay := 30 * (1 << min(attempts, 7)) _, err = db.ExecContext(ctx, "UPDATE email_outbox SET available_at=now()+$3*interval '1 second',lease_until=NULL,lease_id=NULL WHERE id=$1 AND lease_id=$2", id, lease, delay) if err != nil { return err } return sendErr }