mail: address Task 9 review — region guard, tenant precondition, DKIM token check, ARN test, fake parity
Six fixes from review, none a redesign: - Every SES-facing method that takes a region parameter now refuses a mismatch against the client's own bound region instead of silently ignoring it — the dead p.region field is what makes this loud rather than a future silent multi-region breakage. - EnsureTenant's doc comment now states explicitly that no resource association is made and that the send path must not emit X-SES-TENANT until identities and configuration sets are associated with the tenant — SES validates that association on every send. - EnsureIdentity refuses success with zero DKIM tokens rather than reporting an identity that can never converge with nothing to diagnose why. - Added an in-package test using a stubbed sesAPI that exercises the F3-critical ARN composition path directly: the already-exists read-back returns the right tokens, the composed ARN has the exact shape, and — the assertion that actually matters — that ARN is accepted by SendPolicyJSON. Also covers the zero-token refusal, the region mismatch refusal, and EnsureTenant against the stub. - ReapKeys gets a nil-Marker guard against an infinite loop. - Fixed two fake/real divergences in memprovisioner.go (authorised by the reviewer): IdentityStatus on an unknown identity now returns NOT_STARTED rather than erroring, matching SES's real answer; MintKey now enforces IAM's real two-key-per-user cap, which is the one that matters most since Task 13's rotation walk would otherwise pass against the fake and fail against production the first time it ran.
3 files changed,
+232
−5
internal/mail/aws.go+63 −4internal/mail/aws_test.go+152 −0internal/mail/memprovisioner.go+17 −1
diff --git a/internal/mail/aws.go b/internal/mail/aws.go| index c98fa9f30..c6f4ed505 100644 |
| --- a/internal/mail/aws.go |
| +++ b/internal/mail/aws.go |
| @@ -118,6 +118,24 @@ func NewAWSProvisioner(ctx context.Context, region string) (Provisioner, error) |
| }, nil |
| } |
| +// checkRegion refuses a call naming a region other than the one this |
| +// provisioner is bound to. The SES client here is bound once at |
| +// construction (NewAWSProvisioner), but every SES-facing Provisioner method |
| +// still takes a region parameter — because the spec's multi-region story is |
| +// "capable, not deployed" and MemProvisioner genuinely models N regions. An |
| +// awsProvisioner silently routing every region through its one client would |
| +// let a walk test built against the fake pass while production quietly acts |
| +// on the wrong region (or, for EnsureIdentity, composes an ARN naming the |
| +// CALLER's region while the identity itself was created in a different |
| +// one). An empty region is accepted as "caller doesn't care" and treated as |
| +// this provisioner's own region. |
| +func (p *awsProvisioner) checkRegion(region string) error { |
| + if region != "" && region != p.region { |
| + return fmt.Errorf("mail: provisioner is bound to region %s, cannot act on %s", p.region, region) |
| + } |
| + return nil |
| +} |
| + |
| // ---- error classification ------------------------------------------------- |
| func isAlreadyExists(err error) bool { |
| @@ -138,10 +156,23 @@ func isNotFound(err error) bool { |
| // |
| // 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). |
| +// creates ONLY the named container — it does not call |
| +// CreateTenantResourceAssociation, because EnsureTenant's signature carries |
| +// no identity ARN or configuration-set name to associate. Isolation is |
| +// unaffected by that gap: the tenant buys reputation isolation, and |
| +// authority isolation is the IAM policy (spec §4), which this does not touch. |
| +// |
| +// PRECONDITION FOR SENDING, NOT YET MET HERE: per |
| +// docs.aws.amazon.com/ses/latest/dg/tenants.html, a tenant needs at least |
| +// one verified identity AND one configuration set associated via |
| +// CreateTenantResourceAssociation before SES will accept a send attributed |
| +// to it — SES validates that association on every send. Nothing in this |
| +// package wires that association yet. Consequently: the send path MUST NOT |
| +// emit the `X-SES-TENANT: <name>` header until associations are wired, |
| +// wherever that lands (identities and config sets are ensured by separate |
| +// calls, so it is not naturally EnsureTenant's job). Emitting the header |
| +// against a tenant with no associated resources fails every send, the |
| +// opposite of what tenants exist to do. |
| func (p *awsProvisioner) EnsureTenant(ctx context.Context, name string) error { |
| _, err := p.ses.CreateTenant(ctx, &sesv2.CreateTenantInput{ |
| TenantName: aws.String(name), |
| @@ -153,6 +184,9 @@ func (p *awsProvisioner) EnsureTenant(ctx context.Context, name string) error { |
| } |
| func (p *awsProvisioner) EnsureConfigSet(ctx context.Context, region, name, snsTopicARN string) error { |
| + if err := p.checkRegion(region); err != nil { |
| + return err |
| + } |
| _, err := p.ses.CreateConfigurationSet(ctx, &sesv2.CreateConfigurationSetInput{ |
| ConfigurationSetName: aws.String(name), |
| }) |
| @@ -196,6 +230,9 @@ func (p *awsProvisioner) EnsureConfigSet(ctx context.Context, region, name, snsT |
| // 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) { |
| + if err := p.checkRegion(region); err != nil { |
| + return Identity{}, err |
| + } |
| var tokens []string |
| out, err := p.ses.CreateEmailIdentity(ctx, &sesv2.CreateEmailIdentityInput{ |
| EmailIdentity: aws.String(domain), |
| @@ -219,6 +256,15 @@ func (p *awsProvisioner) EnsureIdentity(ctx context.Context, region, domain, mai |
| return Identity{}, fmt.Errorf("mail: create identity %q in %s: %w", domain, region, err) |
| } |
| + // No tokens is not a partial success: with none to publish as CNAMEs, |
| + // the domain can never converge to DKIM SUCCESS, and nothing else in |
| + // the walk would ever surface why. This also catches an identity that |
| + // exists but was created BYODKIM or as an email-address identity rather |
| + // than Easy DKIM — GetEmailIdentity succeeds but has no Tokens either. |
| + if len(tokens) == 0 { |
| + return Identity{}, fmt.Errorf("mail: identity %q in %s has no Easy DKIM tokens available", domain, region) |
| + } |
| + |
| if _, err := p.ses.PutEmailIdentityMailFromAttributes(ctx, &sesv2.PutEmailIdentityMailFromAttributesInput{ |
| EmailIdentity: aws.String(domain), |
| MailFromDomain: aws.String(mailFrom), |
| @@ -234,6 +280,9 @@ func (p *awsProvisioner) EnsureIdentity(ctx context.Context, region, domain, mai |
| } |
| func (p *awsProvisioner) IdentityStatus(ctx context.Context, region, domain string) (Status, error) { |
| + if err := p.checkRegion(region); err != nil { |
| + return Status{}, err |
| + } |
| got, err := p.ses.GetEmailIdentity(ctx, &sesv2.GetEmailIdentityInput{ |
| EmailIdentity: aws.String(domain), |
| }) |
| @@ -257,6 +306,9 @@ func (p *awsProvisioner) IdentityStatus(ctx context.Context, region, domain stri |
| } |
| func (p *awsProvisioner) DeleteIdentity(ctx context.Context, region, domain string) error { |
| + if err := p.checkRegion(region); err != nil { |
| + return err |
| + } |
| _, err := p.ses.DeleteEmailIdentity(ctx, &sesv2.DeleteEmailIdentityInput{ |
| EmailIdentity: aws.String(domain), |
| }) |
| @@ -365,6 +417,13 @@ func (p *awsProvisioner) ReapKeys(ctx context.Context, user string, keep []strin |
| if !out.IsTruncated { |
| return nil |
| } |
| + if out.Marker == nil { |
| + // IsTruncated=true with no Marker to page from would otherwise |
| + // spin forever re-issuing the same request. AWS is not observed |
| + // to do this, but this loop runs inside a console door, and the |
| + // guard is one line while the file is already open. |
| + return nil |
| + } |
| marker = out.Marker |
| } |
| } |
diff --git a/internal/mail/aws_test.go b/internal/mail/aws_test.go| index 055ebc57f..424f92416 100644 |
| --- a/internal/mail/aws_test.go |
| +++ b/internal/mail/aws_test.go |
| @@ -1,10 +1,14 @@ |
| package mail |
| import ( |
| + "context" |
| "errors" |
| + "reflect" |
| "testing" |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| 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" |
| ) |
| @@ -49,3 +53,151 @@ func TestMailFromFailureRejectsRatherThanFallsBack(t *testing.T) { |
| t.Fatalf("BehaviorOnMxFailure = %v, want REJECT_MESSAGE", mailFromBehaviour) |
| } |
| } |
| + |
| +// stubSES is a minimal sesAPI double: only the methods a given test cares |
| +// about are wired via the function fields; everything else answers with a |
| +// zero value and a nil error, which is fine because no test below relies on |
| +// those paths. |
| +type stubSES struct { |
| + createTenant func(*sesv2.CreateTenantInput) (*sesv2.CreateTenantOutput, error) |
| + createEmailIdentity func(*sesv2.CreateEmailIdentityInput) (*sesv2.CreateEmailIdentityOutput, error) |
| + getEmailIdentity func(*sesv2.GetEmailIdentityInput) (*sesv2.GetEmailIdentityOutput, error) |
| + putEmailIdentityMailFromAttributes func(*sesv2.PutEmailIdentityMailFromAttributesInput) (*sesv2.PutEmailIdentityMailFromAttributesOutput, error) |
| +} |
| + |
| +func (s *stubSES) CreateTenant(_ context.Context, in *sesv2.CreateTenantInput, _ ...func(*sesv2.Options)) (*sesv2.CreateTenantOutput, error) { |
| + if s.createTenant != nil { |
| + return s.createTenant(in) |
| + } |
| + return &sesv2.CreateTenantOutput{}, nil |
| +} |
| + |
| +func (s *stubSES) CreateEmailIdentity(_ context.Context, in *sesv2.CreateEmailIdentityInput, _ ...func(*sesv2.Options)) (*sesv2.CreateEmailIdentityOutput, error) { |
| + if s.createEmailIdentity != nil { |
| + return s.createEmailIdentity(in) |
| + } |
| + return &sesv2.CreateEmailIdentityOutput{}, nil |
| +} |
| + |
| +func (s *stubSES) GetEmailIdentity(_ context.Context, in *sesv2.GetEmailIdentityInput, _ ...func(*sesv2.Options)) (*sesv2.GetEmailIdentityOutput, error) { |
| + if s.getEmailIdentity != nil { |
| + return s.getEmailIdentity(in) |
| + } |
| + return &sesv2.GetEmailIdentityOutput{}, nil |
| +} |
| + |
| +func (s *stubSES) PutEmailIdentityMailFromAttributes(_ context.Context, in *sesv2.PutEmailIdentityMailFromAttributesInput, _ ...func(*sesv2.Options)) (*sesv2.PutEmailIdentityMailFromAttributesOutput, error) { |
| + if s.putEmailIdentityMailFromAttributes != nil { |
| + return s.putEmailIdentityMailFromAttributes(in) |
| + } |
| + return &sesv2.PutEmailIdentityMailFromAttributesOutput{}, nil |
| +} |
| + |
| +func (s *stubSES) DeleteEmailIdentity(_ context.Context, in *sesv2.DeleteEmailIdentityInput, _ ...func(*sesv2.Options)) (*sesv2.DeleteEmailIdentityOutput, error) { |
| + return &sesv2.DeleteEmailIdentityOutput{}, nil |
| +} |
| + |
| +func (s *stubSES) CreateConfigurationSet(_ context.Context, in *sesv2.CreateConfigurationSetInput, _ ...func(*sesv2.Options)) (*sesv2.CreateConfigurationSetOutput, error) { |
| + return &sesv2.CreateConfigurationSetOutput{}, nil |
| +} |
| + |
| +func (s *stubSES) CreateConfigurationSetEventDestination(_ context.Context, in *sesv2.CreateConfigurationSetEventDestinationInput, _ ...func(*sesv2.Options)) (*sesv2.CreateConfigurationSetEventDestinationOutput, error) { |
| + return &sesv2.CreateConfigurationSetEventDestinationOutput{}, nil |
| +} |
| + |
| +var _ sesAPI = (*stubSES)(nil) |
| + |
| +// TestEnsureIdentityAlreadyExistsReadsBackTokensAndComposesTheARN pins the |
| +// F3-critical path: the ruling's own words are that getting this wrong means |
| +// every IAM policy names a resource that does not exist, and this was |
| +// otherwise untested until Task 12. It exercises the already-exists branch |
| +// (CreateEmailIdentity fails, GetEmailIdentity is read back for the DKIM |
| +// tokens already published in DNS), checks the exact composed ARN, and — |
| +// the assertion that actually matters, because it pins aws.go and policy.go |
| +// together — checks that ARN is ACCEPTED by SendPolicyJSON. |
| +func TestEnsureIdentityAlreadyExistsReadsBackTokensAndComposesTheARN(t *testing.T) { |
| + wantTokens := []string{"tok1", "tok2", "tok3"} |
| + stub := &stubSES{ |
| + createEmailIdentity: func(*sesv2.CreateEmailIdentityInput) (*sesv2.CreateEmailIdentityOutput, error) { |
| + return nil, &sesv2types.AlreadyExistsException{Message: aws.String("identity already exists")} |
| + }, |
| + getEmailIdentity: func(*sesv2.GetEmailIdentityInput) (*sesv2.GetEmailIdentityOutput, error) { |
| + return &sesv2.GetEmailIdentityOutput{ |
| + DkimAttributes: &sesv2types.DkimAttributes{Tokens: wantTokens}, |
| + }, nil |
| + }, |
| + } |
| + p := &awsProvisioner{ses: stub, account: "123456789012", region: "eu-west-1"} |
| + |
| + got, err := p.EnsureIdentity(context.Background(), "eu-west-1", "example.com", "mail.example.com") |
| + if err != nil { |
| + t.Fatalf("EnsureIdentity: %v", err) |
| + } |
| + if !reflect.DeepEqual(got.DKIMTokens, wantTokens) { |
| + t.Fatalf("DKIMTokens = %v, want the ones read back from GetEmailIdentity %v", got.DKIMTokens, wantTokens) |
| + } |
| + |
| + const wantARN = "arn:aws:ses:eu-west-1:123456789012:identity/example.com" |
| + if got.ARN != wantARN { |
| + t.Fatalf("ARN = %q, want %q", got.ARN, wantARN) |
| + } |
| + |
| + // The assertion that actually matters: the composed ARN must satisfy |
| + // SendPolicyJSON's shape validator, since EnsureIdentity's return value |
| + // flows straight into EnsureUser -> SendPolicyJSON in production. A |
| + // composed ARN that validator rejects would make every send fail with |
| + // no way to notice until Task 12. |
| + if _, err := SendPolicyJSON([]string{got.ARN}, []string{"example.com"}); err != nil { |
| + t.Fatalf("composed ARN %q was rejected by SendPolicyJSON: %v", got.ARN, err) |
| + } |
| +} |
| + |
| +// TestEnsureIdentityRefusesNoTokens is the other half of the same path: an |
| +// identity that reads back with no DKIM tokens (BYODKIM, an email-address |
| +// identity, or a nil DkimAttributes) must not be reported as success — the |
| +// walk would publish no CNAMEs and the domain could never converge, with |
| +// nothing surfacing why. |
| +func TestEnsureIdentityRefusesNoTokens(t *testing.T) { |
| + stub := &stubSES{ |
| + createEmailIdentity: func(*sesv2.CreateEmailIdentityInput) (*sesv2.CreateEmailIdentityOutput, error) { |
| + return &sesv2.CreateEmailIdentityOutput{}, nil |
| + }, |
| + } |
| + p := &awsProvisioner{ses: stub, account: "123456789012", region: "eu-west-1"} |
| + |
| + if _, err := p.EnsureIdentity(context.Background(), "eu-west-1", "example.com", "mail.example.com"); err == nil { |
| + t.Fatal("EnsureIdentity with zero DKIM tokens returned success, want an error") |
| + } |
| +} |
| + |
| +// TestEnsureIdentityRefusesAMismatchedRegion pins the region guard: an |
| +// awsProvisioner is bound to one client at construction, and a caller naming |
| +// a different region must be refused loudly rather than silently routed |
| +// through the wrong client. |
| +func TestEnsureIdentityRefusesAMismatchedRegion(t *testing.T) { |
| + p := &awsProvisioner{ses: &stubSES{}, account: "123456789012", region: "eu-west-1"} |
| + if _, err := p.EnsureIdentity(context.Background(), "us-east-1", "example.com", "mail.example.com"); err == nil { |
| + t.Fatal("EnsureIdentity accepted a region other than the one the provisioner is bound to") |
| + } |
| +} |
| + |
| +// TestEnsureTenantCallsCreateTenantAndAcceptsAlreadyExists exercises |
| +// EnsureTenant, which added CreateTenant to sesAPI and was otherwise never |
| +// stubbed anywhere. |
| +func TestEnsureTenantCallsCreateTenantAndAcceptsAlreadyExists(t *testing.T) { |
| + var gotName string |
| + stub := &stubSES{ |
| + createTenant: func(in *sesv2.CreateTenantInput) (*sesv2.CreateTenantOutput, error) { |
| + gotName = aws.ToString(in.TenantName) |
| + return nil, &sesv2types.AlreadyExistsException{Message: aws.String("tenant already exists")} |
| + }, |
| + } |
| + p := &awsProvisioner{ses: stub, account: "123456789012", region: "eu-west-1"} |
| + |
| + if err := p.EnsureTenant(context.Background(), "acct-42"); err != nil { |
| + t.Fatalf("EnsureTenant: %v", err) |
| + } |
| + if gotName != "acct-42" { |
| + t.Fatalf("CreateTenant called with TenantName %q, want %q", gotName, "acct-42") |
| + } |
| +} |
diff --git a/internal/mail/memprovisioner.go b/internal/mail/memprovisioner.go| index f4bb83ed1..f8e693e39 100644 |
| --- a/internal/mail/memprovisioner.go |
| +++ b/internal/mail/memprovisioner.go |
| @@ -135,7 +135,14 @@ func (p *MemProvisioner) IdentityStatus(_ context.Context, region, domain string |
| } |
| got, ok := p.Identities[identityKey(region, domain)] |
| if !ok { |
| - return Status{}, fmt.Errorf("mail: no identity %s in %s", domain, region) |
| + // Matches the real backend: SES's GetEmailIdentity 404s on an |
| + // unknown identity, and awsProvisioner.IdentityStatus turns that |
| + // into an all-NOT_STARTED Status with a nil error rather than an |
| + // error — an identity nobody has ensured yet is not a failure, it |
| + // just hasn't started. The fake used to error here; that was a |
| + // divergence a walk test could pass against while production, given |
| + // the same input, would not error at all. |
| + return Status{DKIM: "NOT_STARTED", MailFrom: "NOT_STARTED", Sending: "NOT_STARTED"}, nil |
| } |
| return got.Status, nil |
| } |
| @@ -191,6 +198,15 @@ func (p *MemProvisioner) MintKey(_ context.Context, user string) (string, string |
| if !ok { |
| return "", "", fmt.Errorf("mail: no user %q", user) |
| } |
| + // IAM hard-caps every user at two access keys and returns |
| + // LimitExceededException on a third CreateAccessKey — this is the real |
| + // constraint, so the fake enforces it too. Without this, a rotation walk |
| + // that tries to mint a third key (the delivered key, a fresh key mid- |
| + // rotation, and a stray third from a bug) would pass against the fake |
| + // and fail the first time it ran against production. |
| + if len(u.Keys) >= 2 { |
| + return "", "", fmt.Errorf("mail: user %q already has 2 access keys, IAM's per-user limit", user) |
| + } |
| p.nextKey++ |
| id := fmt.Sprintf("AKIAFAKE%06d", p.nextKey) |
| secret := fmt.Sprintf("secret-%06d", p.nextKey) |