inboxer/src/internal/auth/smtp.go
cclohmar 01a58156a1 Consolidate all secrets into config.yaml — remove .env entirely
All configuration (including secrets) now lives in a single file:
bin/config.yaml. The separate .env file has been eliminated.

Changes:
- config.go: Added SMTPSettings struct + AI.APIKey to Config; removed
  godotenv import, Environment struct, and all os.Getenv() calls
- config.yaml: Added smtp section (host/port/username/password) and
  ai.api_key field with placeholder values
- main.go: Reads SMTP and API key from cfg instead of env
- smtp.go: Changed Port field from string to int
- otp_test.go: Updated Port values to int
- .env.example: Deleted (all config is in config.yaml)
- .gitignore: Removed .env.example; kept .env for safety
- go.mod/go.sum: Removed github.com/joho/godotenv dependency
- install.sh: No longer creates .env or uses EnvironmentFile;
  warns about placeholder values in config.yaml instead
2026-04-23 20:06:16 +00:00

179 lines
No EOL
4.8 KiB
Go

package auth
import (
"crypto/tls"
"fmt"
"net"
"net/smtp"
"strings"
"time"
)
// SMTPConfig holds SMTP server configuration
type SMTPConfig struct {
Host string
Port int
Username string
Password string
From string
}
// SMTPSender sends emails via SMTP
type SMTPSender struct {
config SMTPConfig
}
// NewSMTPSender creates a new SMTP sender with the given configuration
func NewSMTPSender(config SMTPConfig) *SMTPSender {
return &SMTPSender{config: config}
}
// heloHostname returns the hostname to use in HELO/EHLO.
// Using the SMTP server host is generally safe; alternatively we could
// use the sender's domain extracted from the From address.
func heloHostname(from, smtpHost string) string {
// Try to extract domain from the From address
if at := strings.LastIndex(from, "@"); at >= 0 {
domain := from[at+1:]
// Only use it if it looks plausible (contains a dot)
if strings.Contains(domain, ".") {
return domain
}
}
// Fallback to the SMTP host's domain (strip port if present)
if host, _, err := net.SplitHostPort(smtpHost); err == nil {
return host
}
return smtpHost
}
// sendMail is a custom replacement for smtp.SendMail that allows
// setting the HELO/EHLO hostname explicitly. Some SMTP servers
// reject messages when the HELO doesn't match a valid domain.
func (s *SMTPSender) sendMail(to []string, msg []byte) error {
addr := fmt.Sprintf("%s:%d", s.config.Host, s.config.Port)
helo := heloHostname(s.config.From, s.config.Host)
// Connect to the SMTP server
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
if err != nil {
return fmt.Errorf("failed to connect to SMTP server: %w", err)
}
defer conn.Close()
// Create SMTP client
client, err := smtp.NewClient(conn, s.config.Host)
if err != nil {
return fmt.Errorf("failed to create SMTP client: %w", err)
}
defer client.Close()
// Send HELO/EHLO with the proper hostname
if err := client.Hello(helo); err != nil {
return fmt.Errorf("failed to send HELO: %w", err)
}
// Upgrade to TLS if supported (required for port 587 STARTTLS)
// StartTLS internally re-sends EHLO per RFC 3207 after upgrading
if ok, _ := client.Extension("STARTTLS"); ok {
tlsConfig := &tls.Config{
ServerName: s.config.Host,
}
if err := client.StartTLS(tlsConfig); err != nil {
return fmt.Errorf("STARTTLS upgrade failed: %w", err)
}
}
// Authenticate
auth := smtp.PlainAuth("", s.config.Username, s.config.Password, s.config.Host)
if err := client.Auth(auth); err != nil {
return fmt.Errorf("SMTP authentication failed: %w", err)
}
// Set sender
if err := client.Mail(s.config.From); err != nil {
return fmt.Errorf("failed to set sender: %w", err)
}
// Set recipients
for _, recipient := range to {
if err := client.Rcpt(recipient); err != nil {
return fmt.Errorf("failed to set recipient %s: %w", recipient, err)
}
}
// Send message body
w, err := client.Data()
if err != nil {
return fmt.Errorf("failed to start data transfer: %w", err)
}
if _, err := w.Write(msg); err != nil {
return fmt.Errorf("failed to write message body: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("failed to close message body: %w", err)
}
return client.Quit()
}
// SendOTP sends an OTP email to the recipient
func (s *SMTPSender) SendOTP(to, otp string) error {
subject := "Your inBOXER Login Code"
body := fmt.Sprintf(`Your one-time password for inBOXER is: %s
This code will expire in 10 minutes.
If you didn't request this code, please ignore this email.`, otp)
msg := []byte(fmt.Sprintf("From: %s\r\n"+
"To: %s\r\n"+
"Subject: %s\r\n"+
"\r\n"+
"%s\r\n", s.config.From, to, subject, body))
return s.sendMail([]string{to}, msg)
}
// SendWelcome sends a welcome email after successful registration
func (s *SMTPSender) SendWelcome(to string) error {
subject := "Welcome to inBOXER"
body := `Welcome to inBOXER!
Your email account has been successfully set up. You can now log in to the dashboard to configure your email settings and start organizing your inbox.
Thank you for using inBOXER!`
msg := []byte(fmt.Sprintf("From: %s\r\n"+
"To: %s\r\n"+
"Subject: %s\r\n"+
"\r\n"+
"%s\r\n", s.config.From, to, subject, body))
return s.sendMail([]string{to}, msg)
}
// ValidateConfig checks if SMTP configuration is valid
func (s *SMTPConfig) ValidateConfig() error {
var errors []string
if s.Host == "" {
errors = append(errors, "SMTP host is required")
}
if s.Port == 0 {
errors = append(errors, "SMTP port is required")
}
if s.Username == "" {
errors = append(errors, "SMTP username is required")
}
if s.Password == "" {
errors = append(errors, "SMTP password is required")
}
if s.From == "" {
errors = append(errors, "From address is required")
}
if len(errors) > 0 {
return fmt.Errorf("invalid SMTP configuration: %s", strings.Join(errors, ", "))
}
return nil
}