package email import ( "context" "errors" "fmt" "net/smtp" "net/textproto" "strings" "time" ) // SMTPConfig configures an SMTPSender. Defaults match local Mailpit // (host=localhost, port=1025, no TLS, no auth). type SMTPConfig struct { Host string Port int Username string // empty means no AUTH Password string // empty means no AUTH UseTLS bool // STARTTLS — false for Mailpit local // Timeout bounds Send to avoid hanging forever on a stuck server. // Defaults to 10s when zero. Timeout time.Duration } // SMTPSender sends mail through an SMTP server. Configured by SMTPConfig // with sensible Mailpit-friendly defaults. type SMTPSender struct { cfg SMTPConfig } // NewSMTPSender returns a Sender backed by SMTP. The SMTPConfig is copied — // mutating the caller's struct after this call has no effect. func NewSMTPSender(cfg SMTPConfig) *SMTPSender { if cfg.Host == "" { cfg.Host = "localhost" } if cfg.Port == 0 { cfg.Port = 1025 } if cfg.Timeout == 0 { cfg.Timeout = 10 * time.Second } return &SMTPSender{cfg: cfg} } // Send delivers the message via SMTP. Returns an error if the message is // invalid (missing required fields), if connecting / authenticating fails, // or if the SMTP server rejects the envelope or data. func (s *SMTPSender) Send(ctx context.Context, msg Message) error { if err := validateMessage(msg); err != nil { return err } addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port) body := buildRFC5322(msg) var auth smtp.Auth if s.cfg.Username != "" { auth = smtp.PlainAuth("", s.cfg.Username, s.cfg.Password, s.cfg.Host) } // Run the SMTP exchange in a goroutine so we can honour the // context's cancellation independently of net/smtp's own timeouts. done := make(chan error, 1) go func() { done <- smtp.SendMail(addr, auth, msg.From, []string{msg.To}, body) }() select { case err := <-done: if err != nil { return fmt.Errorf("smtp send to %s: %w", msg.To, err) } return nil case <-ctx.Done(): return ctx.Err() case <-time.After(s.cfg.Timeout): return errors.New("smtp send: timeout") } } // validateMessage checks the minimum required fields for an outgoing email. func validateMessage(msg Message) error { if msg.To == "" { return errors.New("email: To is required") } if msg.From == "" { return errors.New("email: From is required") } if msg.BodyText == "" && msg.BodyHTML == "" { return errors.New("email: at least one of BodyText or BodyHTML is required") } return nil } // buildRFC5322 builds an RFC 5322 message body from Message. Plain-text // only when BodyHTML is empty ; multipart/alternative when both are set. // // Keep in mind: this is the body sent to net/smtp.SendMail, which adds // no headers itself. We add the canonical From, To, Subject, MIME-Version, // Content-Type, and any caller-provided custom headers. func buildRFC5322(msg Message) []byte { var b strings.Builder // Custom headers first so they show up early in the message. for k, v := range msg.Headers { // Normalise header name case (Foo-Bar, not foo-BAR) k = textproto.CanonicalMIMEHeaderKey(k) fmt.Fprintf(&b, "%s: %s\r\n", k, v) } fmt.Fprintf(&b, "From: %s\r\n", msg.From) fmt.Fprintf(&b, "To: %s\r\n", msg.To) fmt.Fprintf(&b, "Subject: %s\r\n", msg.Subject) fmt.Fprintf(&b, "MIME-Version: 1.0\r\n") if msg.BodyHTML == "" { // Plain text only fmt.Fprintf(&b, "Content-Type: text/plain; charset=utf-8\r\n") fmt.Fprintf(&b, "\r\n") b.WriteString(msg.BodyText) return []byte(b.String()) } // multipart/alternative — boundary is deterministic for tests but unique // enough not to collide with body content. We don't put random bytes in // the boundary because the alphabetical "alt-" prefix is recognisably // ours and the rest is timestamped. boundary := fmt.Sprintf("alt-%d", time.Now().UnixNano()) fmt.Fprintf(&b, "Content-Type: multipart/alternative; boundary=%q\r\n", boundary) fmt.Fprintf(&b, "\r\n") // Plain part (always first — clients that only render text/plain see // it ; clients preferring HTML go to the HTML part) fmt.Fprintf(&b, "--%s\r\n", boundary) fmt.Fprintf(&b, "Content-Type: text/plain; charset=utf-8\r\n\r\n") if msg.BodyText != "" { b.WriteString(msg.BodyText) } else { b.WriteString("(see HTML version)") } fmt.Fprintf(&b, "\r\n") // HTML part fmt.Fprintf(&b, "--%s\r\n", boundary) fmt.Fprintf(&b, "Content-Type: text/html; charset=utf-8\r\n\r\n") b.WriteString(msg.BodyHTML) fmt.Fprintf(&b, "\r\n") // Close boundary fmt.Fprintf(&b, "--%s--\r\n", boundary) return []byte(b.String()) }