carlos / platform Public

mail: the AWS provisioner — SESv2 and IAM behind the seam

Every already-exists and already-gone mapping is what makes the walks
re-runnable after a half-failure. EnsureIdentity reads the identity back on
already-exists rather than failing, because the DKIM tokens it returns must
be the ones already published — fresh tokens would orphan CNAMEs, including
ones a customer published by hand. MAIL FROM uses REJECT_MESSAGE, not the
silent fallback that keeps a misconfigured tenant sending unaligned.
Paul Campbell e1cba7cdb0b4703072eb48d39a705518ef4d6e3f parent c8d3d14
4 files changed, +465 −24
  • go.mod +9 −8
  • go.sum +18 −16
  • internal/mail/aws.go +387 −0
  • internal/mail/aws_test.go +51 −0
diff --git a/go.mod b/go.mod
index 6773eada6..dd7e91d44 100644
--- a/go.mod
+++ b/go.mod
@@ -3,14 +3,16 @@ module github.com/carlosframework/platform
go 1.26
require (
- github.com/aws/aws-sdk-go-v2 v1.43.5
+ github.com/aws/aws-sdk-go-v2 v1.43.6
github.com/aws/aws-sdk-go-v2/config v1.32.6
github.com/aws/aws-sdk-go-v2/credentials v1.19.6
github.com/aws/aws-sdk-go-v2/service/costexplorer v1.67.5
github.com/aws/aws-sdk-go-v2/service/iam v1.58.2
github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0
- github.com/aws/smithy-go v1.27.7
+ github.com/aws/aws-sdk-go-v2/service/sesv2 v1.66.6
+ github.com/aws/aws-sdk-go-v2/service/sts v1.45.6
+ github.com/aws/smithy-go v1.27.8
github.com/benbjohnson/litestream v0.5.14
golang.org/x/crypto v0.52.0
golang.org/x/net v0.55.0
@@ -21,18 +23,17 @@ require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.18 // indirect
- github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.36 // indirect
- github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.36 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.37 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.37 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
- github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.37 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
- github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
diff --git a/go.sum b/go.sum
index 256893dee..d8d482895 100644
--- a/go.sum
+++ b/go.sum
@@ -18,8 +18,8 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJ
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0 h1:wQlqotpyjYPjJz+Noh5bRu7Snmydk8SKC5Z6u1CR20Y=
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M=
-github.com/aws/aws-sdk-go-v2 v1.43.5 h1:yKT5GYnFWhuDo+DqKvE5ZPwVn3RjC4MAeBtZGlh6AVM=
-github.com/aws/aws-sdk-go-v2 v1.43.5/go.mod h1:wZjAJppCntyOGgVSmgVTfDyRJK5PHOasO6Wsy8U7Axk=
+github.com/aws/aws-sdk-go-v2 v1.43.6 h1:RrmFcqCBxkJuf7g1axVo5krB4jM/AO8r5e5oujrgdoQ=
+github.com/aws/aws-sdk-go-v2 v1.43.6/go.mod h1:tXpPM+v0D1lndmga+HqqLDIzUFJlEeR21aspVklHF00=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E=
github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8=
@@ -30,40 +30,42 @@ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBU
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.18 h1:9vWXHtaepwoAl/UuKzxwgOoJDXPCC3hvgNMfcmdS2Tk=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.18/go.mod h1:sKuUZ+MwUTuJbYvZ8pK0x10LvgcJK3Y4rmh63YBekwk=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.36 h1:5CrzwxDqf4w3x1Vs3/NiZ0nsC34Hbm3pIDMWbsLebOE=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.36/go.mod h1:A3gHdKZIvG/QXERzZwcxNS3RNDFcRCuhhTFBYp+V/nw=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.36 h1:A4N2f4YPcST0v+dWtX+xrpPPCL9VTBhoIFFUWYqbacE=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.36/go.mod h1:B/Qr859uxWUEfZeGotK5KAEoof4Q9YWgNtPSwV6jcyk=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.37 h1:lznzIOvvbqjfe8UAaciCRJgBgJsxuTROKlhZuXQWfv8=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.37/go.mod h1:otfkzyfQeMMLZAqX59GSXTL3o22BR/l6HFaRzzbWSqA=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.37 h1:zCEORWo0eU0gDjG+IyApE/2B+ZGG1m+GU7B263XV8ds=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.37/go.mod h1:i6c0PEl3TNOWxRbQ++KQcVenPWS/GoQeiklKhNuqzJ8=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
-github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 h1:3GUprIsfmGcC5SACIyB0e7E0BM1O1b3Erl5CePYIAeQ=
-github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31/go.mod h1:7PuV1yl5e2xnUbm+RqvVg5i2iBM8EyijZNoI9wsOoOc=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38 h1:A3UAuCmx7LyUcrixBTzKJYYIUZ2yTvn6ZhT8PB+7APk=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38/go.mod h1:1PDUYG9Z+JrbbsobsAZHjWOm9QBT/djiK3QbykTL5Z4=
github.com/aws/aws-sdk-go-v2/service/costexplorer v1.67.5 h1:hiJJTfio1+i/xnfLwu6RsDjDzkGSiLux54LTzDK4MyA=
github.com/aws/aws-sdk-go-v2/service/costexplorer v1.67.5/go.mod h1:gy1JJMsby9XLbrwMhJcMu0IuMJF4DvDe4pD1wqMmwkk=
github.com/aws/aws-sdk-go-v2/service/iam v1.58.2 h1:/6iRcqrC6k1rMA6uCZMzFE9inOrBpNmhbrZ90X8qH50=
github.com/aws/aws-sdk-go-v2/service/iam v1.58.2/go.mod h1:Wm74PIQWDrV2tGPFYtwLqh8jZ7JU/rnq/leiC6UI4dg=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17 h1:OvYZOB3qA6zvfdRFiRFRzVSiElMYrz3GdntkXZxlp1o=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17/go.mod h1:JgR/2Ew50ACfIWau1oeMRX59tMtC0kM+PYQGEaT04cY=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 h1:9Fjh6fi/U5JEStVZijmaMpUwE/gvBJj7x2B/PjbO9To=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23/go.mod h1:iMoT2f1tClxrWAAnKCXjZQ6LOmfLrMG14wmnWpM+F14=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrKsSD7EmDjzAPsm/3L9IuOkzaynklJZ1qX7S4=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.37 h1:a3D4AjrOrTrP8+d9ILBthqrElf0z1JNol09Xvnwcys8=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.37/go.mod h1:ky0gTu+ukvUTuUKFIpp6Wid4oninrkCyvbFkVs0kpHM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 h1:uao4A3QZ5UmB326V6KF+qRpv9Tjz7IlnlnTbbANntlU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31/go.mod h1:I/1+z0VwL1GhQyLgkoHDlygpUZ+iTAwOQ/NsftiUL2I=
github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1 h1:qL5ELChQ1o1ZbzHvsl8gzQfAZdbQtrDbUyzJkjkMKcg=
github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1/go.mod h1:0hIRXFez1bZsDFMGkLZvNJbByTSVZ4sFZWpxZ39NPuM=
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0 h1:XptwLL+UHXgafYMIHTy59IRovLbhz3znkxY2uS/pbXU=
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0/go.mod h1:zdmCoFO/dSI7GlrwsPqFJI+WlFnSU4Tc8TJnlXrM1Do=
+github.com/aws/aws-sdk-go-v2/service/sesv2 v1.66.6 h1:wc18zrr6hcDHI0uiwMxVXVebw7Z2hjmEIb/2QYv6AXs=
+github.com/aws/aws-sdk-go-v2/service/sesv2 v1.66.6/go.mod h1:Ftv0JhdAfMkOhOQgZ7IEWkQh7MoOOxwfRhFUZgfZFtM=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0=
-github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70=
-github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=
-github.com/aws/smithy-go v1.27.7 h1:Zgj5z4LfcDYoQIVk+n/yGdTkP/2y6ZT5vYxe0fp7bqE=
-github.com/aws/smithy-go v1.27.7/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
+github.com/aws/aws-sdk-go-v2/service/sts v1.45.6 h1:JvExZWabChDM0qJAirQYGfOYo0ndT3edXj+fqSPNjkE=
+github.com/aws/aws-sdk-go-v2/service/sts v1.45.6/go.mod h1:XZcaQkV2cItp6yEkrwljyaPOf22RuX7T43jxap/FOmM=
+github.com/aws/smithy-go v1.27.8 h1:FR0dxZfIlV7Z8eh2iHfIofdunw382XsDV3Mxt9nUvRY=
+github.com/aws/smithy-go v1.27.8/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/benbjohnson/litestream v0.5.14 h1:pu2RCUfEs2MfAvxSttNg/kVVzJspqg2sX/pPW3Elzxc=
github.com/benbjohnson/litestream v0.5.14/go.mod h1:aN5OtdkcCz07xVjCbmMWOGUSIdPumpqBYtdyrzNMhOI=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
diff --git a/internal/mail/aws.go b/internal/mail/aws.go
new file mode 100644
index 000000000..c98fa9f30
--- /dev/null
+++ b/internal/mail/aws.go
@@ -0,0 +1,387 @@
+// The AWS provisioner: SESv2 + IAM behind the Provisioner seam.
+//
+// Every idempotence mapping here is load-bearing. The walks in walk.go are
+// re-runnable after a half-failure ONLY because each verb below treats
+// "already exists" and "already gone" as success rather than error.
+//
+// TENANT ATTRIBUTION: verified against the live SESv2 API reference and
+// Developer Guide on 2026-08-19 (docs.aws.amazon.com/ses/latest/dg/tenants.html
+// and docs.aws.amazon.com/ses/latest/APIReference-V2/API_CreateTenant.html).
+// SESv2 DOES have tenant management: CreateTenant, CreateTenantResourceAssociation,
+// GetTenant, DeleteTenant, ListTenants, ListTenantResources are all present in
+// aws-sdk-go-v2/service/sesv2 v1.66.6, and CreateTenant's documented error set
+// includes AlreadyExistsException, so it fits the same idempotence mapping as
+// everything else here. SMTP sends ARE attributable to a tenant, via a plain
+// header on the outgoing message: `X-SES-TENANT: <tenant-name>` (the guide
+// gives that exact spelling, NOT the `X-SES-TENANT-NAME` this package's spec
+// guessed at). That header, plus which config set and identity a send uses,
+// is a concern of the code that composes an outgoing message, not of this
+// provisioner. EnsureTenant below therefore does the one thing the Provisioner
+// interface actually asks of it — create the account's named tenant container,
+// idempotently — and stops there: it does not call
+// CreateTenantResourceAssociation, because the interface gives EnsureTenant no
+// identity or config-set to associate. Isolation is unaffected either way:
+// the tenant buys REPUTATION isolation, and AUTHORITY isolation is the IAM
+// policy in policy.go (spec §4).
+package mail
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ awsconfig "github.com/aws/aws-sdk-go-v2/config"
+ "github.com/aws/aws-sdk-go-v2/service/iam"
+ iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types"
+ "github.com/aws/aws-sdk-go-v2/service/sesv2"
+ sesv2types "github.com/aws/aws-sdk-go-v2/service/sesv2/types"
+ "github.com/aws/aws-sdk-go-v2/service/sts"
+)
+
+// mailFromBehaviour is REJECT_MESSAGE and must stay that way.
+//
+// The alternative, USE_DEFAULT_VALUE, silently falls back to amazonses.com
+// when the custom MAIL FROM MX is absent: the tenant keeps sending, from an
+// unaligned envelope domain, failing DMARC, with nothing to notice. Rejecting
+// turns a DNS mistake into a visible error instead of a slow deliverability
+// leak.
+const mailFromBehaviour = sesv2types.BehaviorOnMxFailureRejectMessage
+
+// sesAPI is the subset of *sesv2.Client awsProvisioner calls, narrowed so a
+// future test can stand a stub in for a live client without a network or
+// credentials — exactly as internal/objstore/aws.go narrows s3API and iamAPI.
+type sesAPI interface {
+ CreateTenant(context.Context, *sesv2.CreateTenantInput, ...func(*sesv2.Options)) (*sesv2.CreateTenantOutput, error)
+ CreateEmailIdentity(context.Context, *sesv2.CreateEmailIdentityInput, ...func(*sesv2.Options)) (*sesv2.CreateEmailIdentityOutput, error)
+ GetEmailIdentity(context.Context, *sesv2.GetEmailIdentityInput, ...func(*sesv2.Options)) (*sesv2.GetEmailIdentityOutput, error)
+ PutEmailIdentityMailFromAttributes(context.Context, *sesv2.PutEmailIdentityMailFromAttributesInput, ...func(*sesv2.Options)) (*sesv2.PutEmailIdentityMailFromAttributesOutput, error)
+ DeleteEmailIdentity(context.Context, *sesv2.DeleteEmailIdentityInput, ...func(*sesv2.Options)) (*sesv2.DeleteEmailIdentityOutput, error)
+ CreateConfigurationSet(context.Context, *sesv2.CreateConfigurationSetInput, ...func(*sesv2.Options)) (*sesv2.CreateConfigurationSetOutput, error)
+ CreateConfigurationSetEventDestination(context.Context, *sesv2.CreateConfigurationSetEventDestinationInput, ...func(*sesv2.Options)) (*sesv2.CreateConfigurationSetEventDestinationOutput, error)
+}
+
+// iamAPI is the subset of *iam.Client awsProvisioner calls.
+type iamAPI interface {
+ CreateUser(context.Context, *iam.CreateUserInput, ...func(*iam.Options)) (*iam.CreateUserOutput, error)
+ GetUser(context.Context, *iam.GetUserInput, ...func(*iam.Options)) (*iam.GetUserOutput, error)
+ PutUserPolicy(context.Context, *iam.PutUserPolicyInput, ...func(*iam.Options)) (*iam.PutUserPolicyOutput, error)
+ DeleteUserPolicy(context.Context, *iam.DeleteUserPolicyInput, ...func(*iam.Options)) (*iam.DeleteUserPolicyOutput, error)
+ CreateAccessKey(context.Context, *iam.CreateAccessKeyInput, ...func(*iam.Options)) (*iam.CreateAccessKeyOutput, error)
+ UpdateAccessKey(context.Context, *iam.UpdateAccessKeyInput, ...func(*iam.Options)) (*iam.UpdateAccessKeyOutput, error)
+ DeleteAccessKey(context.Context, *iam.DeleteAccessKeyInput, ...func(*iam.Options)) (*iam.DeleteAccessKeyOutput, error)
+ ListAccessKeys(context.Context, *iam.ListAccessKeysInput, ...func(*iam.Options)) (*iam.ListAccessKeysOutput, error)
+ DeleteUser(context.Context, *iam.DeleteUserInput, ...func(*iam.Options)) (*iam.DeleteUserOutput, error)
+}
+
+// stsAPI is the subset of *sts.Client awsProvisioner's constructor calls,
+// narrowed the same way as sesAPI and iamAPI.
+type stsAPI interface {
+ GetCallerIdentity(context.Context, *sts.GetCallerIdentityInput, ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error)
+}
+
+type awsProvisioner struct {
+ ses sesAPI
+ iam iamAPI
+ region string
+ account string
+}
+
+var _ Provisioner = (*awsProvisioner)(nil)
+
+// NewAWSProvisioner builds a Provisioner against SESv2 and IAM.
+//
+// The SES client is bound to region because SES is regional in every
+// dimension that matters. IAM takes no region: users and keys are global,
+// which is what lets one credential span regions (spec §8).
+//
+// The account id is resolved ONCE here via STS GetCallerIdentity and stored
+// on the provisioner (controller ruling F3), rather than looked up per call:
+// SESv2 does not return an identity ARN, so EnsureIdentity must compose one,
+// and composing it needs the account id. Resolving it once at construction
+// keeps every walk free of an extra round trip.
+func NewAWSProvisioner(ctx context.Context, region string) (Provisioner, error) {
+ cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region))
+ if err != nil {
+ return nil, fmt.Errorf("mail: load aws config: %w", err)
+ }
+ var stsClient stsAPI = sts.NewFromConfig(cfg)
+ ident, err := stsClient.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{})
+ if err != nil {
+ return nil, fmt.Errorf("mail: resolve caller identity: %w", err)
+ }
+ return &awsProvisioner{
+ ses: sesv2.NewFromConfig(cfg),
+ iam: iam.NewFromConfig(cfg),
+ region: region,
+ account: aws.ToString(ident.Account),
+ }, nil
+}
+
+// ---- error classification -------------------------------------------------
+
+func isAlreadyExists(err error) bool {
+ var ses *sesv2types.AlreadyExistsException
+ var im *iamtypes.EntityAlreadyExistsException
+ return errors.As(err, &ses) || errors.As(err, &im)
+}
+
+func isNotFound(err error) bool {
+ var ses *sesv2types.NotFoundException
+ var im *iamtypes.NoSuchEntityException
+ return errors.As(err, &ses) || errors.As(err, &im)
+}
+
+// ---- SES ------------------------------------------------------------------
+
+// EnsureTenant creates the account's SES tenant if absent.
+//
+// See the TENANT ATTRIBUTION note at the top of this file: CreateTenant is a
+// real, present SESv2 API, verified against the live docs on 2026-08-19. This
+// only creates the named container — it does not associate identities or
+// configuration sets with it, because EnsureTenant's signature carries none
+// to associate. Isolation is unaffected either way: the tenant buys
+// reputation isolation, and authority isolation is the IAM policy (spec §4).
+func (p *awsProvisioner) EnsureTenant(ctx context.Context, name string) error {
+ _, err := p.ses.CreateTenant(ctx, &sesv2.CreateTenantInput{
+ TenantName: aws.String(name),
+ })
+ if err != nil && !isAlreadyExists(err) {
+ return fmt.Errorf("mail: create tenant %q: %w", name, err)
+ }
+ return nil
+}
+
+func (p *awsProvisioner) EnsureConfigSet(ctx context.Context, region, name, snsTopicARN string) error {
+ _, err := p.ses.CreateConfigurationSet(ctx, &sesv2.CreateConfigurationSetInput{
+ ConfigurationSetName: aws.String(name),
+ })
+ if err != nil && !isAlreadyExists(err) {
+ return fmt.Errorf("mail: create configuration set %q: %w", name, err)
+ }
+ _, err = p.ses.CreateConfigurationSetEventDestination(ctx, &sesv2.CreateConfigurationSetEventDestinationInput{
+ ConfigurationSetName: aws.String(name),
+ EventDestinationName: aws.String("carlos-events"),
+ EventDestination: &sesv2types.EventDestinationDefinition{
+ Enabled: true,
+ // Send is here as well as the failure events because it is the
+ // only source of the per-tenant send count that enforces a cap
+ // (spec §9) — SES has no per-identity rate limit to read.
+ MatchingEventTypes: []sesv2types.EventType{
+ sesv2types.EventTypeSend,
+ sesv2types.EventTypeBounce,
+ sesv2types.EventTypeComplaint,
+ sesv2types.EventTypeReject,
+ sesv2types.EventTypeDelivery,
+ },
+ SnsDestination: &sesv2types.SnsDestination{TopicArn: aws.String(snsTopicARN)},
+ },
+ })
+ if err != nil && !isAlreadyExists(err) {
+ return fmt.Errorf("mail: create event destination on %q: %w", name, err)
+ }
+ return nil
+}
+
+// EnsureIdentity creates the identity with Easy DKIM and sets the custom
+// MAIL FROM, idempotently.
+//
+// On "already exists" it READS the identity back rather than failing,
+// because the DKIM tokens it must return are the ones already published in
+// DNS. Minting fresh tokens on a re-run would orphan every CNAME already
+// out there — and for a custom domain, CNAMEs somebody published by hand.
+//
+// SESv2 does not return an identity ARN, so it is composed here from region,
+// domain and p.account (resolved once at construction — controller ruling
+// F3), in the exact shape SendPolicyJSON validates:
+// arn:aws:ses:<region>:<account-id>:identity/<domain>.
+func (p *awsProvisioner) EnsureIdentity(ctx context.Context, region, domain, mailFrom string) (Identity, error) {
+ var tokens []string
+ out, err := p.ses.CreateEmailIdentity(ctx, &sesv2.CreateEmailIdentityInput{
+ EmailIdentity: aws.String(domain),
+ })
+ switch {
+ case err == nil:
+ if out.DkimAttributes != nil {
+ tokens = out.DkimAttributes.Tokens
+ }
+ case isAlreadyExists(err):
+ got, gerr := p.ses.GetEmailIdentity(ctx, &sesv2.GetEmailIdentityInput{
+ EmailIdentity: aws.String(domain),
+ })
+ if gerr != nil {
+ return Identity{}, fmt.Errorf("mail: get existing identity %q: %w", domain, gerr)
+ }
+ if got.DkimAttributes != nil {
+ tokens = got.DkimAttributes.Tokens
+ }
+ default:
+ return Identity{}, fmt.Errorf("mail: create identity %q in %s: %w", domain, region, err)
+ }
+
+ if _, err := p.ses.PutEmailIdentityMailFromAttributes(ctx, &sesv2.PutEmailIdentityMailFromAttributesInput{
+ EmailIdentity: aws.String(domain),
+ MailFromDomain: aws.String(mailFrom),
+ BehaviorOnMxFailure: mailFromBehaviour,
+ }); err != nil {
+ return Identity{}, fmt.Errorf("mail: set MAIL FROM %q on %q: %w", mailFrom, domain, err)
+ }
+
+ return Identity{
+ ARN: fmt.Sprintf("arn:aws:ses:%s:%s:identity/%s", region, p.account, domain),
+ DKIMTokens: tokens,
+ }, nil
+}
+
+func (p *awsProvisioner) IdentityStatus(ctx context.Context, region, domain string) (Status, error) {
+ got, err := p.ses.GetEmailIdentity(ctx, &sesv2.GetEmailIdentityInput{
+ EmailIdentity: aws.String(domain),
+ })
+ if err != nil {
+ if isNotFound(err) {
+ return Status{DKIM: "NOT_STARTED", MailFrom: "NOT_STARTED", Sending: "NOT_STARTED"}, nil
+ }
+ return Status{}, fmt.Errorf("mail: get identity %q: %w", domain, err)
+ }
+ st := Status{Sending: "PENDING"}
+ if got.VerifiedForSendingStatus {
+ st.Sending = "SUCCESS"
+ }
+ if got.DkimAttributes != nil {
+ st.DKIM = string(got.DkimAttributes.Status)
+ }
+ if got.MailFromAttributes != nil {
+ st.MailFrom = string(got.MailFromAttributes.MailFromDomainStatus)
+ }
+ return st, nil
+}
+
+func (p *awsProvisioner) DeleteIdentity(ctx context.Context, region, domain string) error {
+ _, err := p.ses.DeleteEmailIdentity(ctx, &sesv2.DeleteEmailIdentityInput{
+ EmailIdentity: aws.String(domain),
+ })
+ if err != nil && !isNotFound(err) {
+ return fmt.Errorf("mail: delete identity %q: %w", domain, err)
+ }
+ return nil
+}
+
+// ---- IAM --------------------------------------------------------------
+
+func (p *awsProvisioner) EnsureUser(ctx context.Context, name, path string, identityARNs, domains []string) (string, error) {
+ // Render the policy FIRST. SendPolicyJSON refuses an empty grant, and
+ // refusing before CreateUser is what stops a principal existing with no
+ // policy at all — an unscoped sending user, briefly, is still one.
+ doc, err := SendPolicyJSON(identityARNs, domains)
+ if err != nil {
+ return "", err
+ }
+ var arn string
+ out, err := p.iam.CreateUser(ctx, &iam.CreateUserInput{
+ UserName: aws.String(name), Path: aws.String(path),
+ })
+ if err != nil {
+ if !isAlreadyExists(err) {
+ return "", fmt.Errorf("mail: create user %q: %w", name, err)
+ }
+ got, gerr := p.iam.GetUser(ctx, &iam.GetUserInput{UserName: aws.String(name)})
+ if gerr != nil {
+ return "", fmt.Errorf("mail: get existing user %q: %w", name, gerr)
+ }
+ arn = aws.ToString(got.User.Arn)
+ } else {
+ arn = aws.ToString(out.User.Arn)
+ }
+ if _, err := p.iam.PutUserPolicy(ctx, &iam.PutUserPolicyInput{
+ UserName: aws.String(name),
+ PolicyName: aws.String(PolicyName),
+ PolicyDocument: aws.String(doc),
+ }); err != nil {
+ return "", fmt.Errorf("mail: put policy for user %q: %w", name, err)
+ }
+ return arn, nil
+}
+
+func (p *awsProvisioner) MintKey(ctx context.Context, user string) (string, string, error) {
+ out, err := p.iam.CreateAccessKey(ctx, &iam.CreateAccessKeyInput{UserName: aws.String(user)})
+ if err != nil {
+ return "", "", fmt.Errorf("mail: create access key for %q: %w", user, err)
+ }
+ return aws.ToString(out.AccessKey.AccessKeyId), aws.ToString(out.AccessKey.SecretAccessKey), nil
+}
+
+// SetKeyActive is the pause. Instant, reversible, and it rewrites no policy —
+// which matters because a policy rewrite would have to be undone correctly to
+// resume, and this cannot half-fail into a broken grant.
+func (p *awsProvisioner) SetKeyActive(ctx context.Context, user, keyID string, active bool) error {
+ st := iamtypes.StatusTypeInactive
+ if active {
+ st = iamtypes.StatusTypeActive
+ }
+ _, err := p.iam.UpdateAccessKey(ctx, &iam.UpdateAccessKeyInput{
+ UserName: aws.String(user), AccessKeyId: aws.String(keyID), Status: st,
+ })
+ if err != nil && !isNotFound(err) {
+ return fmt.Errorf("mail: set key %s active=%v: %w", keyID, active, err)
+ }
+ return nil
+}
+
+func (p *awsProvisioner) DeleteKey(ctx context.Context, user, keyID string) error {
+ _, err := p.iam.DeleteAccessKey(ctx, &iam.DeleteAccessKeyInput{
+ UserName: aws.String(user), AccessKeyId: aws.String(keyID),
+ })
+ if err != nil && !isNotFound(err) {
+ return fmt.Errorf("mail: delete key %s: %w", keyID, err)
+ }
+ return nil
+}
+
+func (p *awsProvisioner) ReapKeys(ctx context.Context, user string, keep []string) error {
+ keepSet := make(map[string]bool, len(keep))
+ for _, id := range keep {
+ keepSet[id] = true
+ }
+ var marker *string
+ for {
+ out, err := p.iam.ListAccessKeys(ctx, &iam.ListAccessKeysInput{
+ UserName: aws.String(user), Marker: marker,
+ })
+ if err != nil {
+ if isNotFound(err) {
+ return nil
+ }
+ return fmt.Errorf("mail: list access keys for %q: %w", user, err)
+ }
+ for _, k := range out.AccessKeyMetadata {
+ id := aws.ToString(k.AccessKeyId)
+ if keepSet[id] {
+ continue
+ }
+ if err := p.DeleteKey(ctx, user, id); err != nil {
+ return err
+ }
+ }
+ if !out.IsTruncated {
+ return nil
+ }
+ marker = out.Marker
+ }
+}
+
+// DeleteUser removes keys and the inline policy first — IAM refuses to
+// delete a user that still has either.
+func (p *awsProvisioner) DeleteUser(ctx context.Context, user string) error {
+ if err := p.ReapKeys(ctx, user, nil); err != nil {
+ return err
+ }
+ if _, err := p.iam.DeleteUserPolicy(ctx, &iam.DeleteUserPolicyInput{
+ UserName: aws.String(user), PolicyName: aws.String(PolicyName),
+ }); err != nil && !isNotFound(err) {
+ return fmt.Errorf("mail: delete policy for %q: %w", user, err)
+ }
+ if _, err := p.iam.DeleteUser(ctx, &iam.DeleteUserInput{UserName: aws.String(user)}); err != nil && !isNotFound(err) {
+ return fmt.Errorf("mail: delete user %q: %w", user, err)
+ }
+ return nil
+}
diff --git a/internal/mail/aws_test.go b/internal/mail/aws_test.go
new file mode 100644
index 000000000..055ebc57f
--- /dev/null
+++ b/internal/mail/aws_test.go
@@ -0,0 +1,51 @@
+package mail
+
+import (
+ "errors"
+ "testing"
+
+ iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types"
+ sesv2types "github.com/aws/aws-sdk-go-v2/service/sesv2/types"
+)
+
+// "Already exists" is a SUCCESS. Every walk re-runs after a half-failure,
+// and a second EnsureIdentity that errors would make a crashed run
+// unrepairable except by hand.
+func TestAlreadyExistsIsRecognised(t *testing.T) {
+ if !isAlreadyExists(&sesv2types.AlreadyExistsException{}) {
+ t.Fatal("SES AlreadyExistsException not recognised")
+ }
+ if !isAlreadyExists(&iamtypes.EntityAlreadyExistsException{}) {
+ t.Fatal("IAM EntityAlreadyExistsException not recognised")
+ }
+ if isAlreadyExists(errors.New("some other failure")) {
+ t.Fatal("an unrelated error was treated as already-exists")
+ }
+}
+
+// "Already gone" is a SUCCESS, for the same reason in the other direction:
+// teardown must be re-runnable.
+func TestNotFoundIsRecognised(t *testing.T) {
+ if !isNotFound(&sesv2types.NotFoundException{}) {
+ t.Fatal("SES NotFoundException not recognised")
+ }
+ if !isNotFound(&iamtypes.NoSuchEntityException{}) {
+ t.Fatal("IAM NoSuchEntityException not recognised")
+ }
+ if isNotFound(errors.New("some other failure")) {
+ t.Fatal("an unrelated error was treated as not-found")
+ }
+}
+
+// BehaviorOnMxFailure must be REJECT_MESSAGE, not USE_DEFAULT_VALUE.
+//
+// USE_DEFAULT_VALUE silently falls back to amazonses.com when the custom
+// MAIL FROM MX is missing — which means a misconfigured tenant keeps
+// sending, from an unaligned envelope domain, failing DMARC, and nothing
+// surfaces the fault. Rejecting is what turns a DNS mistake into an error
+// somebody sees.
+func TestMailFromFailureRejectsRatherThanFallsBack(t *testing.T) {
+ if mailFromBehaviour != sesv2types.BehaviorOnMxFailureRejectMessage {
+ t.Fatalf("BehaviorOnMxFailure = %v, want REJECT_MESSAGE", mailFromBehaviour)
+ }
+}