package email import ( "bytes" "context" "crypto/tls" "crypto/x509" "encoding/base64" "errors" "fmt" "io" "mime" "net" "net/mail" "net/smtp" "strconv" "strings" "time" ) const delimiter = "**=myohmy689407924327" const ( DefaultConnectTimeout = 10 * time.Second DefaultOperationTimeout = 30 * time.Second maximumEnvelopeRecipients = 100 ) // TLSMode makes transport security an explicit configuration decision. type TLSMode string const ( TLSModeNone TLSMode = "none" TLSModeSTARTTLS TLSMode = "starttls" TLSModeImplicit TLSMode = "implicit" ) // Config is the canonical transport configuration. RootCAs, when non-nil, // deliberately replaces the operating-system roots; callers that want to add // a private CA should start from x509.SystemCertPool and append to it. type Config struct { Auth smtp.Auth Host string Port string From string TLSMode TLSMode ConnectTimeout time.Duration OperationTimeout time.Duration RootCAs *x509.CertPool } // InsecureConfig is retained for source compatibility. NewInsecureNoAuth is // an explicitly plaintext, no-authentication development transport. type InsecureConfig struct { Host string Port string From string ConnectTimeout time.Duration OperationTimeout time.Duration } // SecureConfig is retained for the legacy secure constructors. type SecureConfig struct { Auth smtp.Auth Host string Port string From string ConnectTimeout time.Duration OperationTimeout time.Duration RootCAs *x509.CertPool } type MessageWithAttachments struct { To string Subject string Body string Attachments []EmailAttachment } // RawMessage keeps the SMTP envelope separate from the caller-built RFC/MIME // bytes. To and Body are deprecated compatibility fields; new callers should // use EnvelopeRecipients and Data. EnvelopeFrom may be empty to use Config.From. type RawMessage struct { EnvelopeFrom string EnvelopeRecipients []string Data []byte // Deprecated: use EnvelopeRecipients. To string // Deprecated: use Data. Body string } // SMTPClientIface and SmtpDialFn remain exported for source compatibility with // earlier test doubles. The canonical transport uses context-aware net.Conn // dialing directly. type SMTPClientIface interface { StartTLS(*tls.Config) error Auth(smtp.Auth) error Close() error Data() (io.WriteCloser, error) Mail(string) error Quit() error Rcpt(string) error } type SmtpDialFn func(hostPort string) (SMTPClientIface, error) //nolint:revive // Retained for source compatibility. type dialContextFunc func(context.Context, string, string) (net.Conn, error) type EmailService struct { auth smtp.Auth host string port string from string tlsMode TLSMode connectTimeout time.Duration operationTimeout time.Duration rootCAs *x509.CertPool dialContext dialContextFunc configurationErr error sendRawHook func(context.Context, RawMessage) error } // New constructs the canonical validated SMTP transport. func New(config Config) (*EmailService, error) { config.Host = strings.TrimSpace(config.Host) config.Port = strings.TrimSpace(config.Port) config.From = strings.TrimSpace(config.From) if config.ConnectTimeout == 0 { config.ConnectTimeout = DefaultConnectTimeout } if config.OperationTimeout == 0 { config.OperationTimeout = DefaultOperationTimeout } if err := validateConfig(config); err != nil { return nil, err } rootCAs := config.RootCAs if rootCAs != nil { rootCAs = rootCAs.Clone() } dialer := &net.Dialer{Timeout: config.ConnectTimeout} return &EmailService{ auth: config.Auth, host: config.Host, port: config.Port, from: config.From, tlsMode: config.TLSMode, connectTimeout: config.ConnectTimeout, operationTimeout: config.OperationTimeout, rootCAs: rootCAs, dialContext: dialer.DialContext, }, nil } // NewInsecureNoAuth constructs explicit plaintext SMTP for trusted local // development systems such as Mailpit. It never sends authentication data. func NewInsecureNoAuth(config InsecureConfig) *EmailService { return legacyService(Config{ Host: config.Host, Port: config.Port, From: config.From, TLSMode: TLSModeNone, ConnectTimeout: config.ConnectTimeout, OperationTimeout: config.OperationTimeout, }) } // NewInsecure is retained for compatibility but no longer disables TLS // verification. It now requires verified STARTTLS exactly like NewSecure. func NewInsecure(config SecureConfig) *EmailService { return NewSecure(config) } // NewSecure constructs verified, required STARTTLS. func NewSecure(config SecureConfig) *EmailService { return legacyService(Config{ Auth: config.Auth, Host: config.Host, Port: config.Port, From: config.From, TLSMode: TLSModeSTARTTLS, ConnectTimeout: config.ConnectTimeout, OperationTimeout: config.OperationTimeout, RootCAs: config.RootCAs, }) } // NewSecure465 constructs verified implicit TLS. The name is retained for // compatibility; TLS mode is explicit and does not depend on the port value. func NewSecure465(config SecureConfig) *EmailService { return legacyService(Config{ Auth: config.Auth, Host: config.Host, Port: config.Port, From: config.From, TLSMode: TLSModeImplicit, ConnectTimeout: config.ConnectTimeout, OperationTimeout: config.OperationTimeout, RootCAs: config.RootCAs, }) } func legacyService(config Config) *EmailService { service, err := New(config) if err == nil { return service } return &EmailService{ connectTimeout: defaultDuration(config.ConnectTimeout, DefaultConnectTimeout), operationTimeout: defaultDuration(config.OperationTimeout, DefaultOperationTimeout), configurationErr: err, } } func defaultDuration(value, fallback time.Duration) time.Duration { if value <= 0 { return fallback } return value } func validateConfig(config Config) error { if config.Host == "" || strings.ContainsAny(config.Host, "\r\n\t /") { return permanentError("validate configuration", errors.New("SMTP host is invalid")) } port, err := strconv.Atoi(config.Port) if err != nil || port < 1 || port > 65535 { return permanentError("validate configuration", errors.New("SMTP port is invalid")) } if err := validateEnvelopeAddress(config.From); err != nil { return permanentError("validate configuration", fmt.Errorf("envelope sender: %w", err)) } switch config.TLSMode { case TLSModeNone, TLSModeSTARTTLS, TLSModeImplicit: default: return permanentError("validate configuration", errors.New("SMTP TLS mode is invalid")) } if config.ConnectTimeout <= 0 || config.OperationTimeout <= 0 { return permanentError("validate configuration", errors.New("SMTP timeouts must be positive")) } if config.TLSMode == TLSModeNone && config.Auth != nil { return permanentError("validate configuration", errors.New("SMTP authentication requires TLS")) } return nil } // SendEmailContext builds the legacy HTML/attachment message and sends it // through the canonical context-aware raw path. func (service *EmailService) SendEmailContext(ctx context.Context, emailData MessageWithAttachments) error { if strings.TrimSpace(emailData.Subject) == "" || containsHeaderBreak(emailData.Subject) { return permanentError("prepare message", errors.New("subject is invalid")) } message, err := newMessage(service.from, emailData.To, emailData.Subject). withAttachments(emailData.Body, emailData.Attachments) if err != nil { return permanentError("prepare message", err) } return service.SendRawContext(ctx, RawMessage{ EnvelopeFrom: service.from, EnvelopeRecipients: []string{emailData.To}, Data: message, }) } // SendEmail is a compatibility wrapper with an explicit bounded lifetime. func (service *EmailService) SendEmail(emailData MessageWithAttachments) error { ctx, cancel := service.legacyContext() defer cancel() return service.SendEmailContext(ctx, emailData) } // SendRaw is a compatibility wrapper with an explicit bounded lifetime. func (service *EmailService) SendRaw(emailData RawMessage) error { ctx, cancel := service.legacyContext() defer cancel() return service.SendRawContext(ctx, emailData) } func (service *EmailService) legacyContext() (context.Context, context.CancelFunc) { connectTimeout := defaultDuration(service.connectTimeout, DefaultConnectTimeout) operationTimeout := defaultDuration(service.operationTimeout, DefaultOperationTimeout) return context.WithTimeout(context.Background(), connectTimeout+10*operationTimeout) } type message struct { from string to string subject string } func newMessage(from, to, subject string) message { return message{from: from, to: to, subject: subject} } func (m message) withAttachments(body string, attachments []EmailAttachment) ([]byte, error) { if err := validateEnvelopeAddress(m.from); err != nil { return nil, fmt.Errorf("from address: %w", err) } if err := validateEnvelopeAddress(m.to); err != nil { return nil, fmt.Errorf("recipient address: %w", err) } if strings.TrimSpace(m.subject) == "" || containsHeaderBreak(m.subject) { return nil, errors.New("subject is invalid") } var output bytes.Buffer output.WriteString("From: " + m.from + "\r\n") output.WriteString("To: " + m.to + "\r\n") output.WriteString("Subject: " + mime.QEncoding.Encode("UTF-8", m.subject) + "\r\n") output.WriteString("MIME-Version: 1.0\r\n") _, _ = fmt.Fprintf(&output, "Content-Type: multipart/mixed; boundary=%q\r\n\r\n", delimiter) output.WriteString("--" + delimiter + "\r\n") output.WriteString("Content-Type: text/html; charset=\"UTF-8\"\r\n\r\n") output.WriteString(body + "\r\n\r\n") for _, attachment := range attachments { if strings.TrimSpace(attachment.Title) == "" || containsHeaderBreak(attachment.Title) { return nil, errors.New("attachment title is invalid") } content, err := attachment.ReadContent() if err != nil { return nil, err } output.WriteString("--" + delimiter + "\r\n") output.WriteString("Content-Disposition: " + mime.FormatMediaType("attachment", map[string]string{"filename": attachment.Title}) + "\r\n") output.WriteString("Content-Type: application/octet-stream\r\n") output.WriteString("Content-Transfer-Encoding: base64\r\n\r\n") output.WriteString(base64.StdEncoding.EncodeToString(content) + "\r\n") } output.WriteString("--" + delimiter + "--\r\n") return output.Bytes(), nil } type EmailAttachment struct { File io.Reader Title string } func (attachment EmailAttachment) ReadContent() ([]byte, error) { content, err := io.ReadAll(attachment.File) if err != nil { return nil, fmt.Errorf("load attachment: %w", err) } return content, nil } func validateEnvelopeAddress(value string) error { if value == "" || value != strings.TrimSpace(value) || containsHeaderBreak(value) { return errors.New("address is invalid") } parsed, err := mail.ParseAddress(value) if err != nil || parsed.Address != value { return errors.New("address must be one mailbox without a display name") } return nil } func containsHeaderBreak(value string) bool { return strings.ContainsAny(value, "\r\n") }