package dispatch import ( "bytes" "context" "crypto/tls" "crypto/x509" _ "embed" "fmt" "mime/multipart" "mime/quotedprintable" "net" "net/mail" "net/textproto" "os" "slices" "strings" "sync" "text/template" "time" "github.com/emersion/go-sasl" smtp "github.com/emersion/go-smtp" "github.com/google/uuid" "github.com/hashicorp/go-multierror" "golang.org/x/xerrors" "cdr.dev/slog" "github.com/coder/coder/v2/coderd/notifications/render" "github.com/coder/coder/v2/coderd/notifications/types" markdown "github.com/coder/coder/v2/coderd/render" "github.com/coder/coder/v2/codersdk" ) var ( ValidationNoFromAddressErr = xerrors.New("no 'from' address defined") ValidationNoToAddressErr = xerrors.New("no 'to' address(es) defined") ValidationNoSmarthostHostErr = xerrors.New("smarthost 'host' is not defined, or is invalid") ValidationNoSmarthostPortErr = xerrors.New("smarthost 'port' is not defined, or is invalid") ValidationNoHelloErr = xerrors.New("'hello' not defined") //go:embed smtp/html.gotmpl htmlTemplate string //go:embed smtp/plaintext.gotmpl plainTemplate string ) // SMTPHandler is responsible for dispatching notification messages via SMTP. // NOTE: auth and TLS is currently *not* enabled in this initial thin slice. // TODO: implement DKIM/SPF/DMARC? https://github.com/emersion/go-msgauth type SMTPHandler struct { cfg codersdk.NotificationsEmailConfig log slog.Logger loginWarnOnce sync.Once helpers template.FuncMap } func NewSMTPHandler(cfg codersdk.NotificationsEmailConfig, helpers template.FuncMap, log slog.Logger) *SMTPHandler { return &SMTPHandler{cfg: cfg, helpers: helpers, log: log} } func (s *SMTPHandler) Dispatcher(payload types.MessagePayload, titleTmpl, bodyTmpl string) (DeliveryFunc, error) { // First render the subject & body into their own discrete strings. subject, err := markdown.PlaintextFromMarkdown(titleTmpl) if err != nil { return nil, xerrors.Errorf("render subject: %w", err) } htmlBody := markdown.HTMLFromMarkdown(bodyTmpl) plainBody, err := markdown.PlaintextFromMarkdown(bodyTmpl) if err != nil { return nil, xerrors.Errorf("render plaintext body: %w", err) } // Then, reuse these strings in the HTML & plain body templates. payload.Labels["_subject"] = subject payload.Labels["_body"] = htmlBody htmlBody, err = render.GoTemplate(htmlTemplate, payload, s.helpers) if err != nil { return nil, xerrors.Errorf("render full html template: %w", err) } payload.Labels["_body"] = plainBody plainBody, err = render.GoTemplate(plainTemplate, payload, s.helpers) if err != nil { return nil, xerrors.Errorf("render full plaintext template: %w", err) } return s.dispatch(subject, htmlBody, plainBody, payload.UserEmail), nil } // dispatch returns a DeliveryFunc capable of delivering a notification via SMTP. // // Our requirements are too complex to be implemented using smtp.SendMail: // - we require custom TLS settings // - dynamic determination of available AUTH mechanisms // // NOTE: this is inspired by Alertmanager's email notifier: // https://github.com/prometheus/alertmanager/blob/342f6a599ce16c138663f18ed0b880e777c3017d/notify/email/email.go func (s *SMTPHandler) dispatch(subject, htmlBody, plainBody, to string) DeliveryFunc { return func(ctx context.Context, msgID uuid.UUID) (bool, error) { select { case <-ctx.Done(): return false, ctx.Err() default: } s.log.Debug(ctx, "dispatching via SMTP", slog.F("msg_id", msgID)) // Dial the smarthost to establish a connection. smarthost, smarthostPort, err := s.smarthost() if err != nil { return false, xerrors.Errorf("'smarthost' validation: %w", err) } // Outer context has a deadline (see CODER_NOTIFICATIONS_DISPATCH_TIMEOUT). if _, ok := ctx.Deadline(); !ok { return false, xerrors.Errorf("context has no deadline") } // TODO: reuse client across dispatches (if possible). // Create an SMTP client for communication with the smarthost. c, err := s.client(ctx, smarthost, smarthostPort) if err != nil { return true, xerrors.Errorf("SMTP client creation: %w", err) } // Cleanup. defer func() { if err := c.Quit(); err != nil { s.log.Warn(ctx, "failed to close SMTP connection", slog.Error(err)) } }() // Check for authentication capabilities. if ok, avail := c.Extension("AUTH"); ok { // Ensure the auth mechanisms available are ones we can use. auth, err := s.auth(ctx, avail) if err != nil { return true, xerrors.Errorf("determine auth mechanism: %w", err) } // If so, use the auth mechanism to authenticate. if auth != nil { if err := c.Auth(auth); err != nil { return true, xerrors.Errorf("%T auth: %w", auth, err) } } } else if !s.cfg.Auth.Empty() { return false, xerrors.New("no authentication mechanisms supported by server") } // Sender identification. from, err := s.validateFromAddr(s.cfg.From.String()) if err != nil { return false, xerrors.Errorf("'from' validation: %w", err) } err = c.Mail(from, &smtp.MailOptions{}) if err != nil { // This is retryable because the server may be temporarily down. return true, xerrors.Errorf("sender identification: %w", err) } // Recipient designation. recipients, err := s.validateToAddrs(to) if err != nil { return false, xerrors.Errorf("'to' validation: %w", err) } for _, addr := range recipients { err = c.Rcpt(addr, &smtp.RcptOptions{}) if err != nil { // This is a retryable case because the server may be temporarily down. // The addresses are already validated, although it is possible that the server might disagree - in which case // this will lead to some spurious retries, but that's not a big deal. return true, xerrors.Errorf("recipient designation: %w", err) } } // Start message transmission. message, err := c.Data() if err != nil { return true, xerrors.Errorf("message transmission: %w", err) } closeOnce := sync.OnceValue(func() error { return message.Close() }) // Close the message when this method exits in order to not leak resources. Even though we're calling this explicitly // further down, the method may exit before then. defer func() { // If we try close an already-closed writer, it'll send a subsequent request to the server which is invalid. _ = closeOnce() }() // Create message headers. msg := &bytes.Buffer{} multipartBuffer := &bytes.Buffer{} multipartWriter := multipart.NewWriter(multipartBuffer) _, _ = fmt.Fprintf(msg, "From: %s\r\n", from) _, _ = fmt.Fprintf(msg, "To: %s\r\n", strings.Join(recipients, ", ")) _, _ = fmt.Fprintf(msg, "Subject: %s\r\n", subject) _, _ = fmt.Fprintf(msg, "Message-Id: %s@%s\r\n", msgID, s.hostname()) _, _ = fmt.Fprintf(msg, "Date: %s\r\n", time.Now().Format(time.RFC1123Z)) _, _ = fmt.Fprintf(msg, "Content-Type: multipart/alternative; boundary=%s\r\n", multipartWriter.Boundary()) _, _ = fmt.Fprintf(msg, "MIME-Version: 1.0\r\n\r\n") _, err = message.Write(msg.Bytes()) if err != nil { return false, xerrors.Errorf("write headers: %w", err) } // Transmit message body. // Text body w, err := multipartWriter.CreatePart(textproto.MIMEHeader{ "Content-Transfer-Encoding": {"quoted-printable"}, "Content-Type": {"text/plain; charset=UTF-8"}, }) if err != nil { return false, xerrors.Errorf("create part for text body: %w", err) } qw := quotedprintable.NewWriter(w) _, err = qw.Write([]byte(plainBody)) if err != nil { return true, xerrors.Errorf("write text part: %w", err) } err = qw.Close() if err != nil { return true, xerrors.Errorf("close text part: %w", err) } // HTML body // Preferred body placed last per section 5.1.4 of RFC 2046 // https://www.ietf.org/rfc/rfc2046.txt w, err = multipartWriter.CreatePart(textproto.MIMEHeader{ "Content-Transfer-Encoding": {"quoted-printable"}, "Content-Type": {"text/html; charset=UTF-8"}, }) if err != nil { return false, xerrors.Errorf("create part for HTML body: %w", err) } qw = quotedprintable.NewWriter(w) _, err = qw.Write([]byte(htmlBody)) if err != nil { return true, xerrors.Errorf("write HTML part: %w", err) } err = qw.Close() if err != nil { return true, xerrors.Errorf("close HTML part: %w", err) } err = multipartWriter.Close() if err != nil { return false, xerrors.Errorf("close multipartWriter: %w", err) } _, err = message.Write(multipartBuffer.Bytes()) if err != nil { return false, xerrors.Errorf("write body buffer: %w", err) } if err = closeOnce(); err != nil { return true, xerrors.Errorf("delivery failure: %w", err) } // Returning false, nil indicates successful send (i.e. non-retryable non-error) return false, nil } } // client creates an SMTP client capable of communicating over a plain or TLS-encrypted connection. func (s *SMTPHandler) client(ctx context.Context, host string, port string) (*smtp.Client, error) { var ( c *smtp.Client conn net.Conn d net.Dialer err error ) // Outer context has a deadline (see CODER_NOTIFICATIONS_DISPATCH_TIMEOUT). deadline, ok := ctx.Deadline() if !ok { return nil, xerrors.Errorf("context has no deadline") } // Align with context deadline. d.Deadline = deadline tlsCfg, err := s.tlsConfig() if err != nil { return nil, xerrors.Errorf("build TLS config: %w", err) } smarthost := fmt.Sprintf("%s:%s", host, port) useTLS := false // Use TLS if known TLS port(s) are used or TLS is forced. if port == "465" || s.cfg.ForceTLS { useTLS = true // STARTTLS is only used on plain connections to upgrade. if s.cfg.TLS.StartTLS { s.log.Warn(ctx, "STARTTLS is not allowed on TLS connections; disabling STARTTLS") s.cfg.TLS.StartTLS = false } } // Dial a TLS or plain connection to the smarthost. if useTLS { conn, err = tls.DialWithDialer(&d, "tcp", smarthost, tlsCfg) if err != nil { return nil, xerrors.Errorf("establish TLS connection to server: %w", err) } } else { conn, err = d.DialContext(ctx, "tcp", smarthost) if err != nil { return nil, xerrors.Errorf("establish plain connection to server: %w", err) } } // If the connection is plain, and STARTTLS is configured, try to upgrade the connection. if s.cfg.TLS.StartTLS { c, err = smtp.NewClientStartTLS(conn, tlsCfg) if err != nil { return nil, xerrors.Errorf("upgrade connection with STARTTLS: %w", err) } } else { c = smtp.NewClient(conn) // HELO is performed here and not always because smtp.NewClientStartTLS greets the server already to establish // whether STARTTLS is allowed. var hello string // Server handshake. hello, err = s.hello() if err != nil { return nil, xerrors.Errorf("'hello' validation: %w", err) } err = c.Hello(hello) if err != nil { return nil, xerrors.Errorf("server handshake: %w", err) } } // Align with context deadline. c.CommandTimeout = time.Until(deadline) c.SubmissionTimeout = time.Until(deadline) return c, nil } func (s *SMTPHandler) tlsConfig() (*tls.Config, error) { host, _, err := s.smarthost() if err != nil { return nil, err } srvName := s.cfg.TLS.ServerName.String() if srvName == "" { srvName = host } ca, err := s.loadCAFile() if err != nil { return nil, xerrors.Errorf("load CA: %w", err) } var certs []tls.Certificate cert, err := s.loadCertificate() if err != nil { return nil, xerrors.Errorf("load cert: %w", err) } if cert != nil { certs = append(certs, *cert) } return &tls.Config{ ServerName: srvName, // nolint:gosec // Users may choose to enable this. InsecureSkipVerify: s.cfg.TLS.InsecureSkipVerify.Value(), RootCAs: ca, Certificates: certs, ClientAuth: tls.RequireAndVerifyClientCert, }, nil } func (s *SMTPHandler) loadCAFile() (*x509.CertPool, error) { if s.cfg.TLS.CAFile == "" { // nolint:nilnil // A nil CertPool is a valid response. return nil, nil } ca, err := os.ReadFile(s.cfg.TLS.CAFile.String()) if err != nil { return nil, xerrors.Errorf("load CA file: %w", err) } pool := x509.NewCertPool() if !pool.AppendCertsFromPEM(ca) { return nil, xerrors.Errorf("build cert pool: %w", err) } return pool, nil } func (s *SMTPHandler) loadCertificate() (*tls.Certificate, error) { if len(s.cfg.TLS.CertFile) == 0 && len(s.cfg.TLS.KeyFile) == 0 { // nolint:nilnil // A nil certificate is a valid response. return nil, nil } cert, err := os.ReadFile(s.cfg.TLS.CertFile.Value()) if err != nil { return nil, xerrors.Errorf("load cert: %w", err) } key, err := os.ReadFile(s.cfg.TLS.KeyFile.String()) if err != nil { return nil, xerrors.Errorf("load key: %w", err) } pair, err := tls.X509KeyPair(cert, key) if err != nil { return nil, xerrors.Errorf("invalid or unusable keypair: %w", err) } return &pair, nil } // auth returns a value which implements the smtp.Auth based on the available auth mechanisms. func (s *SMTPHandler) auth(ctx context.Context, mechs string) (sasl.Client, error) { username := s.cfg.Auth.Username.String() var errs error list := strings.Split(mechs, " ") for _, mech := range list { switch mech { case sasl.Plain: password, err := s.password() if err != nil { errs = multierror.Append(errs, err) continue } if password == "" { errs = multierror.Append(errs, xerrors.New("cannot use PLAIN auth, password not defined (see CODER_NOTIFICATIONS_EMAIL_AUTH_PASSWORD)")) continue } return sasl.NewPlainClient(s.cfg.Auth.Identity.String(), username, password), nil case sasl.Login: if slices.Contains(list, sasl.Plain) { // Prefer PLAIN over LOGIN. continue } // Warn that LOGIN is obsolete, but don't do it every time we dispatch a notification. s.loginWarnOnce.Do(func() { s.log.Warn(ctx, "LOGIN auth is obsolete and should be avoided (use PLAIN instead): https://www.ietf.org/archive/id/draft-murchison-sasl-login-00.txt") }) password, err := s.password() if err != nil { errs = multierror.Append(errs, err) continue } if password == "" { errs = multierror.Append(errs, xerrors.New("cannot use LOGIN auth, password not defined (see CODER_NOTIFICATIONS_EMAIL_AUTH_PASSWORD)")) continue } return sasl.NewLoginClient(username, password), nil default: return nil, xerrors.Errorf("unsupported auth mechanism: %q (supported: %v)", mechs, []string{sasl.Plain, sasl.Login}) } } return nil, errs } func (*SMTPHandler) validateFromAddr(from string) (string, error) { addrs, err := mail.ParseAddressList(from) if err != nil { return "", xerrors.Errorf("parse 'from' address: %w", err) } if len(addrs) != 1 { return "", ValidationNoFromAddressErr } return from, nil } func (s *SMTPHandler) validateToAddrs(to string) ([]string, error) { addrs, err := mail.ParseAddressList(to) if err != nil { return nil, xerrors.Errorf("parse 'to' addresses: %w", err) } if len(addrs) == 0 { s.log.Warn(context.Background(), "no valid 'to' address(es) defined; some may be invalid", slog.F("defined", to)) return nil, ValidationNoToAddressErr } var out []string for _, addr := range addrs { out = append(out, addr.Address) } return out, nil } // smarthost retrieves the host/port defined and validates them. // Does not allow overriding. // nolint:revive // documented. func (s *SMTPHandler) smarthost() (string, string, error) { host := s.cfg.Smarthost.Host port := s.cfg.Smarthost.Port // We don't validate the contents themselves; this will be done by the underlying SMTP library. if host == "" { return "", "", ValidationNoSmarthostHostErr } if port == "" { return "", "", ValidationNoSmarthostPortErr } return host, port, nil } // hello retrieves the hostname identifying the SMTP server. // Does not allow overriding. func (s *SMTPHandler) hello() (string, error) { val := s.cfg.Hello.String() if val == "" { return "", ValidationNoHelloErr } return val, nil } func (*SMTPHandler) hostname() string { h, err := os.Hostname() // If we can't get the hostname, we'll use localhost if err != nil { h = "localhost.localdomain" } return h } // password returns either the configured password, or reads it from the configured file (if possible). func (s *SMTPHandler) password() (string, error) { file := s.cfg.Auth.PasswordFile.String() if len(file) > 0 { content, err := os.ReadFile(file) if err != nil { return "", xerrors.Errorf("could not read %s: %w", file, err) } return string(content), nil } return s.cfg.Auth.Password.String(), nil }