| 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 |
| +} |