Files
fleet/server/mail/mail.go
T
3e10ad717c Add optional SES sender domain configuration (#43811)
**Related issue:** Resolves #42288

# Summary

This PR adds support for configuring an optional SES sender domain.

When the SES email backend is enabled, Fleet can now use a configured
sender domain for the `From` address instead of always deriving the
domain from `server.server_url`. If the setting is not provided, Fleet
keeps the existing behavior.

# Impact

This gives self-hosted operators a server-side SES configuration option
for email sending without changing UI-managed SMTP settings.

# Root cause

The SES sender path only generated `do-not-reply@<server host>` from the
Fleet server URL, so there was no way to override the sender domain
through server configuration.

# Checklist for submitter

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
- [x] Added/updated automated tests
- [x] Setting(s) is/are explicitly excluded from GitOps

## Testing

- [x] `go test -tags full,fts5,netgo ./server/mail -run
'Test_(getFromSES|sesSender_SendEmail)$'`
- [x] `go test -tags full,fts5,netgo ./server/config -run
'TestConfig(SESSenderDomain|Roundtrip)$'`
- [x] `go test -tags full,fts5,netgo ./server/service -run
'TestService_EmailConfig$'`
- [ ] QA'd all new/changed functionality manually


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added optional SES sender domain configuration. Users can specify a
custom domain for the email "From" address via config or environment
variable; when unset it falls back to the server hostname.

* **Tests**
* Added and expanded tests to verify sender-domain precedence,
From-header generation, and related error cases.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/fleetdm/fleet/pull/43811?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Lucas Manuel Rodriguez <lucas@fleetdm.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-19 11:21:46 -05:00

335 lines
8.8 KiB
Go

// Package mail provides implementations of the Fleet MailService
package mail
import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
"html/template"
"io"
"net"
"net/smtp"
"strings"
"time"
"github.com/fleetdm/fleet/v4/server"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/fleet"
)
// ErrSTARTTLSWithoutSSLTLS is returned when a STARTTLS handshake fails while
// the user has SSL/TLS disabled and SSL cert verification enabled.
var ErrSTARTTLSWithoutSSLTLS = errors.New("STARTTLS must be disabled if not using SSL/TLS to connect.")
func NewService(config config.FleetConfig) (fleet.MailService, error) {
switch strings.ToLower(config.Email.EmailBackend) {
case "ses":
return NewSESSender(config.SES.Region,
config.SES.EndpointURL,
config.SES.AccessKeyID,
config.SES.SecretAccessKey,
config.SES.StsAssumeRoleArn,
config.SES.StsExternalID,
config.SES.SourceArn,
config.SES.SenderDomain,
)
default:
return &mailService{}, nil
}
}
type mailService struct{}
type sender interface {
sendMail(ctx context.Context, e fleet.Email, msg []byte) error
}
func Test(mailer fleet.MailService, e fleet.Email) error {
mailBody, err := getMessageBody(e, getFrom)
if err != nil {
return fmt.Errorf("failed to get message body: %w", err)
}
svc, ok := mailer.(sender)
if !ok {
return nil
}
err = svc.sendMail(context.Background(), e, mailBody)
if err != nil {
return fmt.Errorf("sending mail: %w", err)
}
return nil
}
const (
PortSSL = 465
PortTLS = 587
)
type fromFunc func(e fleet.Email) (string, error)
func getMessageBody(e fleet.Email, f fromFunc) ([]byte, error) {
body, err := e.Mailer.Message()
if err != nil {
return nil, fmt.Errorf("get mailer message: %w", err)
}
mime := `MIME-version: 1.0;` + "\r\n"
content := `Content-Type: text/html; charset="UTF-8";` + "\r\n"
subject := "Subject: " + e.Subject + "\r\n"
from, err := f(e)
if err != nil {
return nil, fmt.Errorf("failed to obtain from address: %w", err)
}
to := ""
if len(e.To) == 1 {
to = "To: " + e.To[0] + "\r\n"
} else if len(e.To) > 1 {
to = "To: undisclosed-recipients\r\n"
}
msg := []byte(subject + from + to + mime + content + "\r\n" + string(body) + "\r\n")
return msg, nil
}
func getFrom(e fleet.Email) (string, error) {
return "From: " + e.SMTPSettings.SMTPSenderAddress + "\r\n", nil
}
func (m mailService) SendEmail(ctx context.Context, e fleet.Email) error {
if !e.SMTPSettings.SMTPConfigured {
return errors.New("requires that SMTP or SES (email) is configured.")
}
msg, err := getMessageBody(e, getFrom)
if err != nil {
return err
}
return m.sendMail(ctx, e, msg)
}
func (m mailService) CanSendEmail(smtpSettings fleet.SMTPSettings) bool {
return smtpSettings.SMTPConfigured
}
type loginauth struct {
username string
password string
host string
}
func LoginAuth(username, password, host string) smtp.Auth {
return &loginauth{username: username, password: password, host: host}
}
func isLocalhost(name string) bool {
return name == "localhost" || name == "127.0.0.1" || name == "::1"
}
func (l *loginauth) Start(server *smtp.ServerInfo) (proto string, toServer []byte, err error) {
if !server.TLS && !isLocalhost(server.Name) {
return "", nil, errors.New("unencrypted connection")
}
if server.Name != l.host {
return "", nil, errors.New("wrong host name")
}
return "LOGIN", nil, nil
}
func (l *loginauth) Next(fromServer []byte, more bool) (toServer []byte, err error) {
if !more {
return nil, nil
}
prompt := strings.TrimSpace(string(fromServer))
switch prompt {
case "Username:":
return []byte(l.username), nil
case "Password:":
return []byte(l.password), nil
default:
return nil, errors.New("unexpected LOGIN prompt from server")
}
}
func smtpAuth(e fleet.Email) (smtp.Auth, error) {
if e.SMTPSettings.SMTPAuthenticationType != fleet.AuthTypeNameUserNamePassword {
return nil, nil
}
username := e.SMTPSettings.SMTPUserName
password := e.SMTPSettings.SMTPPassword
server := e.SMTPSettings.SMTPServer
authMethod := e.SMTPSettings.SMTPAuthenticationMethod
var auth smtp.Auth
switch authMethod {
case fleet.AuthMethodNameCramMD5:
auth = smtp.CRAMMD5Auth(username, password)
case fleet.AuthMethodNamePlain:
auth = smtp.PlainAuth("", username, password, server)
case fleet.AuthMethodNameLogin:
auth = LoginAuth(username, password, server)
default:
return nil, fmt.Errorf("unknown SMTP auth type '%s'", authMethod)
}
return auth, nil
}
func (m mailService) sendMail(ctx context.Context, e fleet.Email, msg []byte) error {
smtpHost := fmt.Sprintf(
"%s:%d", e.SMTPSettings.SMTPServer, e.SMTPSettings.SMTPPort)
auth, err := smtpAuth(e)
if err != nil {
return fmt.Errorf("failed to get smtp auth: %w", err)
}
if e.SMTPSettings.SMTPAuthenticationMethod == fleet.AuthMethodNameCramMD5 {
err = smtp.SendMail(smtpHost, auth, e.SMTPSettings.SMTPSenderAddress, e.To, msg)
if err != nil {
return fmt.Errorf("failed to send mail. crammd5 auth method: %w", err)
}
return nil
}
tlsConfig := &tls.Config{
ServerName: e.SMTPSettings.SMTPServer,
InsecureSkipVerify: !e.SMTPSettings.SMTPVerifySSLCerts,
}
var client *smtp.Client
if e.SMTPSettings.SMTPEnableTLS {
client, err = dialTimeout(smtpHost, tlsConfig)
} else {
client, err = dialTimeout(smtpHost, nil)
}
if err != nil {
return fmt.Errorf("could not dial smtp host: %w", err)
}
defer client.Close()
if e.SMTPSettings.SMTPDomain != "" {
if err = client.Hello(e.SMTPSettings.SMTPDomain); err != nil {
return fmt.Errorf("client hello error: %w", err)
}
}
if e.SMTPSettings.SMTPEnableStartTLS {
if ok, _ := client.Extension("STARTTLS"); ok {
if err = client.StartTLS(tlsConfig); err != nil {
// Surface a prescriptive error only when the user has SSL/TLS
// off and SSL cert verification on.
if !e.SMTPSettings.SMTPEnableTLS && e.SMTPSettings.SMTPVerifySSLCerts {
return ErrSTARTTLSWithoutSSLTLS
}
return fmt.Errorf("startTLS error: %w", err)
}
}
}
if auth != nil {
if err = client.Auth(auth); err != nil {
return fmt.Errorf("client auth error: %w", err)
}
}
if err = client.Mail(e.SMTPSettings.SMTPSenderAddress); err != nil {
return fmt.Errorf("could not issue mail to provided address: %w", err)
}
for _, recip := range e.To {
if err = client.Rcpt(recip); err != nil {
return fmt.Errorf("failed to get recipient: %w", err)
}
}
writer, err := client.Data()
if err != nil {
return fmt.Errorf("getting client data: %w", err)
}
_, err = writer.Write(msg)
if err != nil {
return fmt.Errorf("failed to write: %w", err)
}
if err = writer.Close(); err != nil {
return fmt.Errorf("failed to close writer: %w", err)
}
if err := client.Quit(); err != nil {
// Ignore EOF errors on quit, which can happen if the server
// closes the connection after the message is sent.
if !errors.Is(err, io.EOF) {
return fmt.Errorf("error on client quit: %w", err)
}
}
return nil
}
const dialTimeoutDuration = 28 * time.Second
// dialTimeout sets a timeout on net.Dial to prevent email from attempting to
// send indefinitely.
func dialTimeout(addr string, tlsConfig *tls.Config) (client *smtp.Client, err error) {
// Ensure that errors are always returned after at least 5s to
// eliminate (some) timing attacks (in which a malicious user tries to
// port scan using the email functionality in Fleet)
c := time.After(30 * time.Second)
defer func() {
if err != nil {
// Wait until timer has elapsed to return anything
<-c
}
}()
var conn net.Conn
if tlsConfig == nil {
conn, err = net.DialTimeout("tcp", addr, dialTimeoutDuration)
} else {
conn, err = tls.DialWithDialer(&net.Dialer{Timeout: dialTimeoutDuration}, "tcp", addr, tlsConfig)
}
if err != nil {
return nil, fmt.Errorf("dialing with timeout: %w", err)
}
host, _, err := net.SplitHostPort(addr)
if err != nil {
return nil, fmt.Errorf("split host port: %w", err)
}
// Set a deadline to ensure we time out quickly when there is a TCP
// server listening but it's not an SMTP server (otherwise this seems
// to time out in 20s)
_ = conn.SetDeadline(time.Now().Add(28 * time.Second))
client, err = smtp.NewClient(conn, host)
if err != nil {
return nil, fmt.Errorf("SMTP connection error: %w", err)
}
// Clear deadlines
_ = conn.SetDeadline(time.Time{})
return client, nil
}
// SMTPTestMailer is used to build an email message that will be used as
// a test message when testing SMTP configuration
type SMTPTestMailer struct {
BaseURL template.URL
AssetURL template.URL
CurrentYear int
}
func (m *SMTPTestMailer) Message() ([]byte, error) {
m.CurrentYear = time.Now().Year()
t, err := server.GetTemplate("server/mail/templates/smtp_setup.html", "email_template")
if err != nil {
return nil, err
}
var msg bytes.Buffer
if err = t.Execute(&msg, m); err != nil {
return nil, err
}
return msg.Bytes(), nil
}