diff --git a/changes/issue-10873-email-via-native-ses b/changes/issue-10873-email-via-native-ses
new file mode 100644
index 0000000000..705c104fca
--- /dev/null
+++ b/changes/issue-10873-email-via-native-ses
@@ -0,0 +1 @@
+* introduce new email backend capable of sending email directly using SES APIs
\ No newline at end of file
diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go
index 96070469f6..faa5d07101 100644
--- a/cmd/fleet/serve.go
+++ b/cmd/fleet/serve.go
@@ -162,7 +162,6 @@ the way that the Fleet server works.
var ds fleet.Datastore
var carveStore fleet.CarveStore
var installerStore fleet.InstallerStore
- mailService := mail.NewService()
opts := []mysql.DBOption{mysql.Logger(logger), mysql.WithFleetConfig(&config)}
if config.MysqlReadReplica.Address != "" {
@@ -545,6 +544,19 @@ the way that the Fleet server works.
initFatal(err, "saving app config")
}
+ // setup mail service
+ if appCfg.SMTPSettings.SMTPEnabled {
+ // if SMTP is already enabled then default the backend to empty string, which fill force load the SMTP implementation
+ if config.Email.EmailBackend != "" {
+ config.Email.EmailBackend = ""
+ level.Warn(logger).Log("msg", "SMTP is already enabled, first disable SMTP to utilize a different email backend")
+ }
+ }
+ mailService, err := mail.NewService(config)
+ if err != nil {
+ level.Error(logger).Log("err", err, "msg", "failed to configure mailing service")
+ }
+
cronSchedules := fleet.NewCronSchedules()
baseCtx := licensectx.NewContext(context.Background(), license)
@@ -600,6 +612,15 @@ the way that the Fleet server works.
}
}
+ // err = svc.RequestPasswordReset(context.Background(), "admin@fleetdm.com")
+ // if err != nil {
+ // level.Error(logger).Log("err", err)
+ // }
+ // err = svc.ResetPassword(context.Background(), "dnF5N1QwUmdWNWhzeDhsUjQxdW5BRmtQRCtzS3FyMkk=", "password1234!")
+ // if err != nil {
+ // level.Error(logger).Log("err", err)
+ // }
+
instanceID, err := server.GenerateRandomText(64)
if err != nil {
initFatal(errors.New("Error generating random instance identifier"), "")
diff --git a/docs/Deploying/Configuration.md b/docs/Deploying/Configuration.md
index 65880bb6e6..7841fd8886 100644
--- a/docs/Deploying/Configuration.md
+++ b/docs/Deploying/Configuration.md
@@ -2057,6 +2057,107 @@ kafkarest:
status_topic: osquery_status
```
+#### Email backend
+
+By default, the SMTP backend is enabled and no additional configuration is required on the server settings. You can configure
+SMTP through the [Fleet console UI](https://fleetdm.com/docs/using-fleet/configuration-files#smtp-settings). However, you can also
+configure Fleet to use AWS SES natively rather than through SMTP.
+
+##### backend
+
+Enable SES support for Fleet. You must also configure the ses configurations such as `ses.source_arn`
+
+````yaml
+email:
+ backend: ses
+````
+
+#### SES
+
+The following configurations only have an effect if SES email backend is enabled `FLEET_EMAIL_BACKEND=ses`.
+
+##### ses_region
+
+This flag only has effect if `email.backend` or `FLEET_EMAIL_BACKEND` is set to `ses`.
+
+AWS region to use for SES connection.
+
+- Default value: none
+- Environment variable: `FLEET_SES_REGION`
+- Config file format:
+ ```yaml
+ ses:
+ region: us-east-2
+ ```
+
+##### ses_access_key_id
+
+This flag only has effect if `email.backend` or `FLEET_EMAIL_BACKEND` is set to `ses`.
+
+If `ses_access_key_id` and `ses_secret_access_key` are omitted, Fleet
+will try to use
+[AWS STS](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp.html)
+credentials.
+
+AWS access key ID to use for Lambda authentication.
+
+- Default value: none
+- Environment variable: `FLEET_SES_ACCESS_KEY_ID`
+- Config file format:
+ ```
+ ses:
+ access_key_id: AKIAIOSFODNN7EXAMPLE
+ ```
+
+##### ses_secret_access_key
+
+This flag only has effect if `email.backend` or `FLEET_EMAIL_BACKEND` is set to `ses`.
+
+If `ses_access_key_id` and `ses_secret_access_key` are omitted, Fleet
+will try to use
+[AWS STS](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp.html)
+credentials.
+
+AWS secret access key to use for SES authentication.
+
+- Default value: none
+- Environment variable: `FLEET_SES_SECRET_ACCESS_KEY`
+- Config file format:
+ ```yaml
+ ses:
+ secret_access_key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
+ ```
+
+##### ses_sts_assume_role_arn
+
+This flag only has effect if `email.backend` or `FLEET_EMAIL_BACKEND` is set to `ses`.
+
+AWS STS role ARN to use for SES authentication.
+
+- Default value: none
+- Environment variable: `FLEET_SES_STS_ASSUME_ROLE_ARN`
+- Config file format:
+ ```yaml
+ ses:
+ sts_assume_role_arn: arn:aws:iam::1234567890:role/ses-role
+ ```
+
+##### ses_source_arn
+
+This flag only has effect if `email.backend` or `FLEET_EMAIL_BACKEND` is set to `ses`. This configuration **is
+required** when using the SES email backend.
+
+The ARN of the identity that is associated with the sending authorization policy that permits you to send
+for the email address specified in the Source parameter of SendRawEmail.
+
+- Default value: none
+- Environment variable: `FLEET_SES_SOURCE_ARN`
+- Config file format:
+ ```yaml
+ ses:
+ sts_assume_role_arn: arn:aws:iam::1234567890:role/ses-role
+ ```
+
#### S3 file carving backend
##### s3_bucket
diff --git a/infrastructure/dogfood/terraform/aws-tf-module/github.tf b/infrastructure/dogfood/terraform/aws-tf-module/github.tf
index eaca3ac0b6..ba6dc262ab 100644
--- a/infrastructure/dogfood/terraform/aws-tf-module/github.tf
+++ b/infrastructure/dogfood/terraform/aws-tf-module/github.tf
@@ -95,6 +95,7 @@ data "aws_iam_policy_document" "gha-permissions" {
"firehose:*",
"athena:*",
"glue:*",
+ "ses:*",
]
resources = ["*"]
}
diff --git a/infrastructure/dogfood/terraform/aws-tf-module/main.tf b/infrastructure/dogfood/terraform/aws-tf-module/main.tf
index a77a400465..d0af0d7bc2 100644
--- a/infrastructure/dogfood/terraform/aws-tf-module/main.tf
+++ b/infrastructure/dogfood/terraform/aws-tf-module/main.tf
@@ -87,9 +87,9 @@ module "main" {
policy_name = "${local.customer}-iam-policy-execution"
}
}
- extra_iam_policies = concat(module.firehose-logging.fleet_extra_iam_policies, module.osquery-carve.fleet_extra_iam_policies)
+ extra_iam_policies = concat(module.firehose-logging.fleet_extra_iam_policies, module.osquery-carve.fleet_extra_iam_policies, module.ses.fleet_extra_iam_policies)
extra_execution_iam_policies = concat(module.mdm.extra_execution_iam_policies, [aws_iam_policy.sentry.arn])
- extra_environment_variables = merge(module.mdm.extra_environment_variables, module.firehose-logging.fleet_extra_environment_variables, module.osquery-carve.fleet_extra_environment_variables, local.extra_environment_variables)
+ extra_environment_variables = merge(module.mdm.extra_environment_variables, module.firehose-logging.fleet_extra_environment_variables, module.osquery-carve.fleet_extra_environment_variables, module.ses.fleet_extra_environment_variables, local.extra_environment_variables)
extra_secrets = merge(module.mdm.extra_secrets, local.sentry_secrets)
}
alb_config = {
@@ -289,3 +289,8 @@ module "notify_slack" {
slack_channel = "#help-p1"
slack_username = "monitoring"
}
+
+module "ses" {
+ source = "github.com/fleetdm/fleet//terraform/addons/ses?ref=main"
+ domain = "dogfood.fleetdm.com"
+}
diff --git a/server/config/config.go b/server/config/config.go
index 25b1c092df..2f5eb32d25 100644
--- a/server/config/config.go
+++ b/server/config/config.go
@@ -259,6 +259,20 @@ type KinesisConfig struct {
AuditStream string `yaml:"audit_stream"`
}
+// SESConfig defines configs for the AWS SES service for emailing
+type SESConfig struct {
+ Region string
+ EndpointURL string `yaml:"endpoint_url"`
+ AccessKeyID string `yaml:"access_key_id"`
+ SecretAccessKey string `yaml:"secret_access_key"`
+ StsAssumeRoleArn string `yaml:"sts_assume_role_arn"`
+ SourceArn string `yaml:"source_arn"`
+}
+
+type EmailConfig struct {
+ EmailBackend string `yaml:"backend"`
+}
+
// LambdaConfig defines configs for the AWS Lambda logging plugin
type LambdaConfig struct {
Region string
@@ -391,6 +405,8 @@ type FleetConfig struct {
Kinesis KinesisConfig
Lambda LambdaConfig
S3 S3Config
+ Email EmailConfig
+ SES SESConfig
PubSub PubSubConfig
Filesystem FilesystemConfig
KafkaREST KafkaRESTConfig
@@ -862,6 +878,16 @@ func (man Manager) addConfigs() {
man.addConfigString("logging.tracing_type", "opentelemetry",
"Select the kind of tracing, defaults to opentelemetry, can also be elasticapm")
+ // Email
+ man.addConfigString("email.backend", "", "Provide the email backend type, acceptable values are currently \"ses\" and \"default\" or empty string which will default to SMTP")
+ // SES
+ man.addConfigString("ses.region", "", "AWS Region to use")
+ man.addConfigString("ses.endpoint_url", "", "AWS Service Endpoint to use (leave empty for default service endpoints)")
+ man.addConfigString("ses.access_key_id", "", "Access Key ID for AWS authentication")
+ man.addConfigString("ses.secret_access_key", "", "Secret Access Key for AWS authentication")
+ man.addConfigString("ses.sts_assume_role_arn", "", "ARN of role to assume for AWS")
+ man.addConfigString("ses.source_arn", "", "ARN of the identity that is associated with the sending authorization policy that permits you to send for the email address specified in the Source parameter")
+
// Firehose
man.addConfigString("firehose.region", "", "AWS Region to use")
man.addConfigString("firehose.endpoint_url", "",
@@ -1185,6 +1211,17 @@ func (man Manager) LoadConfig() FleetConfig {
DisableSSL: man.getConfigBool("s3.disable_ssl"),
ForceS3PathStyle: man.getConfigBool("s3.force_s3_path_style"),
},
+ Email: EmailConfig{
+ EmailBackend: man.getConfigString("email.backend"),
+ },
+ SES: SESConfig{
+ Region: man.getConfigString("ses.region"),
+ EndpointURL: man.getConfigString("ses.endpoint_url"),
+ AccessKeyID: man.getConfigString("ses.access_key_id"),
+ SecretAccessKey: man.getConfigString("ses.secret_access_key"),
+ StsAssumeRoleArn: man.getConfigString("ses.sts_assume_role_arn"),
+ SourceArn: man.getConfigString("ses.source_arn"),
+ },
PubSub: PubSubConfig{
Project: man.getConfigString("pubsub.project"),
StatusTopic: man.getConfigString("pubsub.status_topic"),
diff --git a/server/fleet/app.go b/server/fleet/app.go
index cd66f37680..81fff04c9c 100644
--- a/server/fleet/app.go
+++ b/server/fleet/app.go
@@ -358,6 +358,7 @@ type enrichedAppConfigFields struct {
Vulnerabilities *VulnerabilitiesConfig `json:"vulnerabilities,omitempty"`
License *LicenseInfo `json:"license,omitempty"`
Logging *Logging `json:"logging,omitempty"`
+ Email *EmailConfig `json:"email,omitempty"`
}
// UnmarshalJSON implements the json.Unmarshaler interface to make sure we serialize
@@ -741,6 +742,16 @@ type Logging struct {
Audit LoggingPlugin `json:"audit"`
}
+type EmailConfig struct {
+ Backend string `json:"backend"`
+ Config interface{} `json:"config"`
+}
+
+type SESConfig struct {
+ Region string `json:"region"`
+ SourceARN string `json:"source_arn"`
+}
+
type UpdateIntervalConfig struct {
OSQueryDetail time.Duration `json:"osquery_detail"`
OSQueryPolicy time.Duration `json:"osquery_policy"`
diff --git a/server/fleet/service.go b/server/fleet/service.go
index ab14029ffc..bdd932b8f3 100644
--- a/server/fleet/service.go
+++ b/server/fleet/service.go
@@ -85,7 +85,7 @@ type Service interface {
// TODO: find if there's a better way to accomplish this and standardize.
SetEnterpriseOverrides(overrides EnterpriseOverrides)
- ///////////////////////////////////////////////////////////////////////////////
+ // /////////////////////////////////////////////////////////////////////////////
// UserService contains methods for managing a Fleet User.
// CreateUserFromInvite creates a new User from a request payload when there is already an existing invitation.
@@ -145,7 +145,7 @@ type Service interface {
// write the new email address to user.
ChangeUserEmail(ctx context.Context, token string) (string, error)
- ///////////////////////////////////////////////////////////////////////////////
+ // /////////////////////////////////////////////////////////////////////////////
// Session
// InitiateSSO is used to initiate an SSO session and returns a URL that can be used in a redirect to the IDP.
@@ -173,7 +173,7 @@ type Service interface {
GetSessionByKey(ctx context.Context, key string) (session *Session, err error)
DeleteSession(ctx context.Context, id uint) (err error)
- ///////////////////////////////////////////////////////////////////////////////
+ // /////////////////////////////////////////////////////////////////////////////
// PackService is the service interface for managing query packs.
// ApplyPackSpecs applies a list of PackSpecs to the datastore, creating and updating packs as necessary.
@@ -206,7 +206,7 @@ type Service interface {
// ListPacksForHost lists the packs that a host should execute.
ListPacksForHost(ctx context.Context, hid uint) (packs []*Pack, err error)
- ///////////////////////////////////////////////////////////////////////////////
+ // /////////////////////////////////////////////////////////////////////////////
// LabelService
// ApplyLabelSpecs applies a list of LabelSpecs to the datastore, creating and updating labels as necessary.
@@ -229,7 +229,7 @@ type Service interface {
// ListHostsInLabel returns a slice of hosts in the label with the given ID.
ListHostsInLabel(ctx context.Context, lid uint, opt HostListOptions) ([]*Host, error)
- ///////////////////////////////////////////////////////////////////////////////
+ // /////////////////////////////////////////////////////////////////////////////
// QueryService
// ApplyQuerySpecs applies a list of queries (creating or updating them as necessary)
@@ -252,7 +252,7 @@ type Service interface {
// along with any error.
DeleteQueries(ctx context.Context, ids []uint) (uint, error)
- ///////////////////////////////////////////////////////////////////////////////
+ // /////////////////////////////////////////////////////////////////////////////
// CampaignService defines the distributed query campaign related service methods
// NewDistributedQueryCampaignByNames creates a new distributed query campaign with the provided query (or the query
@@ -276,14 +276,14 @@ type Service interface {
CompleteCampaign(ctx context.Context, campaign *DistributedQueryCampaign) error
RunLiveQueryDeadline(ctx context.Context, queryIDs []uint, hostIDs []uint, deadline time.Duration) ([]QueryCampaignResult, int)
- ///////////////////////////////////////////////////////////////////////////////
+ // /////////////////////////////////////////////////////////////////////////////
// AgentOptionsService
// AgentOptionsForHost gets the agent options for the provided host. The host information should be used for
// filtering based on team, platform, etc.
AgentOptionsForHost(ctx context.Context, hostTeamID *uint, hostPlatform string) (json.RawMessage, error)
- ///////////////////////////////////////////////////////////////////////////////
+ // /////////////////////////////////////////////////////////////////////////////
// HostService
// AuthenticateDevice loads host identified by the device's auth token.
@@ -347,7 +347,7 @@ type Service interface {
// Name cannot be used without version, and conversely, version cannot be used without name.
OSVersions(ctx context.Context, teamID *uint, platform *string, name *string, version *string) (*OSVersions, error)
- ///////////////////////////////////////////////////////////////////////////////
+ // /////////////////////////////////////////////////////////////////////////////
// AppConfigService provides methods for configuring the Fleet application
NewAppConfig(ctx context.Context, p AppConfig) (info *AppConfig, err error)
@@ -378,6 +378,9 @@ type Service interface {
// LoggingConfig parses config.FleetConfig instance and returns a Logging.
LoggingConfig(ctx context.Context) (*Logging, error)
+ // EmailConfig parses config.FleetConfig and returns an EmailConfig
+ EmailConfig(ctx context.Context) (*EmailConfig, error)
+
// UpdateIntervalConfig returns the duration for different update intervals configured in osquery
UpdateIntervalConfig(ctx context.Context) (*UpdateIntervalConfig, error)
@@ -385,7 +388,7 @@ type Service interface {
// the fleet instance.
VulnerabilitiesConfig(ctx context.Context) (*VulnerabilitiesConfig, error)
- ///////////////////////////////////////////////////////////////////////////////
+ // /////////////////////////////////////////////////////////////////////////////
// InviteService contains methods for a service which deals with user invites.
// InviteNewUser creates an invite for a new user to join Fleet.
@@ -402,7 +405,7 @@ type Service interface {
UpdateInvite(ctx context.Context, id uint, payload InvitePayload) (*Invite, error)
- ///////////////////////////////////////////////////////////////////////////////
+ // /////////////////////////////////////////////////////////////////////////////
// TargetService **NOTE: SearchTargets will be removed in Fleet 5.0**
// SearchTargets will accept a search query, a slice of IDs of hosts to omit, and a slice of IDs of labels to omit,
@@ -418,7 +421,7 @@ type Service interface {
// observer role for.
CountHostsInTargets(ctx context.Context, queryID *uint, targets HostTargets) (*TargetMetrics, error)
- ///////////////////////////////////////////////////////////////////////////////
+ // /////////////////////////////////////////////////////////////////////////////
// ScheduledQueryService
GetScheduledQueriesInPack(ctx context.Context, id uint, opts ListOptions) (queries []*ScheduledQuery, err error)
@@ -427,7 +430,7 @@ type Service interface {
DeleteScheduledQuery(ctx context.Context, id uint) (err error)
ModifyScheduledQuery(ctx context.Context, id uint, p ScheduledQueryPayload) (query *ScheduledQuery, err error)
- ///////////////////////////////////////////////////////////////////////////////
+ // /////////////////////////////////////////////////////////////////////////////
// StatusService
// StatusResultStore returns nil if the result store is functioning correctly, or an error indicating the problem.
@@ -437,7 +440,7 @@ type Service interface {
// error indicating the problem.
StatusLiveQuery(ctx context.Context) error
- ///////////////////////////////////////////////////////////////////////////////
+ // /////////////////////////////////////////////////////////////////////////////
// CarveService
CarveBegin(ctx context.Context, payload CarveBeginPayload) (*CarveMetadata, error)
@@ -446,7 +449,7 @@ type Service interface {
ListCarves(ctx context.Context, opt CarveListOptions) ([]*CarveMetadata, error)
GetBlock(ctx context.Context, carveId, blockId int64) ([]byte, error)
- ///////////////////////////////////////////////////////////////////////////////
+ // /////////////////////////////////////////////////////////////////////////////
// TeamService
// NewTeam creates a new team.
@@ -476,7 +479,7 @@ type Service interface {
// ApplyTeamSpecs applies the changes for each team as defined in the specs.
ApplyTeamSpecs(ctx context.Context, specs []*TeamSpec, applyOpts ApplySpecOptions) error
- ///////////////////////////////////////////////////////////////////////////////
+ // /////////////////////////////////////////////////////////////////////////////
// ActivitiesService
// NewActivity creates the given activity on the datastore.
@@ -490,13 +493,13 @@ type Service interface {
// logins, running a live query, etc.
ListActivities(ctx context.Context, opt ListActivitiesOptions) ([]*Activity, *PaginationMetadata, error)
- ///////////////////////////////////////////////////////////////////////////////
+ // /////////////////////////////////////////////////////////////////////////////
// UserRolesService
// ApplyUserRolesSpecs applies a list of user global and team role changes
ApplyUserRolesSpecs(ctx context.Context, specs UsersRoleSpec) error
- ///////////////////////////////////////////////////////////////////////////////
+ // /////////////////////////////////////////////////////////////////////////////
// GlobalScheduleService
GlobalScheduleQuery(ctx context.Context, sq *ScheduledQuery) (*ScheduledQuery, error)
@@ -504,12 +507,12 @@ type Service interface {
ModifyGlobalScheduledQueries(ctx context.Context, id uint, q ScheduledQueryPayload) (*ScheduledQuery, error)
DeleteGlobalScheduledQueries(ctx context.Context, id uint) error
- ///////////////////////////////////////////////////////////////////////////////
+ // /////////////////////////////////////////////////////////////////////////////
// TranslatorService
Translate(ctx context.Context, payloads []TranslatePayload) ([]TranslatePayload, error)
- ///////////////////////////////////////////////////////////////////////////////
+ // /////////////////////////////////////////////////////////////////////////////
// TeamScheduleService
TeamScheduleQuery(ctx context.Context, teamID uint, sq *ScheduledQuery) (*ScheduledQuery, error)
@@ -519,7 +522,7 @@ type Service interface {
) (*ScheduledQuery, error)
DeleteTeamScheduledQueries(ctx context.Context, teamID uint, id uint) error
- ///////////////////////////////////////////////////////////////////////////////
+ // /////////////////////////////////////////////////////////////////////////////
// GlobalPolicyService
NewGlobalPolicy(ctx context.Context, p PolicyPayload) (*Policy, error)
@@ -529,14 +532,14 @@ type Service interface {
GetPolicyByIDQueries(ctx context.Context, policyID uint) (*Policy, error)
ApplyPolicySpecs(ctx context.Context, policies []*PolicySpec) error
- ///////////////////////////////////////////////////////////////////////////////
+ // /////////////////////////////////////////////////////////////////////////////
// Software
ListSoftware(ctx context.Context, opt SoftwareListOptions) ([]Software, error)
SoftwareByID(ctx context.Context, id uint, includeCVEScores bool) (*Software, error)
CountSoftware(ctx context.Context, opt SoftwareListOptions) (int, error)
- ///////////////////////////////////////////////////////////////////////////////
+ // /////////////////////////////////////////////////////////////////////////////
// Team Policies
NewTeamPolicy(ctx context.Context, teamID uint, p PolicyPayload) (*Policy, error)
@@ -545,18 +548,18 @@ type Service interface {
ModifyTeamPolicy(ctx context.Context, teamID uint, id uint, p ModifyPolicyPayload) (*Policy, error)
GetTeamPolicyByIDQueries(ctx context.Context, teamID uint, policyID uint) (*Policy, error)
- ///////////////////////////////////////////////////////////////////////////////
+ // /////////////////////////////////////////////////////////////////////////////
// Geolocation
LookupGeoIP(ctx context.Context, ip string) *GeoLocation
- ///////////////////////////////////////////////////////////////////////////////
+ // /////////////////////////////////////////////////////////////////////////////
// Installers
GetInstaller(ctx context.Context, installer Installer) (io.ReadCloser, int64, error)
CheckInstallerExistence(ctx context.Context, installer Installer) error
- ///////////////////////////////////////////////////////////////////////////////
+ // /////////////////////////////////////////////////////////////////////////////
// Apple MDM
GetAppleMDM(ctx context.Context) (*AppleMDM, error)
@@ -675,7 +678,7 @@ type Service interface {
// error can be raised to the user. See TODO for more details.
VerifyMDMAppleConfigured(ctx context.Context) error
- ///////////////////////////////////////////////////////////////////////////////
+ // /////////////////////////////////////////////////////////////////////////////
// CronSchedulesService
// TriggerCronSchedule attempts to trigger an ad-hoc run of the named cron schedule.
diff --git a/server/mail/mail.go b/server/mail/mail.go
index 4617e63005..71b6732b77 100644
--- a/server/mail/mail.go
+++ b/server/mail/mail.go
@@ -13,11 +13,17 @@ import (
"time"
"github.com/fleetdm/fleet/v4/server/bindata"
+ "github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/fleet"
)
-func NewService() fleet.MailService {
- return &mailService{}
+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.SourceArn)
+ default:
+ return &mailService{}, nil
+ }
}
type mailService struct{}
@@ -27,7 +33,7 @@ type sender interface {
}
func Test(mailer fleet.MailService, e fleet.Email) error {
- mailBody, err := getMessageBody(e)
+ mailBody, err := getMessageBody(e, getFrom)
if err != nil {
return fmt.Errorf("failed to get message body: %w", err)
}
@@ -50,7 +56,9 @@ const (
PortTLS = 587
)
-func getMessageBody(e fleet.Email) ([]byte, error) {
+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)
@@ -58,16 +66,23 @@ func getMessageBody(e fleet.Email) ([]byte, error) {
mime := `MIME-version: 1.0;` + "\r\n"
content := `Content-Type: text/html; charset="UTF-8";` + "\r\n"
subject := "Subject: " + e.Subject + "\r\n"
- from := "From: " + e.Config.SMTPSettings.SMTPSenderAddress + "\r\n"
+ from, err := f(e)
+ if err != nil {
+ return nil, fmt.Errorf("failed to obtain from address: %w", err)
+ }
msg := []byte(subject + from + mime + content + "\r\n" + string(body) + "\r\n")
return msg, nil
}
+func getFrom(e fleet.Email) (string, error) {
+ return "From: " + e.Config.SMTPSettings.SMTPSenderAddress + "\r\n", nil
+}
+
func (m mailService) SendEmail(e fleet.Email) error {
if !e.Config.SMTPSettings.SMTPConfigured {
return errors.New("email not configured")
}
- msg, err := getMessageBody(e)
+ msg, err := getMessageBody(e, getFrom)
if err != nil {
return err
}
diff --git a/server/mail/mail_test.go b/server/mail/mail_test.go
index ba964899a5..fef8fbc276 100644
--- a/server/mail/mail_test.go
+++ b/server/mail/mail_test.go
@@ -1,9 +1,11 @@
package mail
import (
+ "fmt"
"os"
"testing"
+ "github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/stretchr/testify/assert"
@@ -26,7 +28,8 @@ func TestMail(t *testing.T) {
}
for _, f := range testFunctions {
- r := NewService()
+ r, err := NewService(config.TestConfig())
+ require.NoError(t, err)
t.Run(test.FunctionName(f), func(t *testing.T) {
f(t, r)
@@ -178,3 +181,31 @@ func TestTemplateProcessor(t *testing.T) {
require.Nil(t, err)
assert.NotNil(t, out)
}
+
+func Test_getFrom(t *testing.T) {
+ type args struct {
+ e fleet.Email
+ }
+ tests := []struct {
+ name string
+ args args
+ want string
+ wantErr assert.ErrorAssertionFunc
+ }{
+ {
+ name: "should return SMTP formatted From string",
+ args: args{e: fleet.Email{Config: &fleet.AppConfig{SMTPSettings: fleet.SMTPSettings{SMTPSenderAddress: "foo@bar.com"}}}},
+ want: "From: foo@bar.com\r\n",
+ wantErr: assert.NoError,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := getFrom(tt.args.e)
+ if !tt.wantErr(t, err, fmt.Sprintf("getFrom(%v)", tt.args.e)) {
+ return
+ }
+ assert.Equalf(t, tt.want, got, "getFrom(%v)", tt.args.e)
+ })
+ }
+}
diff --git a/server/mail/ses.go b/server/mail/ses.go
new file mode 100644
index 0000000000..fd4b0bf5c7
--- /dev/null
+++ b/server/mail/ses.go
@@ -0,0 +1,95 @@
+package mail
+
+import (
+ "errors"
+ "fmt"
+ "net/url"
+
+ "github.com/aws/aws-sdk-go/aws"
+ "github.com/aws/aws-sdk-go/aws/credentials"
+ "github.com/aws/aws-sdk-go/aws/credentials/stscreds"
+ "github.com/aws/aws-sdk-go/aws/session"
+ "github.com/aws/aws-sdk-go/service/ses"
+ "github.com/fleetdm/fleet/v4/server/fleet"
+)
+
+type fleetSESSender interface {
+ SendRawEmail(input *ses.SendRawEmailInput) (*ses.SendRawEmailOutput, error)
+}
+
+type sesSender struct {
+ client fleetSESSender
+ sourceArn string
+}
+
+func getFromSES(e fleet.Email) (string, error) {
+ if e.Config == nil {
+ return "", errors.New("app config is nil")
+ }
+ serverURL, err := url.Parse(e.Config.ServerSettings.ServerURL)
+ if err != nil || len(serverURL.Host) == 0 {
+ return "", fmt.Errorf("failed to parse server url %s err: %w", e.Config.ServerSettings.ServerURL, err)
+ }
+ return fmt.Sprintf("From: %s\r\n", fmt.Sprintf("do-not-reply@%s", serverURL.Host)), nil
+}
+
+func (s *sesSender) SendEmail(e fleet.Email) error {
+ if s.client == nil {
+ return errors.New("ses sender not configured")
+ }
+ msg, err := getMessageBody(e, getFromSES)
+ if err != nil {
+ return err
+ }
+ return s.sendMail(e, msg)
+}
+
+func NewSESSender(region, endpointURL, id, secret, stsAssumeRoleArn, sourceArn string) (*sesSender, error) {
+ conf := &aws.Config{
+ Region: ®ion,
+ Endpoint: &endpointURL, // empty string or nil will use default values
+ }
+
+ // Only provide static credentials if we have them
+ // otherwise use the default credentials provider chain
+ if id != "" && secret != "" {
+ conf.Credentials = credentials.NewStaticCredentials(id, secret, "")
+ }
+
+ sess, err := session.NewSession(conf)
+ if err != nil {
+ return nil, fmt.Errorf("create SES client: %w", err)
+ }
+
+ if stsAssumeRoleArn != "" {
+ creds := stscreds.NewCredentials(sess, stsAssumeRoleArn)
+ conf.Credentials = creds
+
+ sess, err = session.NewSession(conf)
+
+ if err != nil {
+ return nil, fmt.Errorf("create SES client: %w", err)
+ }
+ }
+ return &sesSender{client: ses.New(sess), sourceArn: sourceArn}, nil
+}
+
+func (s *sesSender) sendMail(e fleet.Email, msg []byte) error {
+ toAddresses := make([]*string, len(e.To))
+ for i := range e.To {
+ t := e.To[i]
+ toAddresses[i] = &t
+ }
+
+ _, err := s.client.SendRawEmail(&ses.SendRawEmailInput{
+ Destinations: toAddresses,
+ FromArn: &s.sourceArn,
+ RawMessage: &ses.RawMessage{Data: msg},
+ SourceArn: &s.sourceArn,
+ })
+
+ if err != nil {
+ return err
+ }
+ return nil
+}
diff --git a/server/mail/ses_test.go b/server/mail/ses_test.go
new file mode 100644
index 0000000000..696b6b2386
--- /dev/null
+++ b/server/mail/ses_test.go
@@ -0,0 +1,146 @@
+package mail
+
+import (
+ "errors"
+ "fmt"
+ "testing"
+
+ "github.com/aws/aws-sdk-go/service/ses"
+ "github.com/fleetdm/fleet/v4/server/fleet"
+ "github.com/stretchr/testify/assert"
+)
+
+func Test_getFromSES(t *testing.T) {
+ type args struct {
+ e fleet.Email
+ }
+ tests := []struct {
+ name string
+ args args
+ want string
+ wantErr assert.ErrorAssertionFunc
+ }{
+ {
+ name: "should return properly formatted SMTP from for use in SES",
+ args: args{e: fleet.Email{Config: &fleet.AppConfig{ServerSettings: fleet.ServerSettings{ServerURL: "https://foobar.fleetdm.com"}}}},
+ want: "From: do-not-reply@foobar.fleetdm.com\r\n",
+ wantErr: assert.NoError,
+ },
+ {
+ name: "should error when we fail to parse fleet server url",
+ args: args{e: fleet.Email{Config: &fleet.AppConfig{ServerSettings: fleet.ServerSettings{ServerURL: "not-a-url"}}}},
+ want: "",
+ wantErr: assert.Error,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := getFromSES(tt.args.e)
+ if !tt.wantErr(t, err, fmt.Sprintf("getFromSES(%v)", tt.args.e)) {
+ return
+ }
+ assert.Equalf(t, tt.want, got, "getFromSES(%v)", tt.args.e)
+ })
+ }
+}
+
+type mockSESSender struct {
+ shouldErr bool
+}
+
+func (m mockSESSender) SendRawEmail(input *ses.SendRawEmailInput) (*ses.SendRawEmailOutput, error) {
+ if m.shouldErr {
+ return nil, errors.New("some error")
+ }
+ return nil, nil
+}
+
+func Test_sesSender_SendEmail(t *testing.T) {
+ type fields struct {
+ client fleetSESSender
+ sourceArn string
+ }
+ type args struct {
+ e fleet.Email
+ }
+ tests := []struct {
+ name string
+ fields fields
+ args args
+ wantErr assert.ErrorAssertionFunc
+ }{
+ {
+ name: "should send email",
+ fields: fields{
+ client: mockSESSender{shouldErr: false},
+ sourceArn: "foo",
+ },
+ args: args{e: fleet.Email{
+ Subject: "Hello from Fleet!",
+ To: []string{"foouser@fleetdm.com"},
+ Config: &fleet.AppConfig{ServerSettings: fleet.ServerSettings{ServerURL: "https://foobar.fleetdm.com"}},
+ Mailer: &SMTPTestMailer{
+ BaseURL: "https://localhost:8080",
+ },
+ }},
+ wantErr: assert.NoError,
+ },
+ {
+ name: "should error when email config is nil",
+ fields: fields{
+ client: mockSESSender{shouldErr: false},
+ sourceArn: "foo",
+ },
+ args: args{e: fleet.Email{
+ Subject: "Hello from Fleet!",
+ To: []string{"foouser@fleetdm.com"},
+ Config: nil,
+ Mailer: &SMTPTestMailer{
+ BaseURL: "https://localhost:8080",
+ },
+ }},
+ wantErr: assert.Error,
+ },
+ {
+ name: "should error when ses client is nil",
+ fields: fields{
+ client: nil,
+ sourceArn: "foo",
+ },
+ args: args{e: fleet.Email{
+ Subject: "Hello from Fleet!",
+ To: []string{"foouser@fleetdm.com"},
+ Config: &fleet.AppConfig{ServerSettings: fleet.ServerSettings{ServerURL: "https://foobar.fleetdm.com"}},
+ Mailer: &SMTPTestMailer{
+ BaseURL: "https://localhost:8080",
+ },
+ }},
+ wantErr: assert.Error,
+ },
+ {
+ name: "should error when ses client returns an error",
+ fields: fields{
+ client: mockSESSender{shouldErr: true},
+ sourceArn: "foo",
+ },
+ args: args{e: fleet.Email{
+ Subject: "Hello from Fleet!",
+ To: []string{"foouser@fleetdm.com"},
+ Config: &fleet.AppConfig{ServerSettings: fleet.ServerSettings{ServerURL: "https://foobar.fleetdm.com"}},
+ Mailer: &SMTPTestMailer{
+ BaseURL: "https://localhost:8080",
+ },
+ }},
+ wantErr: assert.Error,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ s := &sesSender{
+ client: tt.fields.client,
+ sourceArn: tt.fields.sourceArn,
+ }
+ tt.wantErr(t, s.SendEmail(tt.args.e), fmt.Sprintf("SendEmail(%v)", tt.args.e))
+ })
+ }
+}
diff --git a/server/service/appconfig.go b/server/service/appconfig.go
index 0859e2cb4b..c3228871c3 100644
--- a/server/service/appconfig.go
+++ b/server/service/appconfig.go
@@ -23,9 +23,9 @@ import (
"github.com/kolide/kit/version"
)
-////////////////////////////////////////////////////////////////////////////////
+// //////////////////////////////////////////////////////////////////////////////
// Get AppConfig
-////////////////////////////////////////////////////////////////////////////////
+// //////////////////////////////////////////////////////////////////////////////
type appConfigResponse struct {
fleet.AppConfig
@@ -41,6 +41,8 @@ type appConfigResponseFields struct {
License *fleet.LicenseInfo `json:"license,omitempty"`
// Logging is loaded on the fly rather than from the database.
Logging *fleet.Logging `json:"logging,omitempty"`
+ // Email is returned when the email backend is something other than SMTP, for example SES
+ Email *fleet.EmailConfig `json:"email,omitempty"`
// SandboxEnabled is true if fleet serve was ran with server.sandbox_enabled=true
SandboxEnabled bool `json:"sandbox_enabled,omitempty"`
Err error `json:"error,omitempty"`
@@ -82,6 +84,10 @@ func getAppConfigEndpoint(ctx context.Context, request interface{}, svc fleet.Se
if err != nil {
return nil, err
}
+ emailConfig, err := svc.EmailConfig(ctx)
+ if err != nil {
+ return nil, err
+ }
updateIntervalConfig, err := svc.UpdateIntervalConfig(ctx)
if err != nil {
return nil, err
@@ -134,6 +140,7 @@ func getAppConfigEndpoint(ctx context.Context, request interface{}, svc fleet.Se
Vulnerabilities: vulnConfig,
License: license,
Logging: loggingConfig,
+ Email: emailConfig,
SandboxEnabled: svc.SandboxEnabled(),
},
}
@@ -171,9 +178,9 @@ func (svc *Service) AppConfigObfuscated(ctx context.Context) (*fleet.AppConfig,
return ac, nil
}
-////////////////////////////////////////////////////////////////////////////////
+// //////////////////////////////////////////////////////////////////////////////
// Modify AppConfig
-////////////////////////////////////////////////////////////////////////////////
+// //////////////////////////////////////////////////////////////////////////////
type modifyAppConfigRequest struct {
Force bool `json:"-" query:"force,optional"` // if true, bypass strict incoming json validation
@@ -543,9 +550,9 @@ func validateSSOSettings(p fleet.AppConfig, existing *fleet.AppConfig, invalid *
}
}
-////////////////////////////////////////////////////////////////////////////////
+// //////////////////////////////////////////////////////////////////////////////
// Apply enroll secret spec
-////////////////////////////////////////////////////////////////////////////////
+// //////////////////////////////////////////////////////////////////////////////
type applyEnrollSecretSpecRequest struct {
Spec *fleet.EnrollSecretSpec `json:"spec"`
@@ -587,9 +594,9 @@ func (svc *Service) ApplyEnrollSecretSpec(ctx context.Context, spec *fleet.Enrol
return svc.ds.ApplyEnrollSecrets(ctx, nil, spec.Secrets)
}
-////////////////////////////////////////////////////////////////////////////////
+// //////////////////////////////////////////////////////////////////////////////
// Get enroll secret spec
-////////////////////////////////////////////////////////////////////////////////
+// //////////////////////////////////////////////////////////////////////////////
type getEnrollSecretSpecResponse struct {
Spec *fleet.EnrollSecretSpec `json:"spec"`
@@ -618,9 +625,9 @@ func (svc *Service) GetEnrollSecretSpec(ctx context.Context) (*fleet.EnrollSecre
return &fleet.EnrollSecretSpec{Secrets: secrets}, nil
}
-////////////////////////////////////////////////////////////////////////////////
+// //////////////////////////////////////////////////////////////////////////////
// Version
-////////////////////////////////////////////////////////////////////////////////
+// //////////////////////////////////////////////////////////////////////////////
type versionResponse struct {
*version.Info
@@ -646,9 +653,9 @@ func (svc *Service) Version(ctx context.Context) (*version.Info, error) {
return &info, nil
}
-////////////////////////////////////////////////////////////////////////////////
+// //////////////////////////////////////////////////////////////////////////////
// Get Certificate Chain
-////////////////////////////////////////////////////////////////////////////////
+// //////////////////////////////////////////////////////////////////////////////
type getCertificateResponse struct {
CertificateChain []byte `json:"certificate_chain"`
diff --git a/server/service/service_appconfig.go b/server/service/service_appconfig.go
index 979e5d75a2..b1fe820b7a 100644
--- a/server/service/service_appconfig.go
+++ b/server/service/service_appconfig.go
@@ -231,3 +231,27 @@ func (svc *Service) LoggingConfig(ctx context.Context) (*fleet.Logging, error) {
}
return logging, nil
}
+
+func (svc *Service) EmailConfig(ctx context.Context) (*fleet.EmailConfig, error) {
+ if err := svc.authz.Authorize(ctx, &fleet.AppConfig{}, fleet.ActionRead); err != nil {
+ return nil, err
+ }
+
+ conf := svc.config
+ var email *fleet.EmailConfig
+ switch conf.Email.EmailBackend {
+ case "ses":
+ email = &fleet.EmailConfig{
+ Backend: conf.Email.EmailBackend,
+ Config: fleet.SESConfig{
+ Region: conf.SES.Region,
+ SourceARN: conf.SES.SourceArn,
+ },
+ }
+ default:
+ // SES is the only email provider configured as server envs/yaml file, the default implementation, SMTP, is configured via API/UI
+ // SMTP config gets its own dedicated section in the AppConfig response
+ }
+
+ return email, nil
+}
diff --git a/server/service/service_appconfig_test.go b/server/service/service_appconfig_test.go
index ee2f6dbcae..d9502c9521 100644
--- a/server/service/service_appconfig_test.go
+++ b/server/service/service_appconfig_test.go
@@ -2,11 +2,11 @@ package service
import (
"context"
+ "fmt"
"runtime"
"testing"
"github.com/fleetdm/fleet/v4/server/config"
-
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mock"
"github.com/fleetdm/fleet/v4/server/test"
@@ -386,3 +386,69 @@ func TestModifyAppConfigPatches(t *testing.T) {
assert.Equal(t, "Acme", storedConfig.OrgInfo.OrgName)
assert.Equal(t, "http://someurl", storedConfig.ServerSettings.ServerURL)
}
+
+func TestService_EmailConfig(t *testing.T) {
+ type fields struct {
+ config config.FleetConfig
+ }
+ type args struct {
+ ctx context.Context
+ }
+ tests := []struct {
+ name string
+ fields fields
+ args args
+ want *fleet.EmailConfig
+ wantErr assert.ErrorAssertionFunc
+ }{
+ {
+ name: "configuring the ses email backend should return ses configurations",
+ fields: fields{
+ config: testSESPluginConfig(),
+ },
+ args: args{
+ ctx: test.UserContext(context.Background(), test.UserAdmin),
+ },
+ want: &fleet.EmailConfig{
+ Backend: "ses",
+ Config: fleet.SESConfig{
+ Region: "us-east-1",
+ SourceARN: "qux",
+ }},
+ wantErr: assert.NoError,
+ },
+ {
+ name: "no configured email backend should return nil",
+ fields: fields{
+ config: config.TestConfig(),
+ },
+ args: args{
+ ctx: test.UserContext(context.Background(), test.UserAdmin),
+ },
+ want: nil,
+ wantErr: assert.NoError,
+ },
+ {
+ name: "no configured email backend should return nil",
+ fields: fields{
+ config: config.TestConfig(),
+ },
+ args: args{
+ ctx: test.UserContext(context.Background(), test.UserNoRoles),
+ },
+ want: nil,
+ wantErr: assert.NoError,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ds := new(mock.Store)
+ svc, _ := newTestServiceWithConfig(t, ds, tt.fields.config, nil, nil)
+ got, err := svc.EmailConfig(tt.args.ctx)
+ if !tt.wantErr(t, err, fmt.Sprintf("EmailConfig(%v)", tt.args.ctx)) {
+ return
+ }
+ assert.Equalf(t, tt.want, got, "EmailConfig(%v)", tt.args.ctx)
+ })
+ }
+}
diff --git a/server/service/testing_utils.go b/server/service/testing_utils.go
index 8a65b0a04e..73b0ddf8e9 100644
--- a/server/service/testing_utils.go
+++ b/server/service/testing_utils.go
@@ -96,7 +96,8 @@ func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig conf
enrollHostLimiter = opts[0].EnrollHostLimiter
}
if opts[0].UseMailService {
- mailer = mail.NewService()
+ mailer, err = mail.NewService(config.TestConfig())
+ require.NoError(t, err)
}
// allow to explicitly set installer store to nil
@@ -322,6 +323,19 @@ func RunServerForTestsWithDS(t *testing.T, ds fleet.Datastore, opts ...*TestServ
return users, server
}
+func testSESPluginConfig() config.FleetConfig {
+ c := config.TestConfig()
+ c.Email = config.EmailConfig{EmailBackend: "ses"}
+ c.SES = config.SESConfig{
+ Region: "us-east-1",
+ AccessKeyID: "foo",
+ SecretAccessKey: "bar",
+ StsAssumeRoleArn: "baz",
+ SourceArn: "qux",
+ }
+ return c
+}
+
func testKinesisPluginConfig() config.FleetConfig {
c := config.TestConfig()
c.Osquery.ResultLogPlugin = "kinesis"
diff --git a/server/service/users.go b/server/service/users.go
index 5bd64f0448..79c01f9ec5 100644
--- a/server/service/users.go
+++ b/server/service/users.go
@@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/base64"
"errors"
+ "github.com/go-kit/kit/log/level"
"html/template"
"net/http"
"time"
@@ -1031,7 +1032,11 @@ func (svc *Service) RequestPasswordReset(ctx context.Context, email string) erro
},
}
- return svc.mailService.SendEmail(resetEmail)
+ err = svc.mailService.SendEmail(resetEmail)
+ if err != nil {
+ level.Error(svc.logger).Log("err", err, "msg", "failed to send password reset request email")
+ }
+ return err
}
func (svc *Service) ListAvailableTeamsForUser(ctx context.Context, user *fleet.User) ([]*fleet.TeamSummary, error) {
diff --git a/terraform/addons/ses/.header.md b/terraform/addons/ses/.header.md
new file mode 100644
index 0000000000..fd1edec288
--- /dev/null
+++ b/terraform/addons/ses/.header.md
@@ -0,0 +1,2 @@
+# SES Mailing Addon
+This addon allows Fleet to send password resets via SES
diff --git a/terraform/addons/ses/.terraform-docs.yml b/terraform/addons/ses/.terraform-docs.yml
new file mode 100644
index 0000000000..1d139ddb40
--- /dev/null
+++ b/terraform/addons/ses/.terraform-docs.yml
@@ -0,0 +1 @@
+header-from: .header.md
diff --git a/terraform/addons/ses/README.md b/terraform/addons/ses/README.md
new file mode 100644
index 0000000000..7b549d31cc
--- /dev/null
+++ b/terraform/addons/ses/README.md
@@ -0,0 +1,43 @@
+# SES Mailing Addon
+This addon allows Fleet to send password resets via SES
+
+## Requirements
+
+No requirements.
+
+## Providers
+
+| Name | Version |
+|------|---------|
+| [aws](#provider\_aws) | 4.60.0 |
+
+## Modules
+
+No modules.
+
+## Resources
+
+| Name | Type |
+|------|------|
+| [aws_iam_policy.main](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource |
+| [aws_route53_record.dkim](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route53_record) | resource |
+| [aws_route53_record.ses_verification](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route53_record) | resource |
+| [aws_route53_record.spf_domain](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route53_record) | resource |
+| [aws_ses_domain_dkim.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ses_domain_dkim) | resource |
+| [aws_ses_domain_identity.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ses_domain_identity) | resource |
+| [aws_ses_domain_identity_verification.default](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ses_domain_identity_verification) | resource |
+| [aws_iam_policy_document.main](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
+| [aws_route53_zone.main](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/route53_zone) | data source |
+
+## Inputs
+
+| Name | Description | Type | Default | Required |
+|------|-------------|------|---------|:--------:|
+| [domain](#input\_domain) | Domain to use for SES. | `string` | n/a | yes |
+
+## Outputs
+
+| Name | Description |
+|------|-------------|
+| [fleet\_extra\_environment\_variables](#output\_fleet\_extra\_environment\_variables) | n/a |
+| [fleet\_extra\_iam\_policies](#output\_fleet\_extra\_iam\_policies) | n/a |
diff --git a/terraform/addons/ses/main.tf b/terraform/addons/ses/main.tf
new file mode 100644
index 0000000000..6394ffd1ec
--- /dev/null
+++ b/terraform/addons/ses/main.tf
@@ -0,0 +1,51 @@
+data "aws_route53_zone" "main" {
+ name = var.domain
+}
+
+resource "aws_ses_domain_identity" "default" {
+ domain = var.domain
+}
+
+resource "aws_ses_domain_dkim" "default" {
+ domain = aws_ses_domain_identity.default.domain
+}
+
+###DKIM VERIFICATION#######
+
+resource "aws_route53_record" "dkim" {
+ for_each = toset(aws_ses_domain_dkim.default.dkim_tokens)
+ zone_id = data.aws_route53_zone.main.zone_id
+ name = format("%s._domainkey.%s", each.key, var.domain)
+ type = "CNAME"
+ ttl = 600
+ records = [format("%s.dkim.amazonses.com", each.key)]
+}
+
+resource "aws_route53_record" "spf_domain" {
+ zone_id = data.aws_route53_zone.main.zone_id
+ name = ""
+ type = "TXT"
+ ttl = "600"
+ records = ["v=spf1 include:amazonses.com -all"]
+}
+
+resource "aws_iam_policy" "main" {
+ policy = data.aws_iam_policy_document.main.json
+}
+
+data "aws_iam_policy_document" "main" {
+ statement {
+ actions = [
+ "ses:SendEmail",
+ "ses:SendRawEmail",
+ ]
+ resources = ["*"]
+ condition {
+ test = "StringLike"
+ variable = "ses:FromAddress"
+ values = [
+ "*@${var.domain}"
+ ]
+ }
+ }
+}
diff --git a/terraform/addons/ses/outputs.tf b/terraform/addons/ses/outputs.tf
new file mode 100644
index 0000000000..0e22664378
--- /dev/null
+++ b/terraform/addons/ses/outputs.tf
@@ -0,0 +1,12 @@
+output "fleet_extra_environment_variables" {
+ value = {
+ FLEET_EMAIL_BACKEND = "ses"
+ FLEET_SES_SOURCE_ARN = aws_ses_domain_identity.default.arn
+ }
+}
+
+output "fleet_extra_iam_policies" {
+ value = [
+ aws_iam_policy.main.arn
+ ]
+}
diff --git a/terraform/addons/ses/variables.tf b/terraform/addons/ses/variables.tf
new file mode 100644
index 0000000000..af4c2932ca
--- /dev/null
+++ b/terraform/addons/ses/variables.tf
@@ -0,0 +1,4 @@
+variable "domain" {
+ type = string
+ description = "Domain to use for SES."
+}