// The identity record (spec §6a): accounts//email/domains/.json. // // ACCOUNT-scoped, not app-scoped, and the reason is narrow: two apps in one // account may legitimately share one custom domain, and only one of them may // create the SES identity and write its DNS. A double-create is the failure // this split exists to prevent. In the common case there is exactly one such // record per app, auto-created, and nobody looks at it. package mail import ( "context" "encoding/json" "errors" "fmt" "os" "regexp" "sort" "strings" "time" "github.com/carlosframework/platform/internal/release" ) // The closed status set. Only StatusVerified puts an identity's ARN into a // credential's policy, so a pending domain grants nothing. const ( StatusPending = "pending" StatusVerified = "verified" StatusFailed = "failed" ) // KindPlatform means CARLOS owns the zone and publishes DNS itself; // KindCustom means the customer does. It is the one bit that decides who // publishes (spec §7). const ( KindPlatform = "platform" KindCustom = "custom" ) // RegionState is one region's identity. Everything in here is regional: // SES issues a distinct identity, distinct DKIM tokens and a distinct // MAIL FROM MX target per region (spec §8). type RegionState struct { IdentityARN string `json:"identity_arn"` DKIMTokens []string `json:"dkim_tokens"` MailFrom string `json:"mail_from"` ConfigSet string `json:"config_set"` Status string `json:"status"` CheckedAt time.Time `json:"checked_at"` // Adopted marks an identity this account did not create but proved it // controls the domain of (#216). CARLOS sends on it and never deletes // it. Absent on every record written before it existed, which keeps // those identities torn down as they always were. Adopted bool `json:"adopted,omitempty"` } // DomainRecord is one sending domain. // // Regions is a MAP rather than a scalar field. Every SES dimension that // matters is regional, so a scalar would have to be migrated across the // fleet the first time a second region appears. Keying costs nothing today // and is the difference between adding a region and a migration. type DomainRecord struct { Schema int `json:"schema"` Domain string `json:"domain"` Kind string `json:"kind"` Apps []string `json:"apps"` Regions map[string]*RegionState `json:"regions"` CreatedAt time.Time `json:"created_at"` } func DomainsPrefix(acct string) string { return "accounts/" + acct + "/email/domains/" } func DomainKey(acct, domain string) string { return DomainsPrefix(acct) + domain + ".json" } // A conservative LDH hostname: lowercase labels of alphanumerics and inner // hyphens, at least two labels. Deliberately stricter than DNS allows, // because this string becomes a bucket key AND an IAM policy resource, and // the only safe place to refuse a traversal or an injection is the one // gate every caller passes through. var domainRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$`) func ValidDomain(d string) bool { return len(d) <= 253 && domainRe.MatchString(d) } func GetDomain(ctx context.Context, s release.Store, acct, domain string) (*DomainRecord, string, error) { if !release.ValidAccount(acct) { return nil, "", fmt.Errorf("mail: invalid account %q", acct) } if !ValidDomain(domain) { return nil, "", fmt.Errorf("mail: invalid domain %q", domain) } raw, etag, err := s.Get(ctx, DomainKey(acct, domain)) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, "", nil } return nil, "", err } var rec DomainRecord if err := json.Unmarshal(raw, &rec); err != nil { return nil, "", fmt.Errorf("mail: %s: %w", DomainKey(acct, domain), err) } return &rec, etag, nil } func claimUnserializedPutDomain(ctx context.Context, s release.Store, acct string, rec *DomainRecord, etag string) (string, error) { if !ValidDomain(rec.Domain) { return "", fmt.Errorf("mail: invalid domain %q", rec.Domain) } rec.Schema = Schema body, err := json.MarshalIndent(rec, "", " ") if err != nil { return "", err } return s.Put(ctx, DomainKey(acct, rec.Domain), append(body, '\n'), etag) } // ListDomains reads every identity record for an account. A key that fails // to parse is skipped rather than failing the listing: one malformed record // must not hide an account's other domains from the console. func ListDomains(ctx context.Context, s release.Store, acct string) ([]*DomainRecord, error) { keys, err := s.List(ctx, DomainsPrefix(acct)) if err != nil { return nil, err } var out []*DomainRecord for _, k := range keys { if !strings.HasSuffix(k, ".json") { continue } raw, _, err := s.Get(ctx, k) if err != nil { continue } var rec DomainRecord if json.Unmarshal(raw, &rec) != nil { continue } out = append(out, &rec) } sort.Slice(out, func(i, j int) bool { return out[i].Domain < out[j].Domain }) return out, nil } // StrictListDomains is ListDomains for the enabled paths that must see EVERY // record of the account: an unreadable, malformed, newer-schema or // key-mismatched record aborts with ErrDomainInventory instead of being // skipped. func StrictListDomains(ctx context.Context, s release.Store, acct string) ([]*DomainRecord, error) { if !release.ValidAccount(acct) { return nil, fmt.Errorf("mail: invalid account %q", acct) } keys, err := s.List(ctx, DomainsPrefix(acct)) if err != nil { return nil, err } out := make([]*DomainRecord, 0, len(keys)) seen := make(map[string]struct{}, len(keys)) for _, k := range keys { // A repeated key in the listing must count once. if _, dup := seen[k]; dup { continue } seen[k] = struct{}{} raw, _, err := s.Get(ctx, k) if err != nil { return nil, fmt.Errorf("%w: record %s: %v", ErrDomainInventory, k, err) } var rec DomainRecord if err := json.Unmarshal(raw, &rec); err != nil { return nil, fmt.Errorf("%w: record %s: unparseable: %v", ErrDomainInventory, k, err) } if rec.Schema < 0 || rec.Schema > Schema || !ValidDomain(rec.Domain) || DomainKey(acct, rec.Domain) != k { return nil, fmt.Errorf("%w: record %s: unsupported or mismatched body", ErrDomainInventory, k) } for _, app := range rec.Apps { if !release.ValidApp(app) { return nil, fmt.Errorf("%w: record %s: invalid member", ErrDomainInventory, k) } } for region, state := range rec.Regions { if region == "" || state == nil { return nil, fmt.Errorf("%w: record %s: invalid region", ErrDomainInventory, k) } } out = append(out, &rec) } sort.Slice(out, func(i, j int) bool { return out[i].Domain < out[j].Domain }) return out, nil } // VerifiedARNs is every identity ARN this domain contributes to a // credential's policy. Only verified regions count. func (r *DomainRecord) VerifiedARNs() []string { var out []string for _, st := range r.Regions { if st.Status == StatusVerified && st.IdentityARN != "" { out = append(out, st.IdentityARN) } } sort.Strings(out) return out } // VerifiedConfigSetARNs is every configuration-set ARN this domain // contributes to a credential's policy (issue #271). Only a verified region // that actually HAS a set bound contributes one — a pending region's // identity is not usable yet, and a verified region with no set is the // ordinary shape on a deployment with no SNS topic. // // The region and account are taken from the region's own IdentityARN rather // than passed in, so the config-set ARN cannot name a different account or // region from the identity it pairs with. func (r *DomainRecord) VerifiedConfigSetARNs() []string { var out []string for _, st := range r.Regions { if st == nil || st.Status != StatusVerified || st.ConfigSet == "" || st.IdentityARN == "" { continue } if arn, ok := ConfigSetARN(st.IdentityARN, st.ConfigSet); ok { out = append(out, arn) } } sort.Strings(out) return out } // ConfigSetARN composes a configuration set's ARN from an identity ARN in // the same account and region, which is the only place this package has // those two values. Reports false for an ARN it cannot parse, so a caller // never builds a resource string out of a malformed one. func ConfigSetARN(identityARN, configSet string) (string, bool) { if identityARN == "" || configSet == "" { return "", false } parts := strings.Split(identityARN, ":") if len(parts) != 6 { return "", false } return strings.Join(parts[:5], ":") + ":configuration-set/" + configSet, true } // AddApp records an app as a referrer. Reports whether it changed anything. func (r *DomainRecord) AddApp(app string) bool { for _, a := range r.Apps { if a == app { return false } } r.Apps = append(r.Apps, app) sort.Strings(r.Apps) return true } // RemoveApp drops a referrer. The SES identity and its DNS are deleted only // when the set empties — without this, one app's teardown would silently // revoke a sibling app's verified domain. func (r *DomainRecord) RemoveApp(app string) bool { for i, a := range r.Apps { if a == app { r.Apps = append(r.Apps[:i], r.Apps[i+1:]...) return true } } return false } func PutDomain(ctx context.Context, s release.Store, acct string, rec *DomainRecord, etag string) (string, error) { if _, enabled := s.(ClaimMailStore); !enabled { return claimUnserializedPutDomain(ctx, s, acct, rec, etag) } var claimResult0 string claimErr := WithClaimMailResource(ctx, s, func(ctx context.Context) error { return claimMailAdmitted(ctx, s, acct, claimPutDomainPlan(rec), func(ctx context.Context) error { var err error claimResult0, err = claimUnserializedPutDomain(ctx, s, acct, rec, etag) return err }) }) return claimResult0, claimErr }