| new file mode 100644 |
| index 000000000..f74cd3f7b |
| --- /dev/null |
| +++ b/internal/mail/domain.go |
| @@ -0,0 +1,178 @@ |
| +// The identity record (spec §6a): accounts/<sqid>/email/domains/<domain>.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"` |
| +} |
| + |
| +// 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 PutDomain(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 |
| +} |
| + |
| +// 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 |
| +} |
| + |
| +// 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 |
| +} |