Files
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

113 lines
3.1 KiB
Go

package mail
import (
"context"
"errors"
"fmt"
"net/url"
"github.com/aws/aws-sdk-go-v2/aws"
aws_config "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/ses"
"github.com/aws/aws-sdk-go-v2/service/ses/types"
"github.com/fleetdm/fleet/v4/server/aws_common"
"github.com/fleetdm/fleet/v4/server/fleet"
)
type fleetSESSender interface {
SendRawEmail(ctx context.Context, input *ses.SendRawEmailInput, optFns ...func(*ses.Options)) (*ses.SendRawEmailOutput, error)
}
type sesSender struct {
client fleetSESSender
sourceArn string
senderDomain string
}
func getFromSES(e fleet.Email, senderDomain string) (string, error) {
if senderDomain != "" {
return fmt.Sprintf("From: do-not-reply@%s\r\n", senderDomain), nil
}
serverURL, err := url.Parse(e.ServerURL)
if err != nil || len(serverURL.Host) == 0 {
return "", fmt.Errorf("failed to parse server url %s err: %w", e.ServerURL, err)
}
return fmt.Sprintf("From: %s\r\n", fmt.Sprintf("do-not-reply@%s", serverURL.Host)), nil
}
func (s *sesSender) SendEmail(ctx context.Context, e fleet.Email) error {
if s.client == nil {
return errors.New("ses sender not configured")
}
msg, err := getMessageBody(e, func(e fleet.Email) (string, error) {
return getFromSES(e, s.senderDomain)
})
if err != nil {
return err
}
return s.sendMail(ctx, e, msg)
}
func (s *sesSender) CanSendEmail(smtpSettings fleet.SMTPSettings) bool {
return s.client != nil
}
func NewSESSender(region, endpointURL, id, secret, stsAssumeRoleArn, stsExternalID, sourceArn, senderDomain string) (*sesSender, error) {
var opts []func(*aws_config.LoadOptions) error
// The service endpoint is deprecated, but we still set it
// in case users are using it.
if endpointURL != "" {
opts = append(opts, aws_config.WithEndpointResolver(aws.EndpointResolverFunc(
func(service, region string) (aws.Endpoint, error) {
return aws.Endpoint{
URL: endpointURL,
}, nil
})),
)
}
// Only provide static credentials if we have them
// otherwise use the default credentials provider chain.
if id != "" && secret != "" {
opts = append(opts,
aws_config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(id, secret, "")),
)
}
opts = append(opts, aws_config.WithRegion(region))
conf, err := aws_config.LoadDefaultConfig(context.Background(), opts...)
if err != nil {
return nil, fmt.Errorf("failed to create default config: %w", err)
}
if stsAssumeRoleArn != "" {
conf, err = aws_common.ConfigureAssumeRoleProvider(conf, opts, stsAssumeRoleArn, stsExternalID)
if err != nil {
return nil, fmt.Errorf("failed to configure assume role provider: %w", err)
}
}
sesClient := ses.NewFromConfig(conf)
return &sesSender{
client: sesClient,
sourceArn: sourceArn,
senderDomain: senderDomain,
}, nil
}
func (s *sesSender) sendMail(ctx context.Context, e fleet.Email, msg []byte) error {
_, err := s.client.SendRawEmail(ctx, &ses.SendRawEmailInput{
Destinations: e.To,
FromArn: &s.sourceArn,
RawMessage: &types.RawMessage{Data: msg},
SourceArn: &s.sourceArn,
})
if err != nil {
return err
}
return nil
}