// Package smtp provides the SMTP-backed provider adapter used by Mail // Service. package smtp import ( "bytes" "context" "crypto/tls" "errors" "fmt" "net" stdmail "net/mail" "strconv" "strings" "time" "galaxy/mail/internal/ports" gomail "github.com/wneessen/go-mail" ) const providerName = "smtp" // Config stores the SMTP provider connection settings. type Config struct { // Addr stores the SMTP server network address. Addr string // Username stores the optional SMTP authentication username. Username string // Password stores the optional SMTP authentication password. Password string // FromEmail stores the envelope sender mailbox. FromEmail string // FromName stores the optional display name of the sender. FromName string // Timeout stores the maximum SMTP dial-and-send window enforced by the // adapter when the caller does not provide an earlier deadline. Timeout time.Duration // InsecureSkipVerify disables SMTP certificate verification. This is meant // only for local development and black-box tests with self-signed capture // servers. InsecureSkipVerify bool // TLSConfig stores the optional TLS client configuration override used by // tests. Production wiring leaves it nil and uses secure defaults. TLSConfig *tls.Config } // Provider stores the SMTP-backed delivery adapter. type Provider struct { client *gomail.Client fromEmail string fromName string timeout time.Duration } // New constructs one SMTP-backed provider and validates cfg. func New(cfg Config) (*Provider, error) { if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("new smtp provider: %w", err) } host, portText, err := net.SplitHostPort(strings.TrimSpace(cfg.Addr)) if err != nil { return nil, fmt.Errorf("new smtp provider: split smtp addr: %w", err) } port, err := strconv.Atoi(portText) if err != nil { return nil, fmt.Errorf("new smtp provider: parse smtp port: %w", err) } options := []gomail.Option{ gomail.WithPort(port), gomail.WithTimeout(cfg.Timeout), gomail.WithTLSPolicy(gomail.TLSMandatory), } if cfg.TLSConfig != nil { options = append(options, gomail.WithTLSConfig(cfg.TLSConfig)) } else if cfg.InsecureSkipVerify { options = append(options, gomail.WithTLSConfig(&tls.Config{ MinVersion: tls.VersionTLS12, ServerName: host, InsecureSkipVerify: true, //nolint:gosec // Explicit opt-in for local integration scenarios only. })) } else { options = append(options, gomail.WithTLSConfig(&tls.Config{ MinVersion: tls.VersionTLS12, ServerName: host, })) } if cfg.Username != "" { options = append(options, gomail.WithUsername(cfg.Username), gomail.WithPassword(cfg.Password), gomail.WithSMTPAuth(gomail.SMTPAuthAutoDiscover), ) } client, err := gomail.NewClient(host, options...) if err != nil { return nil, fmt.Errorf("new smtp provider: %w", err) } return &Provider{ client: client, fromEmail: cfg.FromEmail, fromName: cfg.FromName, timeout: cfg.Timeout, }, nil } // Send attempts one outbound SMTP delivery and returns a classified provider // outcome whenever the interaction reached a stable SMTP result. func (provider *Provider) Send(ctx context.Context, message ports.Message) (ports.Result, error) { switch { case ctx == nil: return ports.Result{}, errors.New("send with smtp provider: nil context") case provider == nil || provider.client == nil: return ports.Result{}, errors.New("send with smtp provider: nil provider") } if err := message.Validate(); err != nil { return ports.Result{}, fmt.Errorf("send with smtp provider: %w", err) } if err := ctx.Err(); err != nil { if errors.Is(err, context.DeadlineExceeded) { return newResult(ports.ClassificationTransientFailure, summaryFields{ Phase: "context", }, map[string]string{ "phase": "context", "error": "deadline_exceeded", }) } return ports.Result{}, fmt.Errorf("send with smtp provider: %w", err) } msg, err := provider.buildMessage(message) if err != nil { return newResult(ports.ClassificationPermanentFailure, summaryFields{ Phase: "build", }, map[string]string{ "phase": "build", "error": classifyLocalBuildError(err), }) } sendCtx, cancel := provider.sendContext(ctx) defer cancel() err = provider.client.DialAndSendWithContext(sendCtx, msg) if err == nil { return newResult(ports.ClassificationAccepted, summaryFields{}, nil) } return provider.classifySendError(err) } // Close releases SMTP client resources. func (provider *Provider) Close() error { if provider == nil || provider.client == nil { return nil } provider.client.Close() return nil } // Validate reports whether cfg stores a complete SMTP provider configuration. func (cfg Config) Validate() error { host, port, err := net.SplitHostPort(strings.TrimSpace(cfg.Addr)) switch { case err != nil || port == "": return fmt.Errorf("smtp addr %q must use host:port form", cfg.Addr) case host != "" && strings.Contains(host, " "): return fmt.Errorf("smtp addr %q must use host:port form", cfg.Addr) case cfg.Timeout <= 0: return fmt.Errorf("smtp timeout must be positive") case strings.TrimSpace(cfg.Username) == "" && strings.TrimSpace(cfg.Password) != "": return fmt.Errorf("smtp username and password must be configured together") case strings.TrimSpace(cfg.Username) != "" && strings.TrimSpace(cfg.Password) == "": return fmt.Errorf("smtp username and password must be configured together") } parsed, err := stdmail.ParseAddress(strings.TrimSpace(cfg.FromEmail)) if err != nil || parsed == nil || parsed.Name != "" || parsed.Address != strings.TrimSpace(cfg.FromEmail) { return fmt.Errorf("smtp from email %q must be a single valid email address", cfg.FromEmail) } return nil } func (provider *Provider) buildMessage(message ports.Message) (*gomail.Msg, error) { msg := gomail.NewMsg() msg.EnvelopeFrom(provider.fromEmail) switch strings.TrimSpace(provider.fromName) { case "": if err := msg.From(provider.fromEmail); err != nil { return nil, fmt.Errorf("set from header: %w", err) } default: if err := msg.FromFormat(provider.fromName, provider.fromEmail); err != nil { return nil, fmt.Errorf("set from header: %w", err) } } msg.SetBodyString(gomail.TypeTextPlain, message.Content.TextBody) if message.Content.HTMLBody != "" { msg.AddAlternativeString(gomail.TypeTextHTML, message.Content.HTMLBody) } msg.Subject(message.Content.Subject) for _, address := range message.Envelope.To { if err := msg.AddTo(address.String()); err != nil { return nil, fmt.Errorf("add to recipient: %w", err) } } for _, address := range message.Envelope.Cc { if err := msg.AddCc(address.String()); err != nil { return nil, fmt.Errorf("add cc recipient: %w", err) } } for _, address := range message.Envelope.Bcc { if err := msg.AddBcc(address.String()); err != nil { return nil, fmt.Errorf("add bcc recipient: %w", err) } } for _, address := range message.Envelope.ReplyTo { if err := msg.ReplyTo(address.String()); err != nil { return nil, fmt.Errorf("add reply-to recipient: %w", err) } } for _, attachment := range message.Attachments { if err := attachment.Validate(); err != nil { return nil, fmt.Errorf("attach file %q: %w", attachment.Metadata.Filename, err) } if err := msg.AttachReader( attachment.Metadata.Filename, bytes.NewReader(attachment.Content), gomail.WithFileContentType(gomail.ContentType(attachment.Metadata.ContentType)), ); err != nil { return nil, fmt.Errorf("attach file %q: %w", attachment.Metadata.Filename, err) } } return msg, nil } func (provider *Provider) classifySendError(err error) (ports.Result, error) { switch { case errors.Is(err, context.DeadlineExceeded): return newResult(ports.ClassificationTransientFailure, summaryFields{ Phase: "send", }, map[string]string{ "phase": "send", "error": "deadline_exceeded", }) case strings.Contains(strings.ToLower(err.Error()), "starttls"): return newResult(ports.ClassificationPermanentFailure, summaryFields{ Phase: "tls", }, map[string]string{ "phase": "tls", "error": "starttls_required", }) } var sendErr *gomail.SendError if errors.As(err, &sendErr) { codeText := "" if code := sendErr.ErrorCode(); code > 0 { codeText = strconv.Itoa(code) } phase := smtpReasonPhase(sendErr, err) details := map[string]string{ "phase": phase, "error": sanitizeDetailValue(strings.ToLower(sendErr.Reason.String())), } if codeText != "" { details["smtp_code"] = codeText } switch { case sendErr.ErrorCode() >= 500: return newResult(ports.ClassificationPermanentFailure, summaryFields{ Phase: phase, SMTPCode: codeText, }, details) case sendErr.ErrorCode() >= 400: return newResult(ports.ClassificationTransientFailure, summaryFields{ Phase: phase, SMTPCode: codeText, }, details) case sendErr.IsTemp(): return newResult(ports.ClassificationTransientFailure, summaryFields{ Phase: phase, }, details) default: return newResult(ports.ClassificationPermanentFailure, summaryFields{ Phase: phase, }, details) } } var netErr net.Error if errors.As(err, &netErr) { return newResult(ports.ClassificationTransientFailure, summaryFields{ Phase: "dial", }, map[string]string{ "phase": "dial", "net_op": "smtp", "net_err": sanitizeDetailValue(strings.ToLower(netErr.Error())), }) } return newResult(ports.ClassificationPermanentFailure, summaryFields{ Phase: "send", }, map[string]string{ "phase": "send", "error": sanitizeDetailValue(strings.ToLower(err.Error())), }) } func (provider *Provider) sendContext(ctx context.Context) (context.Context, context.CancelFunc) { if deadline, ok := ctx.Deadline(); ok { remaining := time.Until(deadline) if remaining <= provider.timeout { return ctx, func() {} } } return context.WithTimeout(ctx, provider.timeout) } type summaryFields struct { Phase string SMTPCode string } func newResult(classification ports.Classification, fields summaryFields, details map[string]string) (ports.Result, error) { summary, err := ports.BuildSafeSummary(ports.SummaryFields{ Provider: providerName, Result: string(classification), Phase: fields.Phase, SMTPCode: fields.SMTPCode, }) if err != nil { return ports.Result{}, fmt.Errorf("build smtp provider summary: %w", err) } result := ports.Result{ Classification: classification, Summary: summary, Details: ports.CloneDetails(details), } if err := result.Validate(); err != nil { return ports.Result{}, fmt.Errorf("build smtp provider result: %w", err) } return result, nil } func classifyLocalBuildError(err error) string { return sanitizeDetailValue(strings.ToLower(err.Error())) } func smtpReasonPhase(sendErr *gomail.SendError, err error) string { if sendErr == nil { return "send" } switch sendErr.Reason { case gomail.ErrConnCheck: return "dial" case gomail.ErrSMTPMailFrom: return "mail_from" case gomail.ErrSMTPRcptTo: return "rcpt_to" case gomail.ErrSMTPData: return "data" case gomail.ErrSMTPDataClose: return "data" case gomail.ErrSMTPReset: return "reset" case gomail.ErrWriteContent: return "build" case gomail.ErrGetSender, gomail.ErrGetRcpts: return "build" case gomail.ErrNoUnencoded: return "build" default: lower := strings.ToLower(err.Error()) switch { case strings.Contains(lower, "starttls"): return "tls" case strings.Contains(lower, "auth"): return "auth" default: return "send" } } } func sanitizeDetailValue(value string) string { value = strings.TrimSpace(value) if value == "" { return "unknown" } var builder strings.Builder for _, r := range value { if r > 0x7f { builder.WriteByte('_') continue } switch { case r >= 'a' && r <= 'z': builder.WriteRune(r) case r >= '0' && r <= '9': builder.WriteRune(r) case r == '.', r == '_', r == '-': builder.WriteRune(r) default: builder.WriteByte('_') } } if builder.Len() == 0 { return "unknown" } return builder.String() }