package email import ( "bytes" "context" "crypto/tls" "encoding/base64" "errors" "io" "net" "net/smtp" "net/textproto" "os" "strconv" "strings" "time" "git.sendico.io/tech/sxchnge/notifications/model" merrors "git.sendico.io/tech/sxchnge/shared/errors" "github.com/go-viper/mapstructure/v2" ) type smtpSettings struct { Host string `mapstructure:"host"` Port int `mapstructure:"port"` Username string `mapstructure:"username"` PasswordEnv string `mapstructure:"password_env"` SenderAddress string `mapstructure:"sender_address"` SenderName string `mapstructure:"sender_name"` NetworkTimeout int `mapstructure:"network_timeout"` } type smtpSender struct { settings smtpSettings password string tls *tls.Config } func newSMTP(config Config) (Sender, error) { var s smtpSettings decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{Result: &s, ErrorUnused: true, TagName: "mapstructure"}) if err != nil { return nil, merrors.InternalWrap(err, "create SMTP settings decoder") } if decoder.Decode(config.Settings) != nil || s.Host == "" || strings.ContainsAny(s.Host, " /@\r\n\t") || s.Port < 1 || s.Port > 65535 || !ValidAddress(s.Username) || !ValidAddress(s.SenderAddress) || strings.ContainsAny(s.SenderName, "\r\n") || s.NetworkTimeout < 1 || s.NetworkTimeout > 20 { return nil, merrors.InvalidArgument("invalid SMTP settings") } password := os.Getenv(s.PasswordEnv) if s.PasswordEnv == "" || password == "" || strings.ContainsAny(password, "\r\n\x00") { return nil, merrors.FailedPrecondition("SMTP password environment variable is missing or invalid") } return &smtpSender{settings: s, password: password, tls: &tls.Config{ServerName: s.Host, MinVersion: tls.VersionTLS12}}, nil } // SMTP uses a fresh authenticated TLS connection per attempt. No automatic // resend follows an uncertain result, since SMTP has no idempotency guarantee. func (s *smtpSender) Send(ctx context.Context, key string, email model.Email) Outcome { body, err := smtpMessage(s.settings, key, email) if err != nil { return Outcome{State: model.Failed, Code: "invalid_payload"} } ctx, cancel := context.WithTimeout(ctx, time.Duration(s.settings.NetworkTimeout)*time.Second) defer cancel() dialer := tls.Dialer{NetDialer: &net.Dialer{}, Config: s.tls} conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(s.settings.Host, strconv.Itoa(s.settings.Port))) if err != nil { return s.failure(err, key, email) } defer conn.Close() stop := context.AfterFunc(ctx, func() { _ = conn.Close() }) defer stop() deadline, _ := ctx.Deadline() if err = conn.SetDeadline(deadline); err != nil { return s.failure(err, key, email) } client, err := smtp.NewClient(conn, s.settings.Host) if err == nil { err = client.Auth(smtp.PlainAuth("", s.settings.Username, s.password, s.settings.Host)) } if err == nil { err = client.Mail(s.settings.SenderAddress) } if err == nil { err = client.Rcpt(email.Address) } if err != nil { return s.failure(err, key, email) } writer, err := client.Data() if err != nil { return s.failure(err, key, email) } if _, err = io.Copy(writer, bytes.NewReader(body)); err == nil { err = writer.Close() } if err != nil { return s.failure(err, key, email) } // DATA's final 250 is acceptance. A later QUIT error cannot undo it. _ = client.Quit() return Outcome{State: model.Accepted, SMTPStatus: 250} } func (s *smtpSender) failure(err error, key string, email model.Email) Outcome { secrets := []string{s.password, s.settings.Username, key, email.Address, email.Subject, email.HTML, email.Text} secrets = append(secrets, base64.StdEncoding.EncodeToString([]byte("\x00"+s.settings.Username+"\x00"+s.password))) secrets = append(secrets, numbers.FindAllString(email.Text+" "+email.HTML, -1)...) out := Outcome{State: model.Unknown, Code: "provider_outcome_unknown", ProviderError: "smtp", ProviderMessage: safeErrorText(err.Error(), secrets)} var reply *textproto.Error if errors.As(err, &reply) { out.SMTPStatus = reply.Code if reply.Code >= 400 && reply.Code < 500 { out.State, out.Code, out.RetryAfter = model.RetryWait, "provider_temporarily_unavailable", time.Minute } else if reply.Code >= 500 && reply.Code < 600 { out.State, out.Code = model.Failed, "provider_rejected" if reply.Code == 530 || reply.Code == 534 || reply.Code == 535 || reply.Code == 538 { out.Code = "provider_authentication_failed" } } } return out }