ClaimOrCreateDomain implements the atomic create/claim logic from the design doc. Creates if new, returns the existing row when the same user already owns it (verified or not), and errors if a different user owns it. The verification_token and DKIM keypair are minted on first INSERT and remain stabl
(ctx context.Context, domain, userID string)
| 478 | // domain before invoking this — the store has no concept of a reserved |
| 479 | // domain. |
| 480 | func (s *Store) ClaimOrCreateDomain(ctx context.Context, domain, userID string) (*Domain, error) { |
| 481 | domain = normalizeDomain(domain) |
| 482 | |
| 483 | verificationToken := "e2a-verify=" + generateID() |
| 484 | |
| 485 | // Generate a DKIM keypair for this domain. Failures here are |
| 486 | // non-fatal — the columns are nullable and the outbound signer |
| 487 | // treats a missing key as "skip DKIM". We still log because key gen |
| 488 | // failing is a hard signal (entropy exhaustion or an OS-level |
| 489 | // CSPRNG bug) that ops should see. |
| 490 | var dkimSelector string |
| 491 | var dkimPubKey string |
| 492 | var dkimPrivKey []byte |
| 493 | if kp, kerr := dkim.GenerateKeypair(); kerr == nil { |
| 494 | // Encrypt the private key at rest (#144). On seal failure (catastrophic |
| 495 | // RNG) drop ALL three DKIM columns so we never publish a public key / |
| 496 | // selector without a usable private key — non-fatal, same posture as a |
| 497 | // keygen failure (the signer treats a missing key as "skip DKIM"). |
| 498 | sealed, serr := s.sealDKIM(kp.PrivateKeyDER, domain) |
| 499 | if serr != nil { |
| 500 | log.Printf("[identity] dkim key seal failed for %s: %v", domain, serr) |
| 501 | } else { |
| 502 | dkimSelector = kp.Selector |
| 503 | dkimPubKey = kp.PublicKeyDNS |
| 504 | dkimPrivKey = sealed |
| 505 | } |
| 506 | } else { |
| 507 | log.Printf("[identity] dkim keygen failed for %s: %v", domain, kerr) |
| 508 | } |
| 509 | |
| 510 | // Atomic upsert. The conflict branch only fires for a same-user |
| 511 | // re-claim of an unverified row, and runs as a no-op SET so |
| 512 | // RETURNING surfaces the existing row. DKIM columns and the |
| 513 | // verification_token are only written on a true INSERT, so they |
| 514 | // stay stable across re-claims — DKIM stability avoids |
| 515 | // invalidating signatures on mail in flight, and token stability |
| 516 | // means a caller who already published the TXT record on DNS |
| 517 | // isn't silently invalidated. A different-user conflict falls |
| 518 | // through to the SELECT below and returns "domain not available", |
| 519 | // preventing squatting on an unverified row whose TXT record the |
| 520 | // original owner may have already published. |
| 521 | d := &Domain{} |
| 522 | err := s.pool.QueryRow(ctx, |
| 523 | `INSERT INTO domains (domain, user_id, verified, verification_token, dkim_selector, dkim_public_key, dkim_private_key) |
| 524 | VALUES ($1, $2, false, $3, $4, $5, $6) |
| 525 | ON CONFLICT (domain) DO UPDATE |
| 526 | SET user_id = domains.user_id |
| 527 | WHERE domains.verified = false AND domains.user_id = $2 |
| 528 | RETURNING domain, user_id, verified, verification_token, created_at, verified_at, is_primary, last_checked_at, COALESCE(dkim_selector, ''), COALESCE(dkim_public_key, ''), sending_status, COALESCE(sending_error, ''), sending_dns_records, sending_last_checked_at, COALESCE(sending_dkim_status, ''), COALESCE(sending_mail_from_status, '')`, |
| 529 | domain, userID, verificationToken, nullIfEmpty(dkimSelector), nullIfEmpty(dkimPubKey), nullIfEmptyBytes(dkimPrivKey), |
| 530 | ).Scan(&d.Domain, &d.UserID, &d.Verified, &d.VerificationToken, &d.CreatedAt, &d.VerifiedAt, &d.IsPrimary, &d.LastCheckedAt, &d.DKIMSelector, &d.DKIMPublicKey, &d.SendingStatus, &d.SendingError, &d.SendingDNSRecordsJSON, &d.SendingLastCheckedAt, &d.SendingDkimStatus, &d.SendingMailFromStatus) |
| 531 | |
| 532 | if err == nil { |
| 533 | return d, nil |
| 534 | } |
| 535 | |
| 536 | // No row returned — the row exists but the conflict UPDATE was |
| 537 | // skipped because either it's already verified or a different user |