Cooperative email for PDS operators
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

Enrollment UX, OAuth identity verification, operational hardening, and member compliance

+4348 -670
+97 -3
cmd/relay/main.go
··· 101 101 102 102 // Rate limits 103 103 HourlyLimit int `json:"hourlyLimit"` // default 100 104 - DailyLimit int `json:"dailyLimit"` // default 1000 104 + DailyLimit int `json:"dailyLimit"` // default 1000 105 105 GlobalPerMinute int `json:"globalPerMinute"` // default 500 106 106 107 107 // Osprey integration (optional — leave empty to disable) ··· 151 151 } 152 152 153 153 var flagConfigPath = flag.String("config", "./relay-config.json", "path to relay config file") 154 + 155 + // storeDomainLister adapts *relaystore.Store to the narrow 156 + // adminui.DomainLister interface so the enrollment landing can show 157 + // existing domains without a full store import. 158 + type storeDomainLister struct{ store *relaystore.Store } 159 + 160 + func (s storeDomainLister) ListMemberDomains(ctx context.Context, did string) ([]string, error) { 161 + domains, err := s.store.ListMemberDomains(ctx, did) 162 + if err != nil { 163 + return nil, err 164 + } 165 + names := make([]string, len(domains)) 166 + for i, d := range domains { 167 + names[i] = d.Domain 168 + } 169 + return names, nil 170 + } 154 171 155 172 func main() { 156 173 flag.Parse() ··· 775 792 // Osprey complaint event so rules can react (e.g. auto-suspend after 776 793 // N complaints in 24h). memberExists guards against spoofed reports 777 794 // naming DIDs we never issued. 795 + var fblNotify func(ctx context.Context, memberDID, senderDomain, recipientDomain, feedbackType, provider string) 778 796 memberExists := func(ctx context.Context, did string) bool { 779 797 m, err := store.GetMember(ctx, did) 780 798 return err == nil && m != nil 781 799 } 782 800 inboundServer.SetFBL(func(ctx context.Context, memberDID, senderDomain, recipientDomain, feedbackType, providerUA string, arrival time.Time) { 801 + provider := normalizeProviderUA(providerUA) 802 + metrics.ComplaintsTotal.WithLabelValues(feedbackType, provider).Inc() 783 803 ospreyEmitter.Emit(ctx, osprey.EventData{ 784 804 EventType: osprey.EventComplaintReceived, 785 805 SenderDID: memberDID, ··· 788 808 FeedbackType: feedbackType, 789 809 ProviderUA: providerUA, 790 810 }) 811 + if fblNotify != nil { 812 + fblNotify(ctx, memberDID, senderDomain, recipientDomain, feedbackType, provider) 813 + } 791 814 }, memberExists) 792 815 log.Printf("inbound.fbl.enabled: inbox=fbl@%s", cfg.Domain) 793 816 ··· 900 923 adminAPI.SetOpMailer(opMailer, cfg.OperatorForwardTo, cfg.PublicBaseURL) 901 924 } 902 925 926 + fblNotify = adminAPI.FireFBLComplaint 927 + 928 + // Operator-initiated warmup sends. Seed addresses come from a 929 + // sops-encrypted env var so they never appear in the repo. Empty 930 + // WARMUP_SEED_ADDRESSES disables the feature (button hidden in UI). 931 + if seeds := os.Getenv("WARMUP_SEED_ADDRESSES"); seeds != "" { 932 + seedList := strings.Split(seeds, ",") 933 + for i := range seedList { 934 + seedList[i] = strings.TrimSpace(seedList[i]) 935 + } 936 + ws := relay.NewWarmupSender(relay.WarmupConfig{ 937 + SeedAddresses: seedList, 938 + MemberLookup: memberLookup, 939 + Queue: queue, 940 + OperatorKeys: operatorKeys, 941 + OperatorDKIMDomain: cfg.OperatorDKIMDomain, 942 + RelayDomain: cfg.Domain, 943 + InsertMessage: func(ctx context.Context, did, from, to, msgID string) (int64, error) { 944 + return store.InsertMessage(ctx, &relaystore.Message{ 945 + MemberDID: did, 946 + FromAddr: from, 947 + ToAddr: to, 948 + MessageID: msgID, 949 + Status: relaystore.MsgQueued, 950 + CreatedAt: time.Now().UTC(), 951 + }) 952 + }, 953 + IncrSendCount: func(ctx context.Context, did string) { 954 + store.IncrementSendCount(ctx, did) 955 + }, 956 + }) 957 + adminAPI.SetWarmupSender(ws) 958 + log.Printf("warmup.enabled: seed_count=%d", len(seedList)) 959 + } 960 + 903 961 // Durable notification queue worker (audit #158). Drains 904 962 // pending_notifications rows that RegenerateKey / FireMemberWelcome 905 963 // enqueue, dispatching each via the admin API's kind-aware ··· 940 998 // into the operator notification webhook so operators see the same 941 999 // event stream regardless of which interface triggered it. 942 1000 dashboardUI.SetNotifyStateChangeHook(adminAPI.NotifyStateChange) 1001 + if adminAPI.WarmupSeedCount() > 0 { 1002 + dashboardUI.SetWarmupHook(func(ctx context.Context, did string) (int, int, []string, error) { 1003 + result, err := adminAPI.SendWarmup(ctx, did) 1004 + if err != nil { 1005 + return 0, 0, nil, err 1006 + } 1007 + return result.Sent, result.Failed, result.Errors, nil 1008 + }, adminAPI.WarmupSeedCount()) 1009 + } 943 1010 eventsUI := adminui.NewEventsHandler(store) 944 1011 inboundUI := adminui.NewInboundHandler(store) 945 1012 reviewQueueUI := adminui.NewReviewQueueHandler(store) ··· 993 1060 var recoverHandlerForShutdown *adminui.RecoverHandler 994 1061 if cfg.PublicAddr != "" && unsubscriber != nil { 995 1062 enrollHandler := adminui.NewEnrollHandler(adminAPI, didResolver) 1063 + enrollHandler.SetDomainLister(storeDomainLister{store: store}) 1064 + enrollHandler.SetFunnelRecorder(metrics) 996 1065 // Enable /enroll/label-status for the success-page polling UX. 997 1066 // LabelChecker is tailnet-only; proxying through the relay keeps 998 1067 // labeler connectivity private. ··· 1013 1082 siteMux.Handle("/enroll/", enrollHandler) 1014 1083 siteMux.Handle("/u/", unsubscriber.Handler()) 1015 1084 siteMux.Handle("/healthz", healthHandler) 1085 + siteMux.HandleFunc("/verify-email", adminAPI.HandleVerifyEmail) 1016 1086 1017 1087 // Self-service attestation publishing (atproto OAuth). Only active 1018 1088 // when the operator has configured SiteBaseURL — the client_id MUST ··· 1023 1093 oauthCfg := atpoauth.Config{ 1024 1094 ClientID: cfg.SiteBaseURL + "/.well-known/atproto-oauth-client-metadata.json", 1025 1095 CallbackURL: cfg.SiteBaseURL + "/enroll/attest/callback", 1026 - Scopes: []string{"atproto", "transition:generic"}, 1096 + Scopes: []string{"atproto", "repo:email.atmos.attestation"}, 1027 1097 SigningKeyPath: cfg.StateDir + "/oauth-signing-key.pem", 1028 1098 } 1029 1099 oauthClient, err := atpoauth.NewClient(oauthCfg, store) ··· 1034 1104 adminui.NewMetadataHandler(oauthClient, "Atmosphere Mail", cfg.SiteBaseURL)) 1035 1105 pub := &adminui.AtpoauthPublisher{C: oauthClient} 1036 1106 attestHandler := adminui.NewAttestHandler(pub, store) 1107 + attestHandler.SetFunnelRecorder(metrics) 1037 1108 attestHandler.RegisterRoutes(siteMux) 1038 1109 1039 1110 // Self-service credential recovery. Shares the attest OAuth ··· 1047 1118 _, apiKey, err := adminAPI.RegenerateKey(context.Background(), did, domain) 1048 1119 return apiKey, err 1049 1120 }) 1121 + recoverHandler.SetHandleResolver(didResolver) 1122 + recoverHandler.SetContactEmailChangedHook(func(ctx context.Context, domain, contactEmail string) { 1123 + adminAPI.TriggerEmailVerification(ctx, domain, contactEmail) 1124 + }) 1050 1125 recoverHandler.RegisterRoutes(siteMux) 1051 1126 attestHandler.SetRecoveryIssuer(recoverHandler) 1127 + attestHandler.SetEnrollAuthIssuer(enrollHandler) 1128 + enrollHandler.SetPublisher(pub) 1129 + enrollHandler.SetAccountTicketIssuer(recoverHandler) 1052 1130 recoverHandlerForShutdown = recoverHandler 1053 1131 1054 1132 log.Printf("atpoauth.enabled: client_id=%s callback=%s confidential=%v", ··· 1134 1212 if publicHandler != nil && publicTLS != nil { 1135 1213 publicServer = &http.Server{ 1136 1214 Addr: cfg.PublicAddr, 1137 - Handler: publicHandler, 1215 + Handler: metrics.HTTPMiddleware(publicHandler), 1138 1216 TLSConfig: publicTLS, 1139 1217 ReadTimeout: 10 * time.Second, 1140 1218 WriteTimeout: 10 * time.Second, ··· 1533 1611 } 1534 1612 } 1535 1613 return subject, body.String() 1614 + } 1615 + 1616 + // normalizeProviderUA maps a raw FBL User-Agent string to a canonical 1617 + // provider bucket for the complaints_total metric. 1618 + func normalizeProviderUA(ua string) string { 1619 + ua = strings.ToLower(ua) 1620 + switch { 1621 + case strings.Contains(ua, "google") || strings.Contains(ua, "gmail"): 1622 + return "gmail" 1623 + case strings.Contains(ua, "microsoft") || strings.Contains(ua, "outlook"): 1624 + return "microsoft" 1625 + case strings.Contains(ua, "yahoo"): 1626 + return "yahoo" 1627 + default: 1628 + return "other" 1629 + } 1536 1630 } 1537 1631 1538 1632 // recipientDomain extracts the domain part from an email address.
+36 -2
infra/nixos/atmos-ops.nix
··· 415 415 after = [ "tailscale-online.service" ]; 416 416 wants = [ "tailscale-online.service" ]; 417 417 }; 418 + systemd.services.docker-atmosphere-office = { 419 + after = [ "tailscale-online.service" ]; 420 + wants = [ "tailscale-online.service" ]; 421 + }; 418 422 419 423 # ------------------------------------------------------------------- 420 424 # Label API (native Go binary) — serves Osprey labels to the relay ··· 516 520 users.groups.atmos-labeler = {}; 517 521 518 522 # ------------------------------------------------------------------- 519 - # Caddy — HTTPS reverse proxy for labeler 523 + # Atmosphere Office (Docker) — static site for AT Proto office suite 524 + # 525 + # Pure static Vite MPA build served by an in-container Caddy on 8082. 526 + # No backend, no persistent data — all user data lives in IndexedDB. 527 + # Image pulled from Gitea registry (via Tailscale, same as other containers). 528 + # ------------------------------------------------------------------- 529 + virtualisation.oci-containers.containers.atmosphere-office = { 530 + image = "git.internal/lanos-familia/atmosphere-office:latest"; 531 + ports = [ "127.0.0.1:8082:8080" ]; 532 + environment = { 533 + INSTANCE_FLAVOR = "public"; 534 + INSTANCE_ACCESS_MODE = "allowlist"; 535 + INSTANCE_ALLOWLIST = "did:plc:dy67wyyakm7u4v2lthy5zwbn,did:plc:x2japbukbrfrwt5wty423m2y"; 536 + INSTANCE_FEATURES = "sync,sharing"; 537 + }; 538 + }; 539 + 540 + # ------------------------------------------------------------------- 541 + # Caddy — HTTPS reverse proxy for labeler, status, and office 520 542 # 521 543 # Uses standard ACME HTTP-01 challenge (port 80). Certs are 522 544 # provisioned automatically once DNS points here. ··· 525 547 enable = true; 526 548 globalConfig = '' 527 549 servers { 528 - metrics 550 + metrics { 551 + per_host 552 + } 529 553 } 530 554 ''; 531 555 logDir = "/var/log/caddy"; 556 + virtualHosts.":9180" = { 557 + extraConfig = '' 558 + metrics /metrics 559 + ''; 560 + }; 532 561 virtualHosts."labeler.atmos.email" = { 533 562 extraConfig = '' 534 563 log { ··· 540 569 virtualHosts."status.atmos.email" = { 541 570 extraConfig = '' 542 571 reverse_proxy localhost:8090 572 + ''; 573 + }; 574 + virtualHosts."office.atmospheremail.com" = { 575 + extraConfig = '' 576 + reverse_proxy localhost:8082 543 577 ''; 544 578 }; 545 579 };
+5
infra/nixos/default.nix
··· 138 138 owner = "atmos-relay"; 139 139 group = "atmos-relay"; 140 140 }; 141 + sops.secrets.warmup_seed_addresses = { 142 + owner = "atmos-relay"; 143 + group = "atmos-relay"; 144 + }; 141 145 142 146 # Generate an environment file from sops secrets for the systemd service 143 147 sops.templates."atmos-relay-env" = { ··· 146 150 content = '' 147 151 ADMIN_TOKEN=${config.sops.placeholder.admin_token} 148 152 LABELER_URL=${config.sops.placeholder.labeler_url} 153 + WARMUP_SEED_ADDRESSES=${config.sops.placeholder.warmup_seed_addresses} 149 154 ''; 150 155 }; 151 156
+3 -2
infra/secrets/relay.yaml
··· 1 1 admin_token: ENC[AES256_GCM,data:FQk9P2fDBLrxCuUwJNEFPEToOG8lE/PEqVaw0wORl+ajr+Wu3PK31eEkC98=,iv:m+Ov6jLO9ZozyjBgJTatSCptCkcrkKPtNbpN1lVHbv4=,tag:3wmFLU6vvsgYdohHXEIbJQ==,type:str] 2 2 labeler_url: ENC[AES256_GCM,data:k4NBBxxgmksqRhmhxXTSnhQHNQdFGeQLMgNo,iv:YyF9PgfpUgxUbm3WQyZbBOLux5BpKJPp8fmz8l3Fy6g=,tag:51qXib0xT9KwniXSzMBFhg==,type:str] 3 + warmup_seed_addresses: ENC[AES256_GCM,data:MSjN5GUjRSL+6S/+DF4NuCXmdEyIACbubOdnUGRaH3Hj1ozrxYaFxvs4BlDxvenA/a6UlXaEF+/8dI1U6EMTl5vI4snlpYFnPrYCK8/gyKbQj+El/t3LSFzv3EjAhtOmis16Iy3n5Q==,iv:micqCCF8kUbGUReBGT57ETe7oniz4Sobjed5sLvnUCw=,tag:gzThQiDBLEMp+RDS+e2qZg==,type:str] 3 4 sops: 4 5 age: 5 6 - recipient: age1kku4ud0z4h6ujn2qums6tupynqq8dhwpcc27kl00rqyeldgmk4lqhcanma ··· 20 21 b0JhUENBY29NM2JoTWl6WTF4RFRreWMKgh4bFCpgqi6lgCxQSj//TYpxhMPHxiM7 21 22 H4FN0JN6m3I2r0QwJP/Bw+WNrrB1voBjNGDfVQgnMFC+slFvZSMA2Q== 22 23 -----END AGE ENCRYPTED FILE----- 23 - lastmodified: "2026-04-15T18:12:35Z" 24 - mac: ENC[AES256_GCM,data:JV4DwOBY9NkE7elQvyIZRnCZ6Kk8bU7zu/R7ZyFh2WCA47aUHsEAtdr4qiMdGEZfN6zyRq9MB0W2UhDwziEz4BHY6FBol7sL3FO10KmKWZvRbJYhvIeKaZIT0tqbjI4sPjK+GzmyglJ5oOZVM2VefwpCcKyYbwI2XhV5exVyqI8=,iv:gIjlK6V5ndimCNJikNzYGF+QcjfmzJ+lsUTYab7+c7k=,tag:IGonALExO1ET7CO5M5c1YA==,type:str] 24 + lastmodified: "2026-04-23T19:12:02Z" 25 + mac: ENC[AES256_GCM,data:cckFGTrOt/EhXM4WuIjM+oPZec2KNK5o/dW6RwrRLndDDtqNpfA/T4TjDQfrwpASF5l6ujKSH4mtfRe3827C7UEaStpsMGnSGh8xZApU1W3cRx7zRH7W01X+XYtPvw0N9xhguYFNgfQeWCNQzejAA6+MLVb/ATcPdnTsmqXDjSo=,iv:aKGuEuf4x7BeuhHclHKb+SM6XNSVeEFGu5z/KbnujWg=,tag:n3DuuXlXR00rRIRzW6jp6Q==,type:str] 25 26 unencrypted_suffix: _unencrypted 26 27 version: 3.11.0
+311 -50
internal/admin/api.go
··· 5 5 "crypto/rand" 6 6 "crypto/subtle" 7 7 "crypto/x509" 8 + "encoding/hex" 8 9 "encoding/json" 9 10 "errors" 10 11 "fmt" ··· 136 137 operatorDKIMKeys *relay.DKIMKeys 137 138 operatorDKIMDomain string 138 139 140 + // warmup is the optional warmup sender for operator-initiated sends. 141 + // Wired via SetWarmupSender; nil disables the /admin/warmup endpoint. 142 + warmup *relay.WarmupSender 143 + 139 144 // System-mail hooks. Set via SetOpMailer. Optional — when nil, all 140 145 // transactional notifications (operator-ping, welcome, key-regen) 141 146 // are skipped with a logged warning. operatorForwardTo names the ··· 157 162 // swapped out, so no additional locking at the field level. 158 163 enrollStartLimiter *rateLimiter 159 164 } 165 + 166 + // maxDomainsPerMember is the soft limit on how many sending domains a 167 + // single DID may enroll. While all members are effectively new/unpaid, 168 + // this prevents a single identity from accumulating unbounded domains. 169 + // Raise or remove this when paid tiers or an "established member" 170 + // threshold exist. 171 + const maxDomainsPerMember = 2 160 172 161 173 // enrollStartRateLimit is the number of /admin/enroll-start requests 162 174 // permitted per source IP per window. Generous enough that a confused ··· 213 225 // Inbound reply forwarding: sets/updates the forward_to for a registered 214 226 // member domain. Admin-authenticated. Body: {"forwardTo": "real@mailbox.com"} 215 227 a.mux.HandleFunc("/admin/domain/", a.handleDomain) 228 + a.mux.HandleFunc("/admin/warmup", a.handleWarmup) 229 + 230 + // Public email verification endpoint — no auth required. Members click 231 + // the link from their verification email to confirm contact_email ownership. 232 + a.mux.HandleFunc("/verify-email", a.HandleVerifyEmail) 216 233 217 234 // Public (API-key-authenticated) endpoint for members to check their own status. 218 235 // No admin token required — authenticated by the member's SMTP API key. ··· 250 267 // the pending row means we don't need a second form submission after DNS 251 268 // verification. 252 269 type EnrollStartRequest struct { 253 - DID string `json:"did"` 254 - Domain string `json:"domain"` 255 - ContactEmail string `json:"contactEmail,omitempty"` 270 + DID string `json:"did"` 271 + Domain string `json:"domain"` 272 + ContactEmail string `json:"contactEmail,omitempty"` 273 + TermsAccepted bool `json:"termsAccepted,omitempty"` 256 274 } 257 275 258 276 // EnrollStartResponse is what the server returns after stashing a pending ··· 331 349 http.Error(w, "contactEmail must be a valid email address", http.StatusBadRequest) 332 350 return 333 351 } 352 + if !req.TermsAccepted { 353 + http.Error(w, "terms acceptance required", http.StatusBadRequest) 354 + return 355 + } 334 356 335 357 // Reject if the domain is already owned by any member. Return the 336 358 // conflict early so the user doesn't waste time publishing a TXT ··· 342 364 return 343 365 } 344 366 if existingDomain != nil { 345 - http.Error(w, "domain already registered", http.StatusConflict) 367 + if existingDomain.DID == did { 368 + http.Error(w, "You've already enrolled this domain. Sign in at /account to manage it.", http.StatusConflict) 369 + } else { 370 + // Don't reveal who owns the domain — that leaks membership info. 371 + http.Error(w, "This domain is registered to another account.", http.StatusConflict) 372 + } 373 + return 374 + } 375 + 376 + // Enforce per-DID domain limit. Multi-domain enrollment is supported 377 + // but capped so a single identity can't accumulate unbounded domains 378 + // before paid tiers exist. 379 + existingDomains, err := a.store.ListMemberDomains(r.Context(), did) 380 + if err != nil { 381 + log.Printf("admin.enroll_start: did=%s list_domains_error=%v", did, err) 382 + http.Error(w, "internal error", http.StatusInternalServerError) 383 + return 384 + } 385 + if len(existingDomains) >= maxDomainsPerMember { 386 + http.Error(w, fmt.Sprintf("domain limit reached — your account currently supports up to %d sending domains", maxDomainsPerMember), http.StatusConflict) 346 387 return 347 388 } 348 389 ··· 354 395 } 355 396 now := time.Now().UTC() 356 397 pending := &relaystore.PendingEnrollment{ 357 - Token: token, 358 - DID: did, 359 - Domain: domain, 360 - ContactEmail: contactEmail, 361 - CreatedAt: now, 362 - ExpiresAt: now.Add(pendingEnrollmentTTL), 398 + Token: token, 399 + DID: did, 400 + Domain: domain, 401 + ContactEmail: contactEmail, 402 + TermsAccepted: req.TermsAccepted, 403 + CreatedAt: now, 404 + ExpiresAt: now.Add(pendingEnrollmentTTL), 363 405 } 364 406 if err := a.store.CreatePendingEnrollment(r.Context(), pending); err != nil { 365 407 log.Printf("admin.enroll_start: did=%s domain=%s error=%v", did, domain, err) ··· 532 574 return 533 575 } 534 576 if existingDomain != nil { 535 - http.Error(w, "domain already registered", http.StatusConflict) 577 + if existingDomain.DID == did { 578 + http.Error(w, "You've already enrolled this domain. Sign in at /account to manage it.", http.StatusConflict) 579 + } else { 580 + http.Error(w, "This domain is registered to another account.", http.StatusConflict) 581 + } 582 + return 583 + } 584 + 585 + // Defense-in-depth domain limit check. The primary check lives in 586 + // handleEnrollStart, but a second enrollment could complete between 587 + // start and verify if the DID raced to acquire domains via another 588 + // browser tab or API caller. 589 + existingDomains, err := a.store.ListMemberDomains(r.Context(), did) 590 + if err != nil { 591 + log.Printf("admin.enroll: did=%s list_domains_error=%v", did, err) 592 + http.Error(w, "internal error", http.StatusInternalServerError) 593 + return 594 + } 595 + if len(existingDomains) >= maxDomainsPerMember { 596 + http.Error(w, fmt.Sprintf("domain limit reached — your account currently supports up to %d sending domains", maxDomainsPerMember), http.StatusConflict) 536 597 return 537 598 } 538 599 ··· 590 651 // signal from DID ownership. 591 652 var memberRecord *relaystore.Member 592 653 if existing == nil { 654 + if !pending.TermsAccepted { 655 + http.Error(w, "terms acceptance required", http.StatusBadRequest) 656 + return 657 + } 593 658 memberRecord = &relaystore.Member{ 594 - DID: did, 595 - Status: relaystore.StatusPending, 596 - DIDVerified: false, 597 - HourlyLimit: 100, 598 - DailyLimit: 1000, 599 - CreatedAt: now, 600 - UpdatedAt: now, 659 + DID: did, 660 + Status: relaystore.StatusPending, 661 + DIDVerified: false, 662 + TermsAcceptedAt: now, 663 + TermsVersion: relaystore.CurrentTermsVersion, 664 + HourlyLimit: 100, 665 + DailyLimit: 1000, 666 + CreatedAt: now, 667 + UpdatedAt: now, 601 668 } 602 669 } 603 670 ··· 622 689 623 690 log.Printf("admin.enroll: did=%s domain=%s selector=%s new_did=%v", did, domain, selector, existing == nil) 624 691 625 - // Fire the operator-ping only on truly-new DIDs. Existing-DID-adding- 626 - // new-domain flows don't re-enter operator review — the operator already 627 - // vetted this member on the original enrollment, and re-notifying every 628 - // time they add a domain would train operators to ignore pings. Use a 629 - // background context so a slow MX doesn't block the HTTP response; the 630 - // call is already log-and-continue. 692 + // Fire the operator-ping email only for truly-new DIDs — re-sending the 693 + // ping every time an approved member adds a domain would train operators 694 + // to ignore it. The webhook notification fires for both cases (different 695 + // kinds) so the operator has visibility without email fatigue. 631 696 if existing == nil { 632 697 go a.FireOperatorPing(context.Background(), did, domain, pending.ContactEmail) 633 698 a.notifyEvent(notify.KindMemberPending, did, domain, "", pending.ContactEmail) 699 + } else { 700 + a.notifyEvent(notify.KindMemberDomainAdded, did, domain, "", pending.ContactEmail) 701 + } 702 + 703 + // Trigger contact email verification if a contact_email was provided. 704 + // Runs in a goroutine so a slow DB/enqueue doesn't block the HTTP response. 705 + if pending.ContactEmail != "" { 706 + go a.TriggerEmailVerification(context.Background(), domain, pending.ContactEmail) 634 707 } 635 708 636 709 // Check SPF alignment if checker is configured ··· 673 746 // --- Member status / suspend / reactivate --- 674 747 675 748 type DomainStatusInfo struct { 676 - Domain string `json:"domain"` 749 + Domain string `json:"domain"` 750 + EmailVerified bool `json:"emailVerified"` 677 751 } 678 752 679 753 type MemberStatusResponse struct { 680 - DID string `json:"did"` 681 - Domains []DomainStatusInfo `json:"domains"` 682 - Status string `json:"status"` 683 - SuspendReason string `json:"suspendReason,omitempty"` 684 - SendCount int64 `json:"sendCount"` 685 - HourlyLimit int `json:"hourlyLimit"` 686 - DailyLimit int `json:"dailyLimit"` 687 - CreatedAt string `json:"createdAt"` 754 + DID string `json:"did"` 755 + Domains []DomainStatusInfo `json:"domains"` 756 + Status string `json:"status"` 757 + SuspendReason string `json:"suspendReason,omitempty"` 758 + SendCount int64 `json:"sendCount"` 759 + HourlyLimit int `json:"hourlyLimit"` 760 + DailyLimit int `json:"dailyLimit"` 761 + TermsAcceptedAt string `json:"termsAcceptedAt,omitempty"` 762 + TermsVersion string `json:"termsVersion,omitempty"` 763 + CreatedAt string `json:"createdAt"` 688 764 } 689 765 690 766 func (a *API) handleMember(w http.ResponseWriter, r *http.Request) { ··· 729 805 a.handleBypassRemove(w, r, did) 730 806 case action == "regenerate-key" && r.Method == http.MethodPost: 731 807 a.handleMemberRegenerateKey(w, r, did) 808 + case action == "send-verification" && r.Method == http.MethodPost: 809 + a.handleMemberSendVerification(w, r, did) 732 810 default: 733 811 http.Error(w, "not found", http.StatusNotFound) 734 812 } ··· 747 825 748 826 domainInfos := make([]DomainStatusInfo, len(domains)) 749 827 for i, d := range domains { 750 - domainInfos[i] = DomainStatusInfo{Domain: d.Domain} 828 + domainInfos[i] = DomainStatusInfo{Domain: d.Domain, EmailVerified: d.EmailVerified} 829 + } 830 + 831 + var termsAcceptedAt string 832 + if !member.TermsAcceptedAt.IsZero() { 833 + termsAcceptedAt = member.TermsAcceptedAt.Format(time.RFC3339) 751 834 } 752 835 753 836 w.Header().Set("Content-Type", "application/json") 754 837 json.NewEncoder(w).Encode(MemberStatusResponse{ 755 - DID: member.DID, 756 - Domains: domainInfos, 757 - Status: member.Status, 758 - SuspendReason: member.SuspendReason, 759 - SendCount: member.SendCount, 760 - HourlyLimit: member.HourlyLimit, 761 - DailyLimit: member.DailyLimit, 762 - CreatedAt: member.CreatedAt.Format(time.RFC3339), 838 + DID: member.DID, 839 + Domains: domainInfos, 840 + Status: member.Status, 841 + SuspendReason: member.SuspendReason, 842 + SendCount: member.SendCount, 843 + HourlyLimit: member.HourlyLimit, 844 + DailyLimit: member.DailyLimit, 845 + TermsAcceptedAt: termsAcceptedAt, 846 + TermsVersion: member.TermsVersion, 847 + CreatedAt: member.CreatedAt.Format(time.RFC3339), 763 848 }) 764 849 } 765 850 ··· 942 1027 for j, d := range m.Domains { 943 1028 domainInfos[j] = DomainStatusInfo{Domain: d} 944 1029 } 1030 + var termsAcceptedAt string 1031 + if !m.TermsAcceptedAt.IsZero() { 1032 + termsAcceptedAt = m.TermsAcceptedAt.Format(time.RFC3339) 1033 + } 945 1034 resp[i] = MemberStatusResponse{ 946 - DID: m.DID, 947 - Domains: domainInfos, 948 - Status: m.Status, 949 - SuspendReason: m.SuspendReason, 950 - SendCount: m.SendCount, 951 - HourlyLimit: m.HourlyLimit, 952 - DailyLimit: m.DailyLimit, 953 - CreatedAt: m.CreatedAt.Format(time.RFC3339), 1035 + DID: m.DID, 1036 + Domains: domainInfos, 1037 + Status: m.Status, 1038 + SuspendReason: m.SuspendReason, 1039 + SendCount: m.SendCount, 1040 + HourlyLimit: m.HourlyLimit, 1041 + DailyLimit: m.DailyLimit, 1042 + TermsAcceptedAt: termsAcceptedAt, 1043 + TermsVersion: m.TermsVersion, 1044 + CreatedAt: m.CreatedAt.Format(time.RFC3339), 954 1045 } 955 1046 } 956 1047 ··· 1266 1357 log.Printf("member.forward_to_set: domain=%s forward_to=%s", domain, req.ForwardTo) 1267 1358 w.WriteHeader(http.StatusNoContent) 1268 1359 } 1360 + 1361 + // --- Warmup --- 1362 + 1363 + // SetWarmupSender wires the operator-initiated warmup path. Nil disables 1364 + // the /admin/warmup endpoint (returns 501). 1365 + func (a *API) SetWarmupSender(ws *relay.WarmupSender) { 1366 + a.warmup = ws 1367 + } 1368 + 1369 + // WarmupSeedCount returns the number of configured seed addresses, or 0 1370 + // if warmup is not configured. Used by the UI to show/hide the button. 1371 + func (a *API) WarmupSeedCount() int { 1372 + if a.warmup == nil { 1373 + return 0 1374 + } 1375 + return a.warmup.SeedCount() 1376 + } 1377 + 1378 + // SendWarmup is the in-process entry point for warmup sends (used by the 1379 + // UI handler). Callers get the result directly without HTTP round-tripping. 1380 + func (a *API) SendWarmup(ctx context.Context, did string) (*relay.WarmupResult, error) { 1381 + if a.warmup == nil { 1382 + return nil, fmt.Errorf("warmup not configured") 1383 + } 1384 + return a.warmup.SendBatch(ctx, did) 1385 + } 1386 + 1387 + // handleWarmup sends a batch of warmup emails on behalf of a member. 1388 + // POST /admin/warmup?did=did:plc:... — admin-authenticated. 1389 + func (a *API) handleWarmup(w http.ResponseWriter, r *http.Request) { 1390 + if r.Method != http.MethodPost { 1391 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 1392 + return 1393 + } 1394 + if !a.requireAuth(w, r) { 1395 + return 1396 + } 1397 + if a.warmup == nil { 1398 + http.Error(w, "warmup not configured — set WARMUP_SEED_ADDRESSES", http.StatusNotImplemented) 1399 + return 1400 + } 1401 + 1402 + did := r.URL.Query().Get("did") 1403 + if did == "" { 1404 + http.Error(w, "did query parameter required", http.StatusBadRequest) 1405 + return 1406 + } 1407 + if !validDID.MatchString(did) { 1408 + http.Error(w, "invalid DID format", http.StatusBadRequest) 1409 + return 1410 + } 1411 + 1412 + result, err := a.warmup.SendBatch(r.Context(), did) 1413 + if err != nil { 1414 + log.Printf("admin.warmup: did=%s error=%v", did, err) 1415 + http.Error(w, err.Error(), http.StatusInternalServerError) 1416 + return 1417 + } 1418 + 1419 + log.Printf("admin.warmup: did=%s sent=%d failed=%d", did, result.Sent, result.Failed) 1420 + w.Header().Set("Content-Type", "application/json") 1421 + json.NewEncoder(w).Encode(result) 1422 + } 1423 + 1424 + // --- Email verification --- 1425 + 1426 + // emailVerifyTokenTTL is how long a verification token remains valid. 1427 + // 72 hours gives the member plenty of time to check their inbox, even 1428 + // across weekends. 1429 + const emailVerifyTokenTTL = 72 * time.Hour 1430 + 1431 + // generateEmailVerifyToken returns a 32-byte cryptographically random 1432 + // token, hex-encoded to 64 characters. 1433 + func generateEmailVerifyToken() (string, error) { 1434 + var b [32]byte 1435 + if _, err := rand.Read(b[:]); err != nil { 1436 + return "", fmt.Errorf("generate verify token: %v", err) 1437 + } 1438 + return hex.EncodeToString(b[:]), nil 1439 + } 1440 + 1441 + // TriggerEmailVerification generates a token, stores it, and enqueues a 1442 + // verification email. Log-and-continue — a broken verification path must 1443 + // not block the caller's primary action (enrollment, email change). 1444 + func (a *API) TriggerEmailVerification(ctx context.Context, domain, contactEmail string) { 1445 + token, err := generateEmailVerifyToken() 1446 + if err != nil { 1447 + log.Printf("email_verify.token_error: domain=%s error=%v", domain, err) 1448 + return 1449 + } 1450 + expiresAt := time.Now().UTC().Add(emailVerifyTokenTTL) 1451 + if err := a.store.SetEmailVerifyToken(ctx, domain, token, expiresAt); err != nil { 1452 + log.Printf("email_verify.store_error: domain=%s error=%v", domain, err) 1453 + return 1454 + } 1455 + verifyURL := "https://" + a.domain + "/verify-email?token=" + token 1456 + a.FireEmailVerification(ctx, domain, contactEmail, verifyURL) 1457 + } 1458 + 1459 + // HandleVerifyEmail is the public (no auth) GET endpoint that a member 1460 + // clicks from their verification email. Looks up the token, marks the 1461 + // email as verified, and renders a simple success or error page. 1462 + func (a *API) HandleVerifyEmail(w http.ResponseWriter, r *http.Request) { 1463 + if r.Method != http.MethodGet { 1464 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 1465 + return 1466 + } 1467 + token := r.URL.Query().Get("token") 1468 + if token == "" { 1469 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 1470 + w.WriteHeader(http.StatusBadRequest) 1471 + fmt.Fprint(w, verifyEmailPage("Missing Token", "No verification token provided. Check the link in your email.")) 1472 + return 1473 + } 1474 + domain, err := a.store.VerifyEmailByToken(r.Context(), token) 1475 + if err != nil { 1476 + log.Printf("email_verify.error: token=%.8s... error=%v", token, err) 1477 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 1478 + w.WriteHeader(http.StatusBadRequest) 1479 + fmt.Fprint(w, verifyEmailPage("Verification Failed", "This link is invalid or has expired. Request a new verification email from your account settings.")) 1480 + return 1481 + } 1482 + log.Printf("email_verify.success: domain=%s", domain) 1483 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 1484 + fmt.Fprint(w, verifyEmailPage("Email Verified", 1485 + fmt.Sprintf("Your contact email for <strong>%s</strong> has been verified. You can close this page.", domain))) 1486 + } 1487 + 1488 + // verifyEmailPage renders a minimal HTML page for the verification result. 1489 + func verifyEmailPage(title, message string) string { 1490 + return fmt.Sprintf(`<!DOCTYPE html> 1491 + <html lang="en"> 1492 + <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"> 1493 + <title>%s - Atmosphere Mail</title> 1494 + <style>body{font-family:system-ui,sans-serif;max-width:480px;margin:80px auto;padding:0 20px;text-align:center} 1495 + h1{font-size:1.4em}p{color:#555;line-height:1.6}</style> 1496 + </head> 1497 + <body><h1>%s</h1><p>%s</p></body> 1498 + </html>`, title, title, message) 1499 + } 1500 + 1501 + // handleMemberSendVerification is the admin endpoint POST 1502 + // /admin/member/{did}/send-verification. For each of the member's 1503 + // domains that has a non-empty, unverified contact_email: generates a 1504 + // token, stores it, and enqueues a verification email. 1505 + func (a *API) handleMemberSendVerification(w http.ResponseWriter, r *http.Request, did string) { 1506 + domains, err := a.store.ListMemberDomains(r.Context(), did) 1507 + if err != nil { 1508 + log.Printf("admin.send_verification: did=%s error=%v", did, err) 1509 + http.Error(w, "internal error", http.StatusInternalServerError) 1510 + return 1511 + } 1512 + if len(domains) == 0 { 1513 + http.Error(w, "member has no domains", http.StatusNotFound) 1514 + return 1515 + } 1516 + 1517 + var sent int 1518 + for _, d := range domains { 1519 + if d.ContactEmail == "" || d.EmailVerified { 1520 + continue 1521 + } 1522 + a.TriggerEmailVerification(r.Context(), d.Domain, d.ContactEmail) 1523 + sent++ 1524 + } 1525 + 1526 + log.Printf("admin.send_verification: did=%s domains_triggered=%d", did, sent) 1527 + w.Header().Set("Content-Type", "application/json") 1528 + json.NewEncoder(w).Encode(map[string]any{"did": did, "verificationsSent": sent}) 1529 + }
+192 -5
internal/admin/api_test.go
··· 63 63 // Helpers keep individual tests focused on what they're actually pinning. 64 64 func startEnrollment(t *testing.T, api *API, did, domain string) EnrollStartResponse { 65 65 t.Helper() 66 - body, _ := json.Marshal(EnrollStartRequest{DID: did, Domain: domain}) 66 + body, _ := json.Marshal(EnrollStartRequest{DID: did, Domain: domain, TermsAccepted: true}) 67 67 req := httptest.NewRequest(http.MethodPost, "/admin/enroll-start", bytes.NewReader(body)) 68 68 w := httptest.NewRecorder() 69 69 api.ServeHTTP(w, req) ··· 530 530 } 531 531 532 532 func TestEnrollStart_RejectsAlreadyRegisteredDomain(t *testing.T) { 533 - // If the domain is already owned by a member, fail fast so the user 534 - // doesn't waste time publishing DNS records for a domain they can't 535 - // claim. 533 + // If the domain is already owned by a different member, fail fast so 534 + // the user doesn't waste time publishing DNS records for a domain 535 + // they can't claim. The message must NOT reveal who owns the domain. 536 536 api, store, _ := testEnrollAPI(t) 537 537 ctx := context.Background() 538 538 now := time.Now().UTC() ··· 544 544 DKIMSelector: "s", CreatedAt: now, 545 545 }) 546 546 547 - body, _ := json.Marshal(EnrollStartRequest{DID: "did:plc:ddddddddeeeeeeeeffffffff", Domain: "taken.example.com"}) 547 + body, _ := json.Marshal(EnrollStartRequest{DID: "did:plc:ddddddddeeeeeeeeffffffff", Domain: "taken.example.com", TermsAccepted: true}) 548 548 req := httptest.NewRequest(http.MethodPost, "/admin/enroll-start", bytes.NewReader(body)) 549 549 w := httptest.NewRecorder() 550 550 api.ServeHTTP(w, req) 551 551 if w.Code != http.StatusConflict { 552 552 t.Errorf("status = %d, want 409", w.Code) 553 553 } 554 + respBody := w.Body.String() 555 + if strings.Contains(respBody, existingDID) { 556 + t.Error("response must not reveal the DID that owns the domain") 557 + } 558 + if !strings.Contains(respBody, "another account") { 559 + t.Errorf("different-DID conflict should mention 'another account', got: %s", respBody) 560 + } 561 + } 562 + 563 + func TestEnrollStart_OwnDomainAlreadyRegistered(t *testing.T) { 564 + // When a DID tries to enroll a domain it already owns, the message 565 + // should direct them to /account instead of a generic "already 566 + // registered" error. This is a common UX path: user forgets they 567 + // already enrolled and goes through the wizard again. 568 + api, store, _ := testEnrollAPI(t) 569 + ctx := context.Background() 570 + now := time.Now().UTC() 571 + did := "did:plc:aaaaaaaabbbbbbbbcccccccc" 572 + store.InsertMember(ctx, &relaystore.Member{DID: did, Status: relaystore.StatusActive, CreatedAt: now, UpdatedAt: now}) 573 + store.InsertMemberDomain(ctx, &relaystore.MemberDomain{ 574 + Domain: "mine.example.com", DID: did, 575 + APIKeyHash: []byte("h"), DKIMRSAPriv: []byte("r"), DKIMEdPriv: []byte("e"), 576 + DKIMSelector: "s", CreatedAt: now, 577 + }) 578 + 579 + body, _ := json.Marshal(EnrollStartRequest{DID: did, Domain: "mine.example.com", TermsAccepted: true}) 580 + req := httptest.NewRequest(http.MethodPost, "/admin/enroll-start", bytes.NewReader(body)) 581 + w := httptest.NewRecorder() 582 + api.ServeHTTP(w, req) 583 + if w.Code != http.StatusConflict { 584 + t.Errorf("status = %d, want 409", w.Code) 585 + } 586 + respBody := w.Body.String() 587 + if !strings.Contains(respBody, "already enrolled") { 588 + t.Errorf("same-DID conflict should say 'already enrolled', got: %s", respBody) 589 + } 590 + if !strings.Contains(respBody, "/account") { 591 + t.Errorf("same-DID conflict should mention /account, got: %s", respBody) 592 + } 593 + } 594 + 595 + func TestEnrollStart_RejectsWithoutTermsAcceptance(t *testing.T) { 596 + api, _, _ := testEnrollAPI(t) 597 + body, _ := json.Marshal(EnrollStartRequest{DID: "did:plc:aaaaaaaabbbbbbbbcccccccc", Domain: "example.com", TermsAccepted: false}) 598 + req := httptest.NewRequest(http.MethodPost, "/admin/enroll-start", bytes.NewReader(body)) 599 + w := httptest.NewRecorder() 600 + api.ServeHTTP(w, req) 601 + if w.Code != http.StatusBadRequest { 602 + t.Errorf("status = %d, want 400", w.Code) 603 + } 554 604 } 555 605 556 606 func TestEnrollStart_NotImplementedWhenVerifierNil(t *testing.T) { ··· 613 663 // Phase 2 flips this to true via atproto OAuth. Phase 1 must leave it false. 614 664 if member.DIDVerified { 615 665 t.Error("self-service DID must NOT be marked verified — that's the Phase 2 OAuth gate") 666 + } 667 + if member.TermsVersion != relaystore.CurrentTermsVersion { 668 + t.Errorf("TermsVersion = %q, want %q", member.TermsVersion, relaystore.CurrentTermsVersion) 669 + } 670 + if member.TermsAcceptedAt.IsZero() { 671 + t.Error("TermsAcceptedAt not recorded") 616 672 } 617 673 618 674 dom, _ := store.GetMemberDomain(context.Background(), domain) ··· 1184 1240 d, _ := store.GetMemberDomain(context.Background(), "atmosphere-mail.org") 1185 1241 if d.ForwardTo != "" { 1186 1242 t.Errorf("forward_to was modified despite auth failure: %q", d.ForwardTo) 1243 + } 1244 + } 1245 + 1246 + // --- Multi-domain enrollment limit --- 1247 + 1248 + func TestEnrollStart_AllowsDIDWithOneDomain(t *testing.T) { 1249 + // A DID that already has one domain should be allowed to start 1250 + // enrollment for a second domain (under the default 2-domain limit). 1251 + api, store, _ := testEnrollAPI(t) 1252 + ctx := context.Background() 1253 + now := time.Now().UTC() 1254 + did := "did:plc:aaaaaaaabbbbbbbbcccccccc" 1255 + store.InsertMember(ctx, &relaystore.Member{DID: did, Status: relaystore.StatusActive, HourlyLimit: 100, DailyLimit: 1000, CreatedAt: now, UpdatedAt: now}) 1256 + store.InsertMemberDomain(ctx, &relaystore.MemberDomain{ 1257 + Domain: "first.example.com", DID: did, 1258 + APIKeyHash: []byte("h"), DKIMRSAPriv: []byte("r"), DKIMEdPriv: []byte("e"), 1259 + DKIMSelector: "s", CreatedAt: now, 1260 + }) 1261 + 1262 + body, _ := json.Marshal(EnrollStartRequest{DID: did, Domain: "second.example.com", TermsAccepted: true}) 1263 + req := httptest.NewRequest(http.MethodPost, "/admin/enroll-start", bytes.NewReader(body)) 1264 + w := httptest.NewRecorder() 1265 + api.ServeHTTP(w, req) 1266 + if w.Code != http.StatusOK { 1267 + t.Errorf("status = %d, want 200; a DID with 1 domain should be allowed a second: %s", w.Code, w.Body.String()) 1268 + } 1269 + } 1270 + 1271 + func TestEnrollStart_RejectsDIDAtDomainLimit(t *testing.T) { 1272 + // A DID that already has 2 domains should be rejected when trying 1273 + // to start enrollment for a third. The default per-member domain limit 1274 + // is 2 while all members are effectively unpaid/new. 1275 + api, store, _ := testEnrollAPI(t) 1276 + ctx := context.Background() 1277 + now := time.Now().UTC() 1278 + did := "did:plc:aaaaaaaabbbbbbbbcccccccc" 1279 + store.InsertMember(ctx, &relaystore.Member{DID: did, Status: relaystore.StatusActive, HourlyLimit: 100, DailyLimit: 1000, CreatedAt: now, UpdatedAt: now}) 1280 + for _, d := range []string{"first.example.com", "second.example.com"} { 1281 + store.InsertMemberDomain(ctx, &relaystore.MemberDomain{ 1282 + Domain: d, DID: did, 1283 + APIKeyHash: []byte("h"), DKIMRSAPriv: []byte("r"), DKIMEdPriv: []byte("e"), 1284 + DKIMSelector: "s", CreatedAt: now, 1285 + }) 1286 + } 1287 + 1288 + body, _ := json.Marshal(EnrollStartRequest{DID: did, Domain: "third.example.com", TermsAccepted: true}) 1289 + req := httptest.NewRequest(http.MethodPost, "/admin/enroll-start", bytes.NewReader(body)) 1290 + w := httptest.NewRecorder() 1291 + api.ServeHTTP(w, req) 1292 + if w.Code != http.StatusConflict { 1293 + t.Errorf("status = %d, want 409; a DID at the domain limit should be rejected", w.Code) 1294 + } 1295 + if !strings.Contains(w.Body.String(), "domain limit") { 1296 + t.Errorf("body should mention domain limit, got: %s", w.Body.String()) 1297 + } 1298 + } 1299 + 1300 + func TestEnrollWithToken_RejectsDIDAtDomainLimit(t *testing.T) { 1301 + // Defense-in-depth: even if a pending enrollment sneaks past 1302 + // enroll-start (e.g. the second domain was added between start and 1303 + // completion), the final enrollment must check the count again. 1304 + api, store, lk := testEnrollAPI(t) 1305 + ctx := context.Background() 1306 + now := time.Now().UTC() 1307 + did := "did:plc:aaaaaaaabbbbbbbbcccccccc" 1308 + 1309 + // Start enrollment BEFORE the DID has any domains — should succeed. 1310 + start := startEnrollment(t, api, did, "new.example.com") 1311 + lk.records["_atmos-enroll.new.example.com"] = []string{start.DNSValue} 1312 + 1313 + // Now create the member with 2 existing domains (simulating a race: 1314 + // another enrollment completed between our start and verify). 1315 + store.InsertMember(ctx, &relaystore.Member{DID: did, Status: relaystore.StatusActive, HourlyLimit: 100, DailyLimit: 1000, CreatedAt: now, UpdatedAt: now}) 1316 + for _, d := range []string{"first.example.com", "second.example.com"} { 1317 + store.InsertMemberDomain(ctx, &relaystore.MemberDomain{ 1318 + Domain: d, DID: did, 1319 + APIKeyHash: []byte("h"), DKIMRSAPriv: []byte("r"), DKIMEdPriv: []byte("e"), 1320 + DKIMSelector: "s", CreatedAt: now, 1321 + }) 1322 + } 1323 + 1324 + body, _ := json.Marshal(EnrollRequest{Token: start.Token}) 1325 + req := httptest.NewRequest(http.MethodPost, "/admin/enroll", bytes.NewReader(body)) 1326 + w := httptest.NewRecorder() 1327 + api.ServeHTTP(w, req) 1328 + if w.Code != http.StatusConflict { 1329 + t.Errorf("status = %d, want 409; enrollment should be rejected at domain limit", w.Code) 1330 + } 1331 + } 1332 + 1333 + func TestEnrollWithToken_ExistingDIDAddsDomain(t *testing.T) { 1334 + // An already-enrolled DID with one domain should be able to add a 1335 + // second domain via the normal enrollment flow. The member record 1336 + // already exists, so only a new domain row is inserted. 1337 + api, store, lk := testEnrollAPI(t) 1338 + ctx := context.Background() 1339 + did := "did:plc:aaaaaaaabbbbbbbbcccccccc" 1340 + 1341 + // Enroll the first domain via the normal flow. 1342 + start1 := startEnrollment(t, api, did, "first.example.com") 1343 + lk.records["_atmos-enroll.first.example.com"] = []string{start1.DNSValue} 1344 + body1, _ := json.Marshal(EnrollRequest{Token: start1.Token}) 1345 + w1 := httptest.NewRecorder() 1346 + api.ServeHTTP(w1, httptest.NewRequest(http.MethodPost, "/admin/enroll", bytes.NewReader(body1))) 1347 + if w1.Code != http.StatusOK { 1348 + t.Fatalf("first enroll: status=%d body=%s", w1.Code, w1.Body.String()) 1349 + } 1350 + 1351 + // Enroll the second domain for the same DID. 1352 + start2 := startEnrollment(t, api, did, "second.example.com") 1353 + lk.records["_atmos-enroll.second.example.com"] = []string{start2.DNSValue} 1354 + body2, _ := json.Marshal(EnrollRequest{Token: start2.Token}) 1355 + w2 := httptest.NewRecorder() 1356 + api.ServeHTTP(w2, httptest.NewRequest(http.MethodPost, "/admin/enroll", bytes.NewReader(body2))) 1357 + if w2.Code != http.StatusOK { 1358 + t.Fatalf("second enroll: status=%d body=%s", w2.Code, w2.Body.String()) 1359 + } 1360 + 1361 + // Verify both domains are registered under the same DID. 1362 + domains, err := store.ListMemberDomains(ctx, did) 1363 + if err != nil { 1364 + t.Fatalf("ListMemberDomains: %v", err) 1365 + } 1366 + if len(domains) != 2 { 1367 + t.Errorf("expected 2 domains, got %d", len(domains)) 1368 + } 1369 + 1370 + // The member record should still be the original one (not duplicated). 1371 + member, err := store.GetMember(ctx, did) 1372 + if err != nil || member == nil { 1373 + t.Fatalf("GetMember: %v", err) 1187 1374 } 1188 1375 } 1189 1376
+373
internal/admin/email_verify_test.go
··· 1 + package admin 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "net/http" 8 + "net/http/httptest" 9 + "testing" 10 + "time" 11 + 12 + "atmosphere-mail/internal/relay" 13 + "atmosphere-mail/internal/relaystore" 14 + ) 15 + 16 + // --- FireEmailVerification --- 17 + 18 + func TestFireEmailVerification_EnqueuesWithPayload(t *testing.T) { 19 + _, store := testAdminAPI(t) 20 + api := New(store, "test-admin-token", "atmos.email") 21 + 22 + api.FireEmailVerification(context.Background(), "verify.example.com", "member@example.com", "https://atmos.email/verify-email?token=abc123") 23 + 24 + now := time.Now().UTC().Unix() 25 + claimed, err := store.ClaimPendingNotifications(context.Background(), now, 10) 26 + if err != nil { 27 + t.Fatalf("ClaimPendingNotifications: %v", err) 28 + } 29 + if len(claimed) != 1 { 30 + t.Fatalf("expected 1 enqueued, got %d", len(claimed)) 31 + } 32 + got := claimed[0] 33 + if got.Kind != relaystore.NotificationKindEmailVerification { 34 + t.Errorf("Kind: want %q got %q", relaystore.NotificationKindEmailVerification, got.Kind) 35 + } 36 + if got.Recipient != "member@example.com" { 37 + t.Errorf("Recipient: want member@example.com got %q", got.Recipient) 38 + } 39 + if d, _ := got.Payload["domain"].(string); d != "verify.example.com" { 40 + t.Errorf("Payload[domain]: want verify.example.com got %v", got.Payload["domain"]) 41 + } 42 + if u, _ := got.Payload["verifyURL"].(string); u != "https://atmos.email/verify-email?token=abc123" { 43 + t.Errorf("Payload[verifyURL]: want full URL got %v", got.Payload["verifyURL"]) 44 + } 45 + } 46 + 47 + func TestFireEmailVerification_EmptyContactIsNoop(t *testing.T) { 48 + _, store := testAdminAPI(t) 49 + api := New(store, "test-admin-token", "atmos.email") 50 + 51 + api.FireEmailVerification(context.Background(), "verify.example.com", "", "https://atmos.email/verify-email?token=abc") 52 + 53 + now := time.Now().UTC().Unix() 54 + claimed, _ := store.ClaimPendingNotifications(context.Background(), now, 10) 55 + if len(claimed) != 0 { 56 + t.Errorf("expected 0 enqueued when contact_email empty, got %d", len(claimed)) 57 + } 58 + } 59 + 60 + // --- VerifyEmailByToken --- 61 + 62 + func TestVerifyEmailByToken_Success(t *testing.T) { 63 + _, store := testAdminAPI(t) 64 + ctx := context.Background() 65 + now := time.Now().UTC() 66 + did := "did:plc:verifysuccesstest1111111" 67 + 68 + store.InsertMember(ctx, &relaystore.Member{DID: did, Status: relaystore.StatusActive, CreatedAt: now, UpdatedAt: now}) 69 + store.InsertMemberDomain(ctx, &relaystore.MemberDomain{ 70 + Domain: "verified.example.com", DID: did, 71 + APIKeyHash: []byte("h"), DKIMRSAPriv: []byte("r"), DKIMEdPriv: []byte("e"), 72 + DKIMSelector: "s", ContactEmail: "user@example.com", CreatedAt: now, 73 + }) 74 + 75 + token := "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" 76 + expiresAt := now.Add(72 * time.Hour) 77 + if err := store.SetEmailVerifyToken(ctx, "verified.example.com", token, expiresAt); err != nil { 78 + t.Fatalf("SetEmailVerifyToken: %v", err) 79 + } 80 + 81 + domain, err := store.VerifyEmailByToken(ctx, token) 82 + if err != nil { 83 + t.Fatalf("VerifyEmailByToken: %v", err) 84 + } 85 + if domain != "verified.example.com" { 86 + t.Errorf("domain = %q, want verified.example.com", domain) 87 + } 88 + 89 + verified, err := store.IsEmailVerified(ctx, "verified.example.com") 90 + if err != nil { 91 + t.Fatalf("IsEmailVerified: %v", err) 92 + } 93 + if !verified { 94 + t.Error("expected email_verified=true after VerifyEmailByToken") 95 + } 96 + } 97 + 98 + func TestVerifyEmailByToken_Expired(t *testing.T) { 99 + _, store := testAdminAPI(t) 100 + ctx := context.Background() 101 + now := time.Now().UTC() 102 + did := "did:plc:verifyexpiredtest111111" 103 + 104 + store.InsertMember(ctx, &relaystore.Member{DID: did, Status: relaystore.StatusActive, CreatedAt: now, UpdatedAt: now}) 105 + store.InsertMemberDomain(ctx, &relaystore.MemberDomain{ 106 + Domain: "expired.example.com", DID: did, 107 + APIKeyHash: []byte("h"), DKIMRSAPriv: []byte("r"), DKIMEdPriv: []byte("e"), 108 + DKIMSelector: "s", ContactEmail: "user@example.com", CreatedAt: now, 109 + }) 110 + 111 + token := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" 112 + expiredAt := now.Add(-1 * time.Hour) 113 + if err := store.SetEmailVerifyToken(ctx, "expired.example.com", token, expiredAt); err != nil { 114 + t.Fatalf("SetEmailVerifyToken: %v", err) 115 + } 116 + 117 + _, err := store.VerifyEmailByToken(ctx, token) 118 + if err == nil { 119 + t.Fatal("expected error for expired token, got nil") 120 + } 121 + 122 + verified, _ := store.IsEmailVerified(ctx, "expired.example.com") 123 + if verified { 124 + t.Error("expired token should NOT mark email as verified") 125 + } 126 + } 127 + 128 + func TestVerifyEmailByToken_Invalid(t *testing.T) { 129 + _, store := testAdminAPI(t) 130 + ctx := context.Background() 131 + 132 + _, err := store.VerifyEmailByToken(ctx, "nonexistent-token-value") 133 + if err == nil { 134 + t.Fatal("expected error for invalid token, got nil") 135 + } 136 + } 137 + 138 + // --- Email change resets verification --- 139 + 140 + func TestEmailChangeResetsVerification(t *testing.T) { 141 + _, store := testAdminAPI(t) 142 + ctx := context.Background() 143 + now := time.Now().UTC() 144 + did := "did:plc:resetverifytest11111111" 145 + 146 + store.InsertMember(ctx, &relaystore.Member{DID: did, Status: relaystore.StatusActive, CreatedAt: now, UpdatedAt: now}) 147 + store.InsertMemberDomain(ctx, &relaystore.MemberDomain{ 148 + Domain: "reset.example.com", DID: did, 149 + APIKeyHash: []byte("h"), DKIMRSAPriv: []byte("r"), DKIMEdPriv: []byte("e"), 150 + DKIMSelector: "s", ContactEmail: "old@example.com", CreatedAt: now, 151 + }) 152 + 153 + token := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" 154 + if err := store.SetEmailVerifyToken(ctx, "reset.example.com", token, now.Add(72*time.Hour)); err != nil { 155 + t.Fatalf("SetEmailVerifyToken: %v", err) 156 + } 157 + if _, err := store.VerifyEmailByToken(ctx, token); err != nil { 158 + t.Fatalf("VerifyEmailByToken: %v", err) 159 + } 160 + 161 + verified, _ := store.IsEmailVerified(ctx, "reset.example.com") 162 + if !verified { 163 + t.Fatal("should be verified before email change") 164 + } 165 + 166 + if err := store.UpdateDomainContactEmail(ctx, "reset.example.com", "new@example.com"); err != nil { 167 + t.Fatalf("UpdateDomainContactEmail: %v", err) 168 + } 169 + 170 + verified, _ = store.IsEmailVerified(ctx, "reset.example.com") 171 + if verified { 172 + t.Error("email_verified should be false after contact_email change") 173 + } 174 + } 175 + 176 + // --- DeliverNotification dispatch --- 177 + 178 + func TestDeliverNotification_EmailVerification(t *testing.T) { 179 + _, store := testAdminAPI(t) 180 + api := New(store, "test-admin-token", "atmos.email") 181 + 182 + cap := &capturedSend{} 183 + mailer := relay.NewOpMailer(relay.OpMailContext{RelayDomain: "atmos.email"}, nil, cap.send) 184 + api.SetOpMailer(mailer, "", "") 185 + 186 + if err := api.DeliverNotification(context.Background(), relaystore.Notification{ 187 + Kind: relaystore.NotificationKindEmailVerification, 188 + Recipient: "verify@example.com", 189 + Payload: map[string]any{ 190 + "domain": "v.example.com", 191 + "verifyURL": "https://atmos.email/verify-email?token=xyz", 192 + }, 193 + }); err != nil { 194 + t.Fatalf("email_verification delivery: %v", err) 195 + } 196 + if !bytes.Contains(cap.lastData, []byte("v.example.com")) { 197 + t.Errorf("body missing domain: %s", cap.lastData) 198 + } 199 + if !bytes.Contains(cap.lastData, []byte("https://atmos.email/verify-email?token=xyz")) { 200 + t.Errorf("body missing verify URL: %s", cap.lastData) 201 + } 202 + } 203 + 204 + // --- Verify-email HTTP endpoint --- 205 + 206 + func TestVerifyEmailEndpoint_Success(t *testing.T) { 207 + api, store := testAdminAPI(t) 208 + ctx := context.Background() 209 + now := time.Now().UTC() 210 + did := "did:plc:httpverifytest111111111" 211 + 212 + store.InsertMember(ctx, &relaystore.Member{DID: did, Status: relaystore.StatusActive, CreatedAt: now, UpdatedAt: now}) 213 + store.InsertMemberDomain(ctx, &relaystore.MemberDomain{ 214 + Domain: "httpverify.example.com", DID: did, 215 + APIKeyHash: []byte("h"), DKIMRSAPriv: []byte("r"), DKIMEdPriv: []byte("e"), 216 + DKIMSelector: "s", ContactEmail: "user@example.com", CreatedAt: now, 217 + }) 218 + 219 + token := "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" 220 + if err := store.SetEmailVerifyToken(ctx, "httpverify.example.com", token, now.Add(72*time.Hour)); err != nil { 221 + t.Fatalf("SetEmailVerifyToken: %v", err) 222 + } 223 + 224 + req := httptest.NewRequest(http.MethodGet, "/verify-email?token="+token, nil) 225 + w := httptest.NewRecorder() 226 + api.ServeHTTP(w, req) 227 + 228 + if w.Code != http.StatusOK { 229 + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) 230 + } 231 + if !bytes.Contains(w.Body.Bytes(), []byte("Email Verified")) { 232 + t.Errorf("response body should contain success message; got: %s", w.Body.String()) 233 + } 234 + 235 + verified, _ := store.IsEmailVerified(ctx, "httpverify.example.com") 236 + if !verified { 237 + t.Error("domain should be verified after successful endpoint call") 238 + } 239 + } 240 + 241 + func TestVerifyEmailEndpoint_InvalidToken(t *testing.T) { 242 + api, _ := testAdminAPI(t) 243 + 244 + req := httptest.NewRequest(http.MethodGet, "/verify-email?token=bogus", nil) 245 + w := httptest.NewRecorder() 246 + api.ServeHTTP(w, req) 247 + 248 + if w.Code != http.StatusBadRequest { 249 + t.Errorf("status = %d, want 400", w.Code) 250 + } 251 + } 252 + 253 + func TestVerifyEmailEndpoint_MissingToken(t *testing.T) { 254 + api, _ := testAdminAPI(t) 255 + 256 + req := httptest.NewRequest(http.MethodGet, "/verify-email", nil) 257 + w := httptest.NewRecorder() 258 + api.ServeHTTP(w, req) 259 + 260 + if w.Code != http.StatusBadRequest { 261 + t.Errorf("status = %d, want 400", w.Code) 262 + } 263 + } 264 + 265 + // --- Admin send-verification endpoint --- 266 + 267 + func TestAdminSendVerification_TriggersForUnverifiedDomains(t *testing.T) { 268 + api, store := testAdminAPI(t) 269 + ctx := context.Background() 270 + now := time.Now().UTC() 271 + did := "did:plc:sendverifytestaaaaaaaaaa" 272 + 273 + store.InsertMember(ctx, &relaystore.Member{DID: did, Status: relaystore.StatusActive, CreatedAt: now, UpdatedAt: now}) 274 + store.InsertMemberDomain(ctx, &relaystore.MemberDomain{ 275 + Domain: "sv.example.com", DID: did, 276 + APIKeyHash: []byte("h"), DKIMRSAPriv: []byte("r"), DKIMEdPriv: []byte("e"), 277 + DKIMSelector: "s", ContactEmail: "admin@example.com", CreatedAt: now, 278 + }) 279 + 280 + req := httptest.NewRequest(http.MethodPost, "/admin/member/"+did+"/send-verification", nil) 281 + req.Header.Set("Authorization", "Bearer test-admin-token") 282 + w := httptest.NewRecorder() 283 + api.ServeHTTP(w, req) 284 + 285 + if w.Code != http.StatusOK { 286 + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) 287 + } 288 + 289 + var resp map[string]any 290 + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { 291 + t.Fatalf("decode: %v", err) 292 + } 293 + if sent, _ := resp["verificationsSent"].(float64); sent != 1 { 294 + t.Errorf("verificationsSent = %v, want 1", resp["verificationsSent"]) 295 + } 296 + 297 + claimed, _ := store.ClaimPendingNotifications(ctx, time.Now().UTC().Unix(), 10) 298 + if len(claimed) != 1 { 299 + t.Fatalf("expected 1 enqueued notification, got %d", len(claimed)) 300 + } 301 + if claimed[0].Kind != relaystore.NotificationKindEmailVerification { 302 + t.Errorf("Kind: want %q got %q", relaystore.NotificationKindEmailVerification, claimed[0].Kind) 303 + } 304 + } 305 + 306 + func TestAdminSendVerification_SkipsVerifiedDomains(t *testing.T) { 307 + api, store := testAdminAPI(t) 308 + ctx := context.Background() 309 + now := time.Now().UTC() 310 + did := "did:plc:skipverifiedtestaabbccdd" 311 + 312 + store.InsertMember(ctx, &relaystore.Member{DID: did, Status: relaystore.StatusActive, CreatedAt: now, UpdatedAt: now}) 313 + store.InsertMemberDomain(ctx, &relaystore.MemberDomain{ 314 + Domain: "already.example.com", DID: did, 315 + APIKeyHash: []byte("h"), DKIMRSAPriv: []byte("r"), DKIMEdPriv: []byte("e"), 316 + DKIMSelector: "s", ContactEmail: "user@example.com", CreatedAt: now, 317 + }) 318 + 319 + token := "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" 320 + store.SetEmailVerifyToken(ctx, "already.example.com", token, now.Add(72*time.Hour)) 321 + store.VerifyEmailByToken(ctx, token) 322 + 323 + req := httptest.NewRequest(http.MethodPost, "/admin/member/"+did+"/send-verification", nil) 324 + req.Header.Set("Authorization", "Bearer test-admin-token") 325 + w := httptest.NewRecorder() 326 + api.ServeHTTP(w, req) 327 + 328 + if w.Code != http.StatusOK { 329 + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) 330 + } 331 + 332 + var resp map[string]any 333 + json.NewDecoder(w.Body).Decode(&resp) 334 + if sent, _ := resp["verificationsSent"].(float64); sent != 0 { 335 + t.Errorf("verificationsSent = %v, want 0 (already verified)", resp["verificationsSent"]) 336 + } 337 + } 338 + 339 + // --- Member detail includes emailVerified --- 340 + 341 + func TestMemberStatus_IncludesEmailVerified(t *testing.T) { 342 + api, store := testAdminAPI(t) 343 + ctx := context.Background() 344 + now := time.Now().UTC() 345 + did := "did:plc:evfieldtest2222222222222" 346 + 347 + store.InsertMember(ctx, &relaystore.Member{DID: did, Status: relaystore.StatusActive, CreatedAt: now, UpdatedAt: now, HourlyLimit: 100, DailyLimit: 1000}) 348 + store.InsertMemberDomain(ctx, &relaystore.MemberDomain{ 349 + Domain: "evfield.example.com", DID: did, 350 + APIKeyHash: []byte("h"), DKIMRSAPriv: []byte("r"), DKIMEdPriv: []byte("e"), 351 + DKIMSelector: "s", ContactEmail: "user@example.com", CreatedAt: now, 352 + }) 353 + 354 + req := httptest.NewRequest(http.MethodGet, "/admin/member/"+did, nil) 355 + req.Header.Set("Authorization", "Bearer test-admin-token") 356 + w := httptest.NewRecorder() 357 + api.ServeHTTP(w, req) 358 + 359 + if w.Code != http.StatusOK { 360 + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) 361 + } 362 + 363 + var resp MemberStatusResponse 364 + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { 365 + t.Fatal(err) 366 + } 367 + if len(resp.Domains) != 1 { 368 + t.Fatalf("expected 1 domain, got %d", len(resp.Domains)) 369 + } 370 + if resp.Domains[0].EmailVerified { 371 + t.Error("emailVerified should be false for fresh domain") 372 + } 373 + }
+74
internal/admin/notifications_test.go
··· 116 116 } 117 117 } 118 118 119 + // TestFireFBLComplaint_EnqueuesWithPayload verifies the FBL complaint 120 + // enqueue path stores all complaint fields in the payload. 121 + func TestFireFBLComplaint_EnqueuesWithPayload(t *testing.T) { 122 + _, store := testAdminAPI(t) 123 + api := New(store, "test-admin-token", "atmos.email") 124 + api.SetOpMailer(nil, "operator@example.com", "") 125 + 126 + api.FireFBLComplaint(context.Background(), "did:plc:fbltest1111111111111aaab", "member.example.com", "gmail.com", "abuse", "gmail") 127 + 128 + now := time.Now().UTC().Unix() 129 + claimed, err := store.ClaimPendingNotifications(context.Background(), now, 10) 130 + if err != nil { 131 + t.Fatalf("ClaimPendingNotifications: %v", err) 132 + } 133 + if len(claimed) != 1 { 134 + t.Fatalf("expected 1 enqueued, got %d", len(claimed)) 135 + } 136 + got := claimed[0] 137 + if got.Kind != relaystore.NotificationKindFBLComplaint { 138 + t.Errorf("Kind: want %q got %q", relaystore.NotificationKindFBLComplaint, got.Kind) 139 + } 140 + if got.Recipient != "operator@example.com" { 141 + t.Errorf("Recipient: want operator@example.com got %q", got.Recipient) 142 + } 143 + if v, _ := got.Payload["memberDID"].(string); v != "did:plc:fbltest1111111111111aaab" { 144 + t.Errorf("Payload[memberDID]: want did:plc:fbltest1111111111111aaab got %v", got.Payload["memberDID"]) 145 + } 146 + if v, _ := got.Payload["senderDomain"].(string); v != "member.example.com" { 147 + t.Errorf("Payload[senderDomain]: want member.example.com got %v", got.Payload["senderDomain"]) 148 + } 149 + if v, _ := got.Payload["feedbackType"].(string); v != "abuse" { 150 + t.Errorf("Payload[feedbackType]: want abuse got %v", got.Payload["feedbackType"]) 151 + } 152 + if v, _ := got.Payload["provider"].(string); v != "gmail" { 153 + t.Errorf("Payload[provider]: want gmail got %v", got.Payload["provider"]) 154 + } 155 + } 156 + 157 + // TestFireFBLComplaint_NoForwardIsNoop confirms that complaints are 158 + // silently skipped when no operator forwarding address is configured. 159 + func TestFireFBLComplaint_NoForwardIsNoop(t *testing.T) { 160 + _, store := testAdminAPI(t) 161 + api := New(store, "test-admin-token", "atmos.email") 162 + 163 + api.FireFBLComplaint(context.Background(), "did:plc:fbltest1111111111111aaab", "member.example.com", "gmail.com", "abuse", "gmail") 164 + 165 + now := time.Now().UTC().Unix() 166 + claimed, _ := store.ClaimPendingNotifications(context.Background(), now, 10) 167 + if len(claimed) != 0 { 168 + t.Errorf("expected 0 enqueued when no operator forward, got %d", len(claimed)) 169 + } 170 + } 171 + 119 172 // TestDeliverNotificationRoutesByKind exercises the kind-dispatch 120 173 // inside DeliverNotification. This is the bridge between the queue 121 174 // worker and the existing OpMailer — a regression here would silently ··· 150 203 } 151 204 if !bytes.Contains(cap.lastData, []byte("k.example.com")) { 152 205 t.Errorf("key-regenerated body missing domain: %s", cap.lastData) 206 + } 207 + 208 + // FBL complaint delivery. 209 + if err := api.DeliverNotification(context.Background(), relaystore.Notification{ 210 + Kind: relaystore.NotificationKindFBLComplaint, 211 + Recipient: "ops@example.com", 212 + Payload: map[string]any{ 213 + "memberDID": "did:plc:test123", 214 + "senderDomain": "complaint.example.com", 215 + "recipientDomain": "gmail.com", 216 + "feedbackType": "abuse", 217 + "provider": "gmail", 218 + }, 219 + }); err != nil { 220 + t.Fatalf("fbl_complaint delivery: %v", err) 221 + } 222 + if !bytes.Contains(cap.lastData, []byte("complaint.example.com")) { 223 + t.Errorf("fbl_complaint body missing domain: %s", cap.lastData) 224 + } 225 + if !bytes.Contains(cap.lastData, []byte("did:plc:test123")) { 226 + t.Errorf("fbl_complaint body missing DID: %s", cap.lastData) 153 227 } 154 228 155 229 // Unknown kind must surface an error so the row dead-letters
+57
internal/admin/opmail.go
··· 126 126 } 127 127 } 128 128 129 + // FireEmailVerification enqueues a verification email for the given 130 + // domain's contact_email. Skips silently if contactEmail is empty. 131 + // The verifyURL is the full HTTPS link the member clicks to confirm. 132 + func (a *API) FireEmailVerification(ctx context.Context, domain, contactEmail, verifyURL string) { 133 + if contactEmail == "" { 134 + log.Printf("opmail.verify.skipped: domain=%s reason=no_contact_email", domain) 135 + return 136 + } 137 + if _, err := a.store.EnqueueNotification(ctx, relaystore.NotificationKindEmailVerification, contactEmail, map[string]any{ 138 + "domain": domain, 139 + "verifyURL": verifyURL, 140 + }); err != nil { 141 + log.Printf("opmail.verify.enqueue_error: domain=%s error=%v", domain, err) 142 + } 143 + } 144 + 145 + // FireFBLComplaint enqueues an operator notification when a FBL/ARF 146 + // complaint is received for a member. Recipient is the operator's 147 + // forwarding address — complaints are always operator-facing, never 148 + // member-facing (the member doesn't need to know a recipient hit "spam"). 149 + func (a *API) FireFBLComplaint(ctx context.Context, memberDID, senderDomain, recipientDomain, feedbackType, provider string) { 150 + if a.operatorForwardTo == "" { 151 + log.Printf("opmail.fbl_complaint.skipped: did=%s reason=no_operator_forward", memberDID) 152 + return 153 + } 154 + if _, err := a.store.EnqueueNotification(ctx, relaystore.NotificationKindFBLComplaint, a.operatorForwardTo, map[string]any{ 155 + "memberDID": memberDID, 156 + "senderDomain": senderDomain, 157 + "recipientDomain": recipientDomain, 158 + "feedbackType": feedbackType, 159 + "provider": provider, 160 + }); err != nil { 161 + log.Printf("opmail.fbl_complaint.enqueue_error: did=%s error=%v", memberDID, err) 162 + } 163 + } 164 + 129 165 // DeadLetterNotification is the JSON shape returned by 130 166 // /admin/notifications/dead-letter. Serialized timestamps are RFC3339 131 167 // UTC so operator tooling has a stable parse target. ··· 216 252 return err 217 253 case relaystore.NotificationKindKeyRegenerated: 218 254 _, err := a.opMailer.SendKeyRegenerated(opmailCtx, n.Recipient, relay.KeyRegeneratedData{Domain: domain}) 255 + return err 256 + case relaystore.NotificationKindFBLComplaint: 257 + memberDID, _ := n.Payload["memberDID"].(string) 258 + senderDomain, _ := n.Payload["senderDomain"].(string) 259 + recipientDomain, _ := n.Payload["recipientDomain"].(string) 260 + feedbackType, _ := n.Payload["feedbackType"].(string) 261 + provider, _ := n.Payload["provider"].(string) 262 + _, err := a.opMailer.SendFBLComplaint(opmailCtx, n.Recipient, relay.FBLComplaintData{ 263 + MemberDID: memberDID, 264 + SenderDomain: senderDomain, 265 + RecipientDomain: recipientDomain, 266 + FeedbackType: feedbackType, 267 + Provider: provider, 268 + }) 269 + return err 270 + case relaystore.NotificationKindEmailVerification: 271 + verifyURL, _ := n.Payload["verifyURL"].(string) 272 + _, err := a.opMailer.SendEmailVerification(opmailCtx, n.Recipient, relay.EmailVerificationData{ 273 + Domain: domain, 274 + VerifyURL: verifyURL, 275 + }) 219 276 return err 220 277 default: 221 278 return fmt.Errorf("unknown notification kind: %s", n.Kind)
+63 -3
internal/admin/ui/attest.go
··· 58 58 // has no Attestation payload — the member went through OAuth via the 59 59 // /recover flow rather than /enroll. May be nil to disable recovery. 60 60 recoveryIssuer RecoveryIssuer 61 + // enrollAuthIssuer, when set, is invoked on callbacks where the session 62 + // carries the enroll-auth sentinel — the user is verifying DID ownership 63 + // before filling out the enrollment form. May be nil to disable. 64 + enrollAuthIssuer EnrollAuthIssuer 65 + funnel FunnelRecorder 61 66 } 62 67 63 68 // NewAttestHandler constructs the handler. pub and store must both be non-nil. ··· 70 75 // can share indigo's single configured redirect URI. 71 76 func (h *AttestHandler) SetRecoveryIssuer(issuer RecoveryIssuer) { 72 77 h.recoveryIssuer = issuer 78 + } 79 + 80 + // SetEnrollAuthIssuer wires the enrollment identity-verification flow 81 + // into the shared OAuth callback. The enroll-auth sentinel in the 82 + // attestation payload signals this dispatch path. 83 + func (h *AttestHandler) SetEnrollAuthIssuer(issuer EnrollAuthIssuer) { 84 + h.enrollAuthIssuer = issuer 85 + } 86 + 87 + // SetFunnelRecorder wires enrollment funnel metrics. 88 + func (h *AttestHandler) SetFunnelRecorder(fr FunnelRecorder) { 89 + h.funnel = fr 73 90 } 74 91 75 92 // RegisterRoutes attaches handlers to the given mux. ··· 89 106 h.renderError(w, r, "invalid form submission") 90 107 return 91 108 } 109 + if h.funnel != nil { 110 + h.funnel.RecordEnrollStep("attest_start") 111 + } 92 112 did := strings.TrimSpace(r.FormValue("did")) 93 113 domain := strings.TrimSpace(r.FormValue("domain")) 94 114 dkimSelector := strings.TrimSpace(r.FormValue("dkim_selector")) ··· 144 164 http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 145 165 return 146 166 } 167 + if h.funnel != nil { 168 + h.funnel.RecordEnrollStep("attest_callback") 169 + } 147 170 ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) 148 171 defer cancel() 149 172 ··· 157 180 sess, err := h.pub.CompleteCallback(ctx, params) 158 181 if err != nil { 159 182 log.Printf("attest.callback: callback_error=%v", err) 160 - // Don't surface error-description text to the user raw — it may 161 - // contain strings from the AS that we don't want to reflect. A 162 - // generic message + log detail is enough. 183 + if h.funnel != nil { 184 + h.funnel.RecordOAuthCallback("error") 185 + } 163 186 msg := "OAuth callback failed — please start over." 164 187 if err == atpoauth.ErrPendingNotFound { 165 188 msg = "This attestation flow has expired or was already used. Please start over." ··· 180 203 h.renderError(w, r, "recovery flow is not enabled on this deployment") 181 204 return 182 205 } 206 + if h.funnel != nil { 207 + h.funnel.RecordOAuthCallback("recovery") 208 + } 183 209 target := h.recoveryIssuer.IssueRecoveryTicket(sess.AccountDID(), sess.Domain()) 184 210 log.Printf("attest.callback: did=%s domain=%s handoff=recovery target=%s", 185 211 sess.AccountDID(), sess.Domain(), target) ··· 187 213 return 188 214 } 189 215 216 + // Enrollment auth dispatch. The sentinel {"flow":"enroll-auth"} 217 + // means the user is verifying DID ownership before filling out the 218 + // enrollment form. No record is published — just mint a ticket. 219 + if isEnrollAuthSentinel(sess.Attestation()) { 220 + if h.enrollAuthIssuer == nil { 221 + log.Printf("attest.callback: did=%s enroll_auth=true but no issuer wired", sess.AccountDID()) 222 + h.renderError(w, r, "enrollment identity verification is not enabled on this deployment") 223 + return 224 + } 225 + if h.funnel != nil { 226 + h.funnel.RecordOAuthCallback("enroll_auth") 227 + } 228 + target := h.enrollAuthIssuer.IssueEnrollAuthTicket(sess.AccountDID(), sess.Domain(), r.UserAgent()) 229 + if target == "" { 230 + h.renderError(w, r, "enrollment service is temporarily overloaded — try again in a moment") 231 + return 232 + } 233 + log.Printf("attest.callback: did=%s handle=%s handoff=enroll-auth", 234 + sess.AccountDID(), sess.Domain()) 235 + http.Redirect(w, r, target, http.StatusFound) 236 + return 237 + } 238 + 190 239 // Decode the pending JSON back into a map so the APIClient Post can 191 240 // re-encode it. Using a map keeps the record's field order stable 192 241 // across the serialize→deserialize→publish path. ··· 212 261 sess.AccountDID(), sess.Domain(), err) 213 262 } 214 263 264 + if h.funnel != nil { 265 + h.funnel.RecordOAuthCallback("attestation") 266 + } 215 267 log.Printf("attest.callback: did=%s domain=%s rkey=%s published=true", 216 268 sess.AccountDID(), sess.Domain(), rkey) 217 269 w.Header().Set("Content-Type", "text/html; charset=utf-8") ··· 254 306 return a.s.PutRecord(ctx, collection, rkey, record) 255 307 } 256 308 func (a *sessionAdapter) Close(ctx context.Context) { a.s.Close(ctx) } 309 + 310 + func isEnrollAuthSentinel(b []byte) bool { 311 + var m map[string]string 312 + if err := json.Unmarshal(b, &m); err != nil { 313 + return false 314 + } 315 + return m["flow"] == "enroll-auth" 316 + }
+353 -44
internal/admin/ui/enroll.go
··· 3 3 import ( 4 4 "bytes" 5 5 "context" 6 + "crypto/rand" 7 + "crypto/sha256" 8 + "encoding/hex" 6 9 "encoding/json" 7 10 "fmt" 8 11 "io" ··· 10 13 "net/http" 11 14 "net/http/httptest" 12 15 "strings" 16 + "sync" 13 17 "time" 14 18 15 19 "atmosphere-mail/internal/admin/ui/templates" 20 + "atmosphere-mail/internal/atpoauth" 16 21 "atmosphere-mail/internal/relay" 17 22 ) 18 23 ··· 41 46 QueryLabels(ctx context.Context, did string) ([]string, error) 42 47 } 43 48 49 + // DomainLister returns the sending domains registered under a DID. 50 + // Used by the enrollment landing to show existing enrollment state so 51 + // returning members see their domains and remaining quota. Nil is 52 + // allowed; the landing page silently degrades to the new-user form. 53 + type DomainLister interface { 54 + ListMemberDomains(ctx context.Context, did string) ([]string, error) 55 + } 56 + 57 + const ( 58 + enrollAuthTicketTTL = 15 * time.Minute 59 + enrollAuthCookieName = "atmos_enroll_auth" 60 + enrollAuthCookieMax = int(enrollAuthTicketTTL / time.Second) 61 + ) 62 + 63 + type enrollAuthTicket struct { 64 + did string 65 + handle string 66 + expiry time.Time 67 + uaHash [32]byte 68 + } 69 + 70 + // EnrollAuthIssuer mints an enrollment-auth ticket after the OAuth 71 + // callback sees the enroll-auth sentinel. Implemented by EnrollHandler. 72 + type EnrollAuthIssuer interface { 73 + IssueEnrollAuthTicket(did, handle, userAgent string) string 74 + } 75 + 76 + // AccountTicketIssuer mints an account (recovery) session ticket so a 77 + // verified enrollment user can reach /account/manage without re-authenticating. 78 + type AccountTicketIssuer interface { 79 + IssueRecoveryTicketWithUA(did, domain, ua string) string 80 + } 81 + 82 + // FunnelRecorder records enrollment funnel step visits and OAuth callback outcomes. 83 + type FunnelRecorder interface { 84 + RecordEnrollStep(step string) 85 + RecordOAuthCallback(callbackType string) 86 + } 87 + 44 88 type EnrollHandler struct { 45 - // adminAPI is the admin API whose /admin/enroll-start + /admin/enroll 46 - // handlers we invoke in-process. We construct sub-requests with 47 - // httptest.NewRecorder so no TCP round-trip happens and we don't 48 - // require a loopback HTTP client. 49 89 adminAPI http.Handler 50 - // resolver optionally powers /enroll/resolve (handle→DID lookup). If 51 - // nil, the wizard still works — users just have to paste their DID 52 - // directly. 53 90 resolver HandleResolver 54 - // labels optionally powers /enroll/label-status (labeler poll). If nil, 55 - // the endpoint returns 503 and the success page hides the polling UI. 56 - labels LabelStatusQuerier 57 - mux *http.ServeMux 91 + labels LabelStatusQuerier 92 + domains DomainLister 93 + pub Publisher 94 + account AccountTicketIssuer 95 + funnel FunnelRecorder 96 + mux *http.ServeMux 97 + 98 + mu sync.Mutex 99 + tickets map[string]enrollAuthTicket 58 100 } 59 101 60 102 // NewEnrollHandler constructs a public enrollment UI that delegates the 61 103 // start/verify business logic to adminAPI (typically *admin.API). Pass 62 104 // resolver to enable handle→DID resolution at /enroll/resolve. 63 105 func NewEnrollHandler(adminAPI http.Handler, resolver HandleResolver) *EnrollHandler { 64 - h := &EnrollHandler{adminAPI: adminAPI, resolver: resolver, mux: http.NewServeMux()} 106 + h := &EnrollHandler{adminAPI: adminAPI, resolver: resolver, mux: http.NewServeMux(), tickets: make(map[string]enrollAuthTicket)} 65 107 h.mux.HandleFunc("/", h.handleMarketing) 66 108 h.mux.HandleFunc("/enroll", h.handleLanding) 109 + h.mux.HandleFunc("/enroll/auth", h.handleAuth) 110 + h.mux.HandleFunc("/enroll/reset", h.handleReset) 111 + h.mux.HandleFunc("/enroll/manage", h.handleManageBridge) 67 112 h.mux.HandleFunc("/enroll/start", h.handleStart) 68 113 h.mux.HandleFunc("/enroll/verify", h.handleVerify) 69 114 h.mux.HandleFunc("/enroll/resolve", h.handleResolve) ··· 85 130 h.labels = q 86 131 } 87 132 133 + // SetPublisher wires the OAuth client used for identity verification 134 + // during enrollment. When set, /enroll requires OAuth proof of DID 135 + // ownership before the domain enrollment form is shown. 136 + func (h *EnrollHandler) SetPublisher(pub Publisher) { 137 + h.pub = pub 138 + } 139 + 140 + // SetAccountTicketIssuer wires the recovery handler so that verified 141 + // enrollment users can navigate to /account/manage without re-authenticating. 142 + func (h *EnrollHandler) SetAccountTicketIssuer(ati AccountTicketIssuer) { 143 + h.account = ati 144 + } 145 + 146 + // SetDomainLister wires the domain lookup used by the enrollment 147 + // landing page to show existing enrollment state. When set, verified 148 + // users see their current domains and remaining quota instead of a 149 + // blank form. Nil = feature-off (new-user form always shown). 150 + func (h *EnrollHandler) SetDomainLister(dl DomainLister) { 151 + h.domains = dl 152 + } 153 + 154 + // SetFunnelRecorder wires enrollment funnel metrics. 155 + func (h *EnrollHandler) SetFunnelRecorder(fr FunnelRecorder) { 156 + h.funnel = fr 157 + } 158 + 159 + func (h *EnrollHandler) recordStep(step string) { 160 + if h.funnel != nil { 161 + h.funnel.RecordEnrollStep(step) 162 + } 163 + } 164 + 88 165 // handleLabelStatus answers GET /enroll/label-status?did=... with the 89 166 // current label set for a DID as reported by the labeler. Called by a 90 167 // small client-side poller on the enroll success page so members can ··· 92 169 // server-side so the labeler can remain tailnet-only. 93 170 // 94 171 // Response shape: 95 - // {"did":"did:plc:...","labels":["verified-mail-operator"], 96 - // "hasVerifiedMailOperator":true,"hasRelayMember":false} 172 + // 173 + // {"did":"did:plc:...","labels":["verified-mail-operator"], 174 + // "hasVerifiedMailOperator":true,"hasRelayMember":false} 97 175 // 98 176 // 503 when no labeler is configured. 400 when did is missing or too 99 177 // long (rudimentary sanity check; we don't strictly validate DID shape ··· 246 324 if r.Method == http.MethodHead { 247 325 return 248 326 } 327 + h.recordStep("marketing") 249 328 signedOut := r.URL.Query().Get("signed_out") == "1" 250 329 _ = templates.MarketingLanding(signedOut).Render(r.Context(), w) 251 330 } 252 331 253 332 func (h *EnrollHandler) handleLanding(w http.ResponseWriter, r *http.Request) { 254 - // Only render the landing page for exact path matches — avoid serving 255 - // it under arbitrary prefixes like /enroll/whatever that we don't 256 - // control. 257 333 if r.URL.Path != "/enroll" && r.URL.Path != "/enroll/" { 258 334 http.NotFound(w, r) 259 335 return ··· 266 342 if r.Method == http.MethodHead { 267 343 return 268 344 } 269 - _ = templates.EnrollLanding().Render(r.Context(), w) 345 + 346 + // Ticket in URL → set cookie, redirect to clean URL (same pattern 347 + // as recovery — URLs leak via Referer, cookies don't). 348 + if qt := strings.TrimSpace(r.URL.Query().Get("ticket")); qt != "" { 349 + setEnrollAuthCookie(w, qt) 350 + http.Redirect(w, r, "/enroll", http.StatusFound) 351 + return 352 + } 353 + 354 + // Check for auth cookie → Phase 2 (identity verified, show domain form). 355 + var authDID, authHandle string 356 + if id, ok := enrollAuthTicketFromCookie(r); ok { 357 + if ticket, ok := h.lookupEnrollAuthTicket(id, r.UserAgent()); ok { 358 + authDID = ticket.did 359 + authHandle = ticket.handle 360 + } 361 + } 362 + 363 + // When the user is verified and we have a domain lister, fetch their 364 + // existing domains so the template can show enrollment state. 365 + var existingDomains []string 366 + if authDID != "" && h.domains != nil { 367 + if ds, err := h.domains.ListMemberDomains(r.Context(), authDID); err == nil { 368 + existingDomains = ds 369 + } 370 + } 371 + 372 + h.recordStep("landing") 373 + _ = templates.EnrollLanding(authDID, authHandle, h.pub != nil, existingDomains).Render(r.Context(), w) 270 374 } 271 375 272 376 // handleStart is step 1 of the wizard: the user POSTs (DID, domain) and we ··· 281 385 h.renderError(w, r, "invalid form submission") 282 386 return 283 387 } 284 - did := strings.TrimSpace(r.FormValue("did")) 285 - identity := strings.TrimSpace(r.FormValue("identity")) 388 + h.recordStep("enroll_start") 286 389 domain := strings.TrimSpace(r.FormValue("domain")) 287 390 contactEmail := strings.TrimSpace(r.FormValue("contact_email")) 288 391 289 - // Server-side fallback: if the landing JS didn't populate the hidden 290 - // `did` field (JS off, XHR blocked, resolver raced the submit), try 291 - // to resolve `identity` ourselves. Accepts either a full DID or a 292 - // handle. Keeps enrollments from failing ugly for users who don't 293 - // run Javascript or whose browser blocked the /enroll/resolve XHR. 294 - if did == "" && identity != "" { 295 - identity = strings.TrimPrefix(identity, "@") 296 - if strings.HasPrefix(identity, "did:") { 297 - did = identity 298 - } else if h.resolver != nil && relay.LooksLikeHandle(identity) { 299 - ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) 300 - resolved, err := h.resolver.ResolveHandle(ctx, identity) 301 - cancel() 302 - if err != nil { 303 - log.Printf("enroll.start.server_resolve_failed: identity=%s error=%v", identity, err) 304 - h.renderError(w, r, fmt.Sprintf("handle %q did not resolve — check your DID document or paste your DID directly", identity)) 305 - return 392 + // When OAuth is configured, DID must come from the auth ticket — the 393 + // user proved ownership via their PDS. This is the primary path. 394 + var did string 395 + if h.pub != nil { 396 + if id, ok := enrollAuthTicketFromCookie(r); ok { 397 + if ticket, ok := h.lookupEnrollAuthTicket(id, r.UserAgent()); ok { 398 + did = ticket.did 399 + } 400 + } 401 + if did == "" { 402 + h.renderError(w, r, "Identity verification required — please verify your handle first") 403 + return 404 + } 405 + } else { 406 + // Legacy fallback: no OAuth configured, read DID from form. 407 + did = strings.TrimSpace(r.FormValue("did")) 408 + identity := strings.TrimSpace(r.FormValue("identity")) 409 + if did == "" && identity != "" { 410 + identity = strings.TrimPrefix(identity, "@") 411 + if strings.HasPrefix(identity, "did:") { 412 + did = identity 413 + } else if h.resolver != nil && relay.LooksLikeHandle(identity) { 414 + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) 415 + resolved, err := h.resolver.ResolveHandle(ctx, identity) 416 + cancel() 417 + if err != nil { 418 + log.Printf("enroll.start.server_resolve_failed: identity=%s error=%v", identity, err) 419 + h.renderError(w, r, fmt.Sprintf("handle %q did not resolve — check your DID document or paste your DID directly", identity)) 420 + return 421 + } 422 + did = resolved 306 423 } 307 - did = resolved 308 424 } 309 425 } 310 426 311 427 if did == "" || domain == "" { 312 - h.renderError(w, r, "A handle or DID and a sending domain are both required") 428 + h.renderError(w, r, "A verified identity and a sending domain are both required") 313 429 return 314 430 } 315 431 432 + termsAccepted := r.FormValue("terms_accepted") == "on" 433 + 316 434 // Forward to /admin/enroll-start. No admin bearer — the endpoint is 317 435 // public because token issuance on its own conveys no privilege; the 318 436 // token is only useful once the corresponding TXT record exists. 319 437 // contact_email is optional for backward-compat with existing form 320 438 // submissions; when present it propagates into pending_enrollments so 321 439 // the operator-ping and welcome emails have a recipient on file. 322 - body, _ := json.Marshal(map[string]string{ 323 - "did": did, 324 - "domain": domain, 325 - "contactEmail": contactEmail, 440 + body, _ := json.Marshal(map[string]any{ 441 + "did": did, 442 + "domain": domain, 443 + "contactEmail": contactEmail, 444 + "termsAccepted": termsAccepted, 326 445 }) 327 446 resp := h.proxyAdminInner(http.MethodPost, "/admin/enroll-start", bytes.NewReader(body)) 328 447 if resp.Code != http.StatusOK { 329 - h.renderError(w, r, fmt.Sprintf("enrollment start failed (%d): %s", resp.Code, strings.TrimSpace(resp.Body.String()))) 448 + msg := strings.TrimSpace(resp.Body.String()) 449 + if msg == "" { 450 + msg = fmt.Sprintf("the relay returned an unexpected status (%d)", resp.Code) 451 + } 452 + h.renderError(w, r, msg) 330 453 return 331 454 } 332 455 var sr struct { ··· 363 486 h.renderError(w, r, "invalid form submission") 364 487 return 365 488 } 489 + h.recordStep("enroll_verify") 366 490 did := strings.TrimSpace(r.FormValue("did")) 367 491 domain := strings.TrimSpace(r.FormValue("domain")) 368 492 token := strings.TrimSpace(r.FormValue("token")) ··· 430 554 }, 431 555 } 432 556 557 + h.recordStep("enroll_success") 433 558 log.Printf("enroll.public_success: did=%s domain=%s", er.DID, domain) 434 559 w.Header().Set("Content-Type", "text/html; charset=utf-8") 435 560 _ = templates.EnrollSuccess(result).Render(r.Context(), w) 561 + } 562 + 563 + // handleAuth kicks off the OAuth flow to verify DID ownership before 564 + // enrollment. The sentinel attestation {"flow":"enroll-auth"} tells the 565 + // shared callback to dispatch here instead of publishing a record. 566 + func (h *EnrollHandler) handleAuth(w http.ResponseWriter, r *http.Request) { 567 + if r.Method != http.MethodPost { 568 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 569 + return 570 + } 571 + if h.pub == nil { 572 + h.renderError(w, r, "identity verification is not configured on this relay") 573 + return 574 + } 575 + if err := r.ParseForm(); err != nil { 576 + h.renderError(w, r, "invalid form submission") 577 + return 578 + } 579 + 580 + h.recordStep("auth_start") 581 + did := strings.TrimSpace(r.FormValue("did")) 582 + identity := strings.TrimSpace(r.FormValue("identity")) 583 + handle := strings.TrimPrefix(strings.TrimSpace(identity), "@") 584 + 585 + if did == "" && identity != "" { 586 + if strings.HasPrefix(strings.TrimSpace(identity), "did:") { 587 + did = strings.TrimSpace(identity) 588 + handle = did 589 + } else if h.resolver != nil && relay.LooksLikeHandle(handle) { 590 + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) 591 + resolved, err := h.resolver.ResolveHandle(ctx, handle) 592 + cancel() 593 + if err != nil { 594 + log.Printf("enroll.auth: handle=%s resolve_error=%v", handle, err) 595 + h.renderError(w, r, fmt.Sprintf("handle %q did not resolve — check the spelling or paste a DID directly", handle)) 596 + return 597 + } 598 + did = resolved 599 + } 600 + } 601 + if did == "" { 602 + h.renderError(w, r, "A handle or DID is required") 603 + return 604 + } 605 + 606 + sentinel := []byte(`{"flow":"enroll-auth"}`) 607 + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) 608 + defer cancel() 609 + authorizeURL, state, err := h.pub.StartAuthFlow(ctx, did, atpoauth.StartOptions{ 610 + ExpectedDID: did, 611 + Domain: handle, 612 + Attestation: sentinel, 613 + }) 614 + if err != nil { 615 + log.Printf("enroll.auth: did=%s handle=%s error=%v", did, handle, err) 616 + h.renderError(w, r, fmt.Sprintf("couldn't start identity verification: %v", err)) 617 + return 618 + } 619 + 620 + log.Printf("enroll.auth: did=%s handle=%s state=%s", did, handle, state) 621 + http.Redirect(w, r, authorizeURL, http.StatusFound) 622 + } 623 + 624 + // handleManageBridge lets a verified enrollment user reach /account/manage 625 + // without re-authenticating. Validates the enroll auth ticket, mints a 626 + // recovery ticket via the account issuer, and redirects. 627 + func (h *EnrollHandler) handleManageBridge(w http.ResponseWriter, r *http.Request) { 628 + if h.account == nil { 629 + http.Redirect(w, r, "/account", http.StatusFound) 630 + return 631 + } 632 + id, ok := enrollAuthTicketFromCookie(r) 633 + if !ok { 634 + http.Redirect(w, r, "/account", http.StatusFound) 635 + return 636 + } 637 + ticket, ok := h.lookupEnrollAuthTicket(id, r.UserAgent()) 638 + if !ok { 639 + http.Redirect(w, r, "/account", http.StatusFound) 640 + return 641 + } 642 + target := h.account.IssueRecoveryTicketWithUA(ticket.did, "", r.UserAgent()) 643 + if target == "" { 644 + http.Redirect(w, r, "/account", http.StatusFound) 645 + return 646 + } 647 + http.Redirect(w, r, target, http.StatusFound) 648 + } 649 + 650 + // handleReset clears the enrollment auth cookie so the user can start 651 + // over with a different identity. 652 + func (h *EnrollHandler) handleReset(w http.ResponseWriter, r *http.Request) { 653 + clearEnrollAuthCookie(w) 654 + http.Redirect(w, r, "/enroll", http.StatusFound) 655 + } 656 + 657 + // IssueEnrollAuthTicket mints a ticket proving DID ownership and returns 658 + // a redirect URL. Called by the shared OAuth callback when it sees the 659 + // enroll-auth sentinel. 660 + func (h *EnrollHandler) IssueEnrollAuthTicket(did, handle, userAgent string) string { 661 + var raw [16]byte 662 + _, _ = rand.Read(raw[:]) 663 + id := hex.EncodeToString(raw[:]) 664 + 665 + t := enrollAuthTicket{ 666 + did: did, 667 + handle: handle, 668 + expiry: time.Now().Add(enrollAuthTicketTTL), 669 + } 670 + if userAgent != "" { 671 + t.uaHash = sha256.Sum256([]byte(userAgent)) 672 + } 673 + 674 + h.mu.Lock() 675 + now := time.Now() 676 + for k, v := range h.tickets { 677 + if now.After(v.expiry) { 678 + delete(h.tickets, k) 679 + } 680 + } 681 + h.tickets[id] = t 682 + h.mu.Unlock() 683 + 684 + return "/enroll?ticket=" + id 685 + } 686 + 687 + func (h *EnrollHandler) lookupEnrollAuthTicket(id, userAgent string) (enrollAuthTicket, bool) { 688 + if id == "" { 689 + return enrollAuthTicket{}, false 690 + } 691 + h.mu.Lock() 692 + defer h.mu.Unlock() 693 + t, ok := h.tickets[id] 694 + if !ok { 695 + return enrollAuthTicket{}, false 696 + } 697 + if time.Now().After(t.expiry) { 698 + delete(h.tickets, id) 699 + return enrollAuthTicket{}, false 700 + } 701 + var zero [32]byte 702 + if t.uaHash != zero { 703 + got := sha256.Sum256([]byte(userAgent)) 704 + if got != t.uaHash { 705 + return enrollAuthTicket{}, false 706 + } 707 + } 708 + return t, true 709 + } 710 + 711 + func setEnrollAuthCookie(w http.ResponseWriter, ticket string) { 712 + http.SetCookie(w, &http.Cookie{ 713 + Name: enrollAuthCookieName, 714 + Value: ticket, 715 + Path: "/enroll", 716 + HttpOnly: true, 717 + Secure: true, 718 + SameSite: http.SameSiteLaxMode, 719 + MaxAge: enrollAuthCookieMax, 720 + }) 721 + } 722 + 723 + func clearEnrollAuthCookie(w http.ResponseWriter) { 724 + http.SetCookie(w, &http.Cookie{ 725 + Name: enrollAuthCookieName, 726 + Value: "", 727 + Path: "/enroll", 728 + HttpOnly: true, 729 + Secure: true, 730 + SameSite: http.SameSiteLaxMode, 731 + MaxAge: -1, 732 + }) 733 + } 734 + 735 + func enrollAuthTicketFromCookie(r *http.Request) (string, bool) { 736 + c, err := r.Cookie(enrollAuthCookieName) 737 + if err != nil || c == nil { 738 + return "", false 739 + } 740 + id := strings.TrimSpace(c.Value) 741 + if id == "" { 742 + return "", false 743 + } 744 + return id, true 436 745 } 437 746 438 747 func (h *EnrollHandler) renderError(w http.ResponseWriter, r *http.Request, message string) {
+203 -7
internal/admin/ui/enroll_test.go
··· 83 83 } 84 84 body := w.Body.String() 85 85 if !strings.Contains(body, "/enroll/start") { 86 - t.Error("enroll landing should POST to /enroll/start") 86 + t.Error("enroll landing without OAuth should POST to /enroll/start") 87 + } 88 + if !strings.Contains(body, `name="domain"`) { 89 + t.Error("legacy enroll landing should include sending domain field") 90 + } 91 + } 92 + 93 + func TestEnrollLanding_OAuthEnabledStartsWithIdentityVerification(t *testing.T) { 94 + h := NewEnrollHandler(&fakeAdminAPI{}, nil) 95 + h.SetPublisher(&fakePublisher{}) 96 + req := httptest.NewRequest(http.MethodGet, "/enroll", nil) 97 + w := httptest.NewRecorder() 98 + h.ServeHTTP(w, req) 99 + if w.Code != http.StatusOK { 100 + t.Fatalf("status = %d, want 200", w.Code) 101 + } 102 + body := w.Body.String() 103 + if !strings.Contains(body, "/enroll/auth") { 104 + t.Error("enroll landing with OAuth should POST to /enroll/auth") 105 + } 106 + if strings.Contains(body, `name="domain"`) { 107 + t.Error("OAuth phase 1 should not expose domain enrollment fields before identity verification") 108 + } 109 + if !strings.Contains(body, ">Your handle<") { 110 + t.Error("identity label should say 'Your handle'") 111 + } 112 + if strings.Contains(body, "Bluesky") { 113 + t.Error("identity copy should stay protocol-neutral") 114 + } 115 + if !strings.Contains(body, `href="https://atproto.com/specs/handle"`) { 116 + t.Error("identity helper text should link to the handle spec") 87 117 } 88 118 } 89 119 ··· 138 168 } 139 169 } 140 170 171 + func TestEnrollStart_OAuthRequiresVerifiedTicket(t *testing.T) { 172 + fake := &fakeAdminAPI{} 173 + h := NewEnrollHandler(fake, nil) 174 + h.SetPublisher(&fakePublisher{}) 175 + 176 + req := httptest.NewRequest(http.MethodPost, "/enroll/start", strings.NewReader("did=did:plc:client&domain=example.com")) 177 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 178 + w := httptest.NewRecorder() 179 + h.ServeHTTP(w, req) 180 + 181 + if w.Code != http.StatusBadRequest { 182 + t.Fatalf("status = %d, want 400", w.Code) 183 + } 184 + if fake.gotEnrollStart { 185 + t.Error("admin API must not be called without a verified OAuth ticket") 186 + } 187 + if !strings.Contains(w.Body.String(), "Identity verification required") { 188 + t.Errorf("error should explain identity verification requirement; body=%s", w.Body.String()) 189 + } 190 + } 191 + 192 + func TestEnrollStart_OAuthUsesTicketDID(t *testing.T) { 193 + fake := &fakeAdminAPI{ 194 + enrollStartStatus: http.StatusOK, 195 + enrollStartBody: `{"token":"tok","dnsName":"_atmos-enroll.example.com","dnsValue":"atmos-verify=tok","expiresAt":"2026-04-17T12:00:00Z"}`, 196 + } 197 + h := NewEnrollHandler(fake, nil) 198 + h.SetPublisher(&fakePublisher{}) 199 + target := h.IssueEnrollAuthTicket("did:plc:verified", "verified.example", "ua") 200 + ticketID := strings.TrimPrefix(target, "/enroll?ticket=") 201 + 202 + req := httptest.NewRequest(http.MethodPost, "/enroll/start", 203 + strings.NewReader("did=did:plc:client&domain=example.com&contact_email=user%40example.com")) 204 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 205 + req.Header.Set("User-Agent", "ua") 206 + req.AddCookie(&http.Cookie{Name: enrollAuthCookieName, Value: ticketID}) 207 + w := httptest.NewRecorder() 208 + h.ServeHTTP(w, req) 209 + 210 + if w.Code != http.StatusOK { 211 + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) 212 + } 213 + if !fake.gotEnrollStart { 214 + t.Fatal("admin API should have been called with verified DID") 215 + } 216 + if !strings.Contains(fake.lastBody, "did:plc:verified") { 217 + t.Errorf("verified DID not forwarded to admin API; got %s", fake.lastBody) 218 + } 219 + if strings.Contains(fake.lastBody, "did:plc:client") { 220 + t.Errorf("client-supplied DID should be ignored when OAuth is enabled; got %s", fake.lastBody) 221 + } 222 + } 223 + 141 224 func TestEnrollStart_AdminAPIFailureRendersError(t *testing.T) { 142 225 fake := &fakeAdminAPI{ 143 226 enrollStartStatus: http.StatusConflict, 144 - enrollStartBody: "domain already registered", 227 + enrollStartBody: "This domain is registered to another account.", 145 228 } 146 229 h := NewEnrollHandler(fake, nil) 147 230 ··· 155 238 t.Errorf("status = %d, want 400 (error template)", w.Code) 156 239 } 157 240 body := w.Body.String() 158 - if !strings.Contains(body, "failed") { 159 - t.Errorf("error template should mention failure; body: %s", body[:min(200, len(body))]) 241 + // The API message should appear directly in the rendered page — 242 + // the UI passes it through without wrapping in a status-code prefix. 243 + if !strings.Contains(body, "another account") { 244 + t.Errorf("error template should contain the API error message; body: %s", body[:min(200, len(body))]) 245 + } 246 + // Must render the error page, not the enrollment form. 247 + if !strings.Contains(body, "enroll") { 248 + t.Errorf("error template should contain a link back to /enroll; body: %s", body[:min(200, len(body))]) 160 249 } 161 250 } 162 251 ··· 271 360 h := NewEnrollHandler(&fakeAdminAPI{}, nil) 272 361 273 362 cases := []string{ 274 - "domain=example.com", // missing token 275 - "token=&domain=example.com", // empty token 363 + "domain=example.com", // missing token 364 + "token=&domain=example.com", // empty token 276 365 } 277 366 for _, form := range cases { 278 367 req := httptest.NewRequest(http.MethodPost, "/enroll/verify", strings.NewReader(form)) ··· 358 447 359 448 func TestEnrollResolve_InvalidHandleSyntax(t *testing.T) { 360 449 h := NewEnrollHandler(&fakeAdminAPI{}, &fakeResolver{}) 361 - // "no-dots" doesn't match the atproto handle regex (needs a dot). 450 + // "no-dots" doesn't match the handle regex (needs a dot). 362 451 req := httptest.NewRequest(http.MethodGet, "/enroll/resolve?handle=nodots", nil) 363 452 w := httptest.NewRecorder() 364 453 h.ServeHTTP(w, req) ··· 740 829 } 741 830 if resp.DID != "did:plc:abcd1234" { 742 831 t.Errorf("did = %q, want did:plc:abcd1234", resp.DID) 832 + } 833 + } 834 + 835 + // --- Enrollment-aware landing (domain lister) --- 836 + 837 + type fakeDomainLister struct { 838 + domains map[string][]string // DID → domain names 839 + } 840 + 841 + func (f *fakeDomainLister) ListMemberDomains(_ context.Context, did string) ([]string, error) { 842 + return f.domains[did], nil 843 + } 844 + 845 + func TestEnrollLanding_VerifiedNewUser_ShowsDomainForm(t *testing.T) { 846 + // A verified user with no existing domains should see the standard 847 + // domain enrollment form with "Identity verified" messaging. 848 + h := NewEnrollHandler(&fakeAdminAPI{}, nil) 849 + h.SetPublisher(&fakePublisher{}) 850 + h.SetDomainLister(&fakeDomainLister{domains: map[string][]string{}}) 851 + 852 + did := "did:plc:newusertest1234567890ab" 853 + ticket := h.IssueEnrollAuthTicket(did, "newuser.bsky.social", "test-ua") 854 + req := httptest.NewRequest(http.MethodGet, "/enroll", nil) 855 + req.Header.Set("User-Agent", "test-ua") 856 + req.AddCookie(&http.Cookie{Name: enrollAuthCookieName, Value: ticket[len("/enroll?ticket="):]}) 857 + w := httptest.NewRecorder() 858 + h.ServeHTTP(w, req) 859 + 860 + body := w.Body.String() 861 + if !strings.Contains(body, "Identity verified") { 862 + t.Error("new verified user should see 'Identity verified' messaging") 863 + } 864 + if !strings.Contains(body, `name="domain"`) { 865 + t.Error("new verified user should see domain enrollment form") 866 + } 867 + if !strings.Contains(body, "Start enrollment") { 868 + t.Error("new user should see 'Start enrollment' button text") 869 + } 870 + } 871 + 872 + func TestEnrollLanding_VerifiedOneDomain_ShowsAddForm(t *testing.T) { 873 + // A verified user with 1 domain should see their existing domain, 874 + // a message about adding one more, and the domain form. 875 + h := NewEnrollHandler(&fakeAdminAPI{}, nil) 876 + h.SetPublisher(&fakePublisher{}) 877 + did := "did:plc:onedomain1234567890abcd" 878 + h.SetDomainLister(&fakeDomainLister{domains: map[string][]string{ 879 + did: {"existing.example.com"}, 880 + }}) 881 + 882 + ticket := h.IssueEnrollAuthTicket(did, "user.bsky.social", "test-ua") 883 + req := httptest.NewRequest(http.MethodGet, "/enroll", nil) 884 + req.Header.Set("User-Agent", "test-ua") 885 + req.AddCookie(&http.Cookie{Name: enrollAuthCookieName, Value: ticket[len("/enroll?ticket="):]}) 886 + w := httptest.NewRecorder() 887 + h.ServeHTTP(w, req) 888 + 889 + body := w.Body.String() 890 + if !strings.Contains(body, "existing.example.com") { 891 + t.Error("should show existing domain name") 892 + } 893 + if !strings.Contains(body, "one more") { 894 + t.Error("should mention they can add one more domain") 895 + } 896 + if !strings.Contains(body, `name="domain"`) { 897 + t.Error("should still show domain enrollment form") 898 + } 899 + if !strings.Contains(body, "Add domain") { 900 + t.Error("button text should say 'Add domain' not 'Start enrollment'") 901 + } 902 + if !strings.Contains(body, `href="/enroll/manage"`) { 903 + t.Error("should link to /enroll/manage for management") 904 + } 905 + } 906 + 907 + func TestEnrollLanding_VerifiedAtLimit_ShowsNoForm(t *testing.T) { 908 + // A verified user at the 2-domain limit should see their domains 909 + // and a message about the limit — no domain form. 910 + h := NewEnrollHandler(&fakeAdminAPI{}, nil) 911 + h.SetPublisher(&fakePublisher{}) 912 + did := "did:plc:twodomain1234567890abcd" 913 + h.SetDomainLister(&fakeDomainLister{domains: map[string][]string{ 914 + did: {"first.example.com", "second.example.com"}, 915 + }}) 916 + 917 + ticket := h.IssueEnrollAuthTicket(did, "user.bsky.social", "test-ua") 918 + req := httptest.NewRequest(http.MethodGet, "/enroll", nil) 919 + req.Header.Set("User-Agent", "test-ua") 920 + req.AddCookie(&http.Cookie{Name: enrollAuthCookieName, Value: ticket[len("/enroll?ticket="):]}) 921 + w := httptest.NewRecorder() 922 + h.ServeHTTP(w, req) 923 + 924 + body := w.Body.String() 925 + if !strings.Contains(body, "first.example.com") { 926 + t.Error("should show first domain name") 927 + } 928 + if !strings.Contains(body, "second.example.com") { 929 + t.Error("should show second domain name") 930 + } 931 + if !strings.Contains(body, "maximum") { 932 + t.Error("should mention the maximum domain limit") 933 + } 934 + if strings.Contains(body, `name="domain"`) { 935 + t.Error("should NOT show domain enrollment form when at limit") 936 + } 937 + if !strings.Contains(body, `href="/enroll/manage"`) { 938 + t.Error("should link to /enroll/manage for management") 743 939 } 744 940 } 745 941
+34
internal/admin/ui/handlers.go
··· 37 37 // operator webhook stream as the JSON API actions. Nil = skip. 38 38 type NotifyStateChangeHook func(kind, did, reason string) 39 39 40 + // WarmupHook sends a batch of warmup emails for a member DID. Returns 41 + // sent/failed counts and per-recipient errors. Nil = feature disabled. 42 + type WarmupHook func(ctx context.Context, did string) (sent, failed int, errors []string, err error) 43 + 40 44 // Handler serves the operator dashboard UI. 41 45 type Handler struct { 42 46 store *relaystore.Store 43 47 labelQuerier LabelQuerier 44 48 queueDepth QueueDepthFunc // may be nil 49 + warmupSeeds int // number of configured seed addresses (0 = hidden) 45 50 mux *http.ServeMux 46 51 onApprove ApproveHook 47 52 onRegenerate RegenerateKeyHook 48 53 onStateChange NotifyStateChangeHook 54 + onWarmup WarmupHook 49 55 50 56 // allowedOrigins is the CSRF allowlist for state-changing admin 51 57 // POSTs. Populated via NewWithOrigins/AllowOrigins; when empty, ··· 83 89 h.onStateChange = hook 84 90 } 85 91 92 + // SetWarmupHook wires the warmup button on member detail pages. 93 + // seedCount controls whether the button renders (0 = hidden). 94 + func (h *Handler) SetWarmupHook(hook WarmupHook, seedCount int) { 95 + h.onWarmup = hook 96 + h.warmupSeeds = seedCount 97 + } 98 + 86 99 // New creates a UI handler that serves the operator dashboard. 87 100 // labelQuerier may be nil if the labeler is not configured. 88 101 func New(store *relaystore.Store, labelQuerier LabelQuerier) *Handler { ··· 236 249 h.handleMemberRejectAction(w, r, did) 237 250 case action == "regenerate-key" && r.Method == http.MethodPost: 238 251 h.handleMemberRegenerateKeyAction(w, r, did) 252 + case action == "warmup" && r.Method == http.MethodPost: 253 + h.handleMemberWarmupAction(w, r, did) 239 254 default: 240 255 http.NotFound(w, r) 241 256 } ··· 254 269 } 255 270 256 271 detail := memberToDetail(member, domains) 272 + detail.WarmupSeeds = h.warmupSeeds 257 273 258 274 // Fetch labels from the labeler (best-effort — don't fail if unavailable) 259 275 if h.labelQuerier != nil { ··· 453 469 APIKey: apiKey, 454 470 }).Render(r.Context(), w) 455 471 } 472 + 473 + func (h *Handler) handleMemberWarmupAction(w http.ResponseWriter, r *http.Request, did string) { 474 + if h.onWarmup == nil { 475 + http.Error(w, "warmup not configured", http.StatusServiceUnavailable) 476 + return 477 + } 478 + 479 + sent, failed, errors, err := h.onWarmup(r.Context(), did) 480 + if err != nil { 481 + log.Printf("ui.warmup: did=%s error=%v", did, err) 482 + http.Error(w, "warmup failed: "+err.Error(), http.StatusInternalServerError) 483 + return 484 + } 485 + 486 + log.Printf("ui.warmup: did=%s sent=%d failed=%d", did, sent, failed) 487 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 488 + _ = templates.WarmupResult(sent, failed, errors).Render(r.Context(), w) 489 + }
+161 -33
internal/admin/ui/recover.go
··· 33 33 34 34 "atmosphere-mail/internal/admin/ui/templates" 35 35 "atmosphere-mail/internal/atpoauth" 36 + "atmosphere-mail/internal/relay" 36 37 "atmosphere-mail/internal/relaystore" 37 38 ) 38 39 ··· 79 80 type RecoverHandler struct { 80 81 pub Publisher 81 82 store *relaystore.Store 83 + resolver HandleResolver 82 84 siteBaseURL string 83 85 // allowedOrigins are the CSRF-acceptable origins for /account/* 84 86 // POSTs. Defaults to [siteBaseURL] when unset. ··· 87 89 // main.go to the admin API's regenerate-key logic so we don't reach 88 90 // across packages to duplicate the SQL. 89 91 regenFn RecoverRegenerateFunc 92 + // onContactEmailChanged is called after a successful contact_email 93 + // update so the admin API can trigger email re-verification. Nil = 94 + // no-op (verification feature not wired). 95 + onContactEmailChanged func(ctx context.Context, domain, contactEmail string) 90 96 91 97 mu sync.Mutex 92 98 tickets map[string]recoveryTicket ··· 101 107 closeOnce sync.Once 102 108 } 103 109 110 + // SetHandleResolver wires handle→DID resolution for the /account sign-in 111 + // form. Nil leaves server-side handle fallback disabled, but users may 112 + // still submit a DID directly. 113 + func (h *RecoverHandler) SetHandleResolver(r HandleResolver) { 114 + h.resolver = r 115 + } 116 + 117 + // SetContactEmailChangedHook registers a callback invoked after a 118 + // successful contact_email update. The callback receives the domain and 119 + // the new contact email address so the caller (main.go) can trigger 120 + // email re-verification without the UI package importing admin. 121 + func (h *RecoverHandler) SetContactEmailChangedHook(fn func(ctx context.Context, domain, contactEmail string)) { 122 + h.onContactEmailChanged = fn 123 + } 124 + 104 125 // RecoverRegenerateFunc rotates the API key for (did, domain) and 105 126 // returns the new plaintext key. Called with the context of the HTTP 106 127 // request so it can time out with the user. ··· 201 222 mux.Handle("/account", wrap(h.handleLanding)) 202 223 mux.Handle("/account/start", wrap(h.handleStart)) 203 224 mux.Handle("/account/manage", wrap(h.handleManage)) 225 + mux.Handle("/account/select-domain", wrap(h.handleSelectDomain)) 204 226 mux.Handle("/account/regenerate", wrap(h.handleRegenerate)) 205 227 mux.Handle("/account/contact-email", wrap(h.handleContactEmail)) 206 228 mux.Handle("/account/sign-out", wrap(h.handleSignOut)) ··· 309 331 } 310 332 311 333 // handleLanding renders the entry form where the member enters the 312 - // sending domain they originally enrolled. 334 + // handle or DID they originally enrolled. 313 335 func (h *RecoverHandler) handleLanding(w http.ResponseWriter, r *http.Request) { 314 336 if r.Method != http.MethodGet { 315 337 http.Error(w, "method not allowed", http.StatusMethodNotAllowed) ··· 319 341 _ = templates.RecoverLanding("").Render(r.Context(), w) 320 342 } 321 343 322 - // handleStart reads the domain from the submitted form, looks up the 323 - // member DID, and kicks off OAuth. Omitting the Attestation field in 324 - // StartOptions is the signal to the callback handler that this is a 325 - // recovery flow, not an enrollment. 344 + // handleStart reads the submitted handle/DID and kicks off OAuth. Domain 345 + // selection happens after OAuth, when we know the browser controls the DID. 346 + // Omitting the Attestation field in StartOptions is the signal to the 347 + // callback handler that this is a recovery flow, not an enrollment. 326 348 func (h *RecoverHandler) handleStart(w http.ResponseWriter, r *http.Request) { 327 349 if r.Method != http.MethodPost { 328 350 http.Error(w, "method not allowed", http.StatusMethodNotAllowed) ··· 332 354 h.renderLandingErr(w, r, "invalid form submission") 333 355 return 334 356 } 335 - domain := strings.TrimSpace(strings.ToLower(r.FormValue("domain"))) 336 - if domain == "" { 337 - h.renderLandingErr(w, r, "A domain is required") 338 - return 339 - } 340 - // Audit #156: validate domain server-side. The HTML pattern= 341 - // attribute is bypassable, and a domain with CRLF will corrupt 342 - // any downstream log line if we let it through. 343 - if !isValidRecoveryDomain(domain) { 344 - log.Printf("recover.start: invalid_domain domain=%s", sanitizeForLog(domain)) 345 - h.renderLandingErr(w, r, "That doesn't look like a valid domain. Check the spelling and try again.") 346 - return 357 + did := strings.TrimSpace(r.FormValue("did")) 358 + identity := strings.TrimSpace(r.FormValue("identity")) 359 + if did == "" && identity != "" { 360 + identity = strings.TrimPrefix(identity, "@") 361 + if strings.HasPrefix(identity, "did:") { 362 + did = identity 363 + } else if h.resolver != nil && relay.LooksLikeHandle(identity) { 364 + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) 365 + resolved, err := h.resolver.ResolveHandle(ctx, identity) 366 + cancel() 367 + if err != nil { 368 + log.Printf("recover.start: identity_resolve_failed identity=%s err=%v", sanitizeForLog(identity), err) 369 + h.renderLandingErr(w, r, "That handle didn't resolve. Check the spelling or paste your DID directly.") 370 + return 371 + } 372 + did = resolved 373 + } 347 374 } 348 - 349 - // Look up the DID from the domain. If the domain isn't enrolled, 350 - // return a generic "check your domain" message — deliberately NOT 351 - // distinguishing "not found" from "wrong entry" to avoid a domain 352 - // enumeration oracle. 353 - memberDomain, err := h.store.GetMemberDomain(r.Context(), domain) 354 - if err != nil || memberDomain == nil { 355 - log.Printf("recover.start: not_found domain=%s err=%v", sanitizeForLog(domain), err) 356 - h.renderLandingErr(w, r, "we couldn't find an enrollment for that domain. Check the spelling, or enroll if this is your first time.") 375 + if did == "" || !strings.HasPrefix(did, "did:") { 376 + h.renderLandingErr(w, r, "A handle or DID is required") 357 377 return 358 378 } 359 379 360 380 // Kick off OAuth. Empty Attestation payload — the callback handler 361 381 // will read that as "recovery, not enrollment" and dispatch here. 362 - authorizeURL, state, err := h.pub.StartAuthFlow(r.Context(), memberDomain.DID, atpoauth.StartOptions{ 363 - ExpectedDID: memberDomain.DID, 364 - Domain: domain, 382 + authorizeURL, state, err := h.pub.StartAuthFlow(r.Context(), did, atpoauth.StartOptions{ 383 + ExpectedDID: did, 384 + // Domain deliberately empty. /account/manage will select the 385 + // member domain after OAuth proves DID ownership. 365 386 // Attestation deliberately nil. 366 387 }) 367 388 if err != nil { ··· 369 390 // message to the user. Upstream error strings can carry PDS 370 391 // hostnames, network internals, and indigo-specific tokens 371 392 // that don't belong in a browser. 372 - log.Printf("recover.start: start_error did_hash=%s domain=%s err=%v", 373 - HashForLog(memberDomain.DID), sanitizeForLog(domain), err) 393 + log.Printf("recover.start: start_error did_hash=%s err=%v", 394 + HashForLog(did), err) 374 395 h.renderLandingErr(w, r, "Couldn't start sign-in. Try again in a moment.") 375 396 return 376 397 } 377 398 378 - log.Printf("recover.start: did_hash=%s domain=%s state_hash=%s", 379 - HashForLog(memberDomain.DID), sanitizeForLog(domain), HashForLog(state)) 399 + log.Printf("recover.start: did_hash=%s state_hash=%s", 400 + HashForLog(did), HashForLog(state)) 380 401 http.Redirect(w, r, authorizeURL, http.StatusFound) 381 402 } 382 403 ··· 415 436 return 416 437 } 417 438 439 + if ticket.domain == "" { 440 + domains, err := h.store.ListMemberDomains(r.Context(), ticket.did) 441 + if err != nil { 442 + log.Printf("recover.manage: did_hash=%s domain_list_error=%v", HashForLog(ticket.did), err) 443 + http.Error(w, "internal error", http.StatusInternalServerError) 444 + return 445 + } 446 + if len(domains) == 0 { 447 + log.Printf("recover.manage: did_hash=%s no_domains", HashForLog(ticket.did)) 448 + h.renderLandingErr(w, r, "No enrolled domains found for that account. Enroll first, then come back to Account.") 449 + return 450 + } 451 + if len(domains) == 1 { 452 + if updated, ok := h.setTicketDomain(id, r.UserAgent(), domains[0].Domain); ok { 453 + ticket = updated 454 + } else { 455 + h.renderLandingErr(w, r, "Recovery session expired or not found. Start recovery again.") 456 + return 457 + } 458 + } else { 459 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 460 + _ = templates.RecoverSelectDomain(templates.RecoverSelectDomainData{ 461 + DID: ticket.did, 462 + Domains: domains, 463 + ExpiresAt: ticket.expiry.Format(time.RFC3339), 464 + }).Render(r.Context(), w) 465 + return 466 + } 467 + } 468 + 418 469 memberDomain, err := h.store.GetMemberDomain(r.Context(), ticket.domain) 419 470 if err != nil || memberDomain == nil { 420 471 log.Printf("recover.manage: domain=%s lookup_error=%v", sanitizeForLog(ticket.domain), err) ··· 432 483 }).Render(r.Context(), w) 433 484 } 434 485 486 + func (h *RecoverHandler) handleSelectDomain(w http.ResponseWriter, r *http.Request) { 487 + if r.Method != http.MethodPost { 488 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 489 + return 490 + } 491 + if err := r.ParseForm(); err != nil { 492 + http.Error(w, "invalid form", http.StatusBadRequest) 493 + return 494 + } 495 + id, ok := recoveryTicketFromCookie(r) 496 + if !ok { 497 + h.renderLandingErr(w, r, "Session expired or not found. Start over by signing in.") 498 + return 499 + } 500 + ticket, ok := h.lookupTicket(id, r.UserAgent()) 501 + if !ok { 502 + h.renderLandingErr(w, r, "Session expired or not found. Start over by signing in.") 503 + return 504 + } 505 + if ticket.domain != "" { 506 + http.Redirect(w, r, "/account/manage", http.StatusFound) 507 + return 508 + } 509 + domain := strings.TrimSpace(strings.ToLower(r.FormValue("domain"))) 510 + if !isValidRecoveryDomain(domain) { 511 + log.Printf("account.select_domain: invalid_domain did_hash=%s domain=%s", HashForLog(ticket.did), sanitizeForLog(domain)) 512 + h.renderLandingErr(w, r, "That doesn't look like a valid domain. Sign in again and choose a listed domain.") 513 + return 514 + } 515 + memberDomain, err := h.store.GetMemberDomain(r.Context(), domain) 516 + if err != nil || memberDomain == nil || memberDomain.DID != ticket.did { 517 + log.Printf("account.select_domain: did_hash=%s domain=%s allowed=false err=%v", 518 + HashForLog(ticket.did), sanitizeForLog(domain), err) 519 + h.renderLandingErr(w, r, "That domain is not enrolled for this account. Sign in again and choose a listed domain.") 520 + return 521 + } 522 + if _, ok := h.setTicketDomain(id, r.UserAgent(), domain); !ok { 523 + h.renderLandingErr(w, r, "Session expired or not found. Start over by signing in.") 524 + return 525 + } 526 + log.Printf("account.select_domain: did_hash=%s domain=%s", HashForLog(ticket.did), sanitizeForLog(domain)) 527 + http.Redirect(w, r, "/account/manage", http.StatusFound) 528 + } 529 + 435 530 // handleRegenerate consumes the ticket, rotates the API key via the 436 531 // injected regenFn, and renders the one-time view of the new plaintext 437 532 // key. POST-only so a GET from a browser referer doesn't accidentally ··· 459 554 h.renderLandingErr(w, r, "Recovery session expired or already used. Start recovery again.") 460 555 return 461 556 } 557 + if ticket.domain == "" { 558 + h.renderLandingErr(w, r, "Choose a domain before rotating your API key.") 559 + return 560 + } 462 561 463 562 apiKey, err := h.regenFn(ticket.did, ticket.domain) 464 563 if err != nil { ··· 509 608 h.renderLandingErr(w, r, "Session expired or not found. Start over by signing in.") 510 609 return 511 610 } 611 + if ticket.domain == "" { 612 + http.Redirect(w, r, "/account/manage", http.StatusFound) 613 + return 614 + } 512 615 email := strings.TrimSpace(r.FormValue("contact_email")) 513 616 // Audit #156: empty is OK (unset); non-empty must parse as a 514 617 // valid RFC 5322 address. net/mail.ParseAddress is stricter than ··· 528 631 } 529 632 log.Printf("account.contact_email: did_hash=%s domain=%s updated", 530 633 HashForLog(ticket.did), sanitizeForLog(ticket.domain)) 634 + // Trigger re-verification for the new address. Goroutined so a 635 + // slow enqueue doesn't delay the page render. No-op if hook is nil 636 + // (verification feature not wired) or if email was cleared. 637 + if h.onContactEmailChanged != nil && email != "" { 638 + go h.onContactEmailChanged(context.Background(), ticket.domain, email) 639 + } 531 640 h.renderManageWithMessage(w, r, ticket, "Contact email updated.", false) 532 641 } 533 642 ··· 612 721 if !uaMatches(t.uaHash, userAgent) { 613 722 return recoveryTicket{}, false 614 723 } 724 + return t, true 725 + } 726 + 727 + func (h *RecoverHandler) setTicketDomain(id, userAgent, domain string) (recoveryTicket, bool) { 728 + if id == "" || domain == "" { 729 + return recoveryTicket{}, false 730 + } 731 + h.mu.Lock() 732 + defer h.mu.Unlock() 733 + t, ok := h.tickets[id] 734 + if !ok || t.consumed || time.Now().After(t.expiry) { 735 + delete(h.tickets, id) 736 + return recoveryTicket{}, false 737 + } 738 + if !uaMatches(t.uaHash, userAgent) { 739 + return recoveryTicket{}, false 740 + } 741 + t.domain = domain 742 + h.tickets[id] = t 615 743 return t, true 616 744 } 617 745
+219 -23
internal/admin/ui/recover_test.go
··· 73 73 } 74 74 } 75 75 76 + func addRecoverDomain(t *testing.T, s *relaystore.Store, did, domain string) { 77 + t.Helper() 78 + now := time.Now().UTC() 79 + if err := s.InsertMemberDomain(context.Background(), &relaystore.MemberDomain{ 80 + DID: did, 81 + Domain: domain, 82 + APIKeyHash: []byte("old-hash"), 83 + DKIMRSAPriv: []byte("rsa"), 84 + DKIMEdPriv: []byte("ed"), 85 + DKIMSelector: "atmos20260420", 86 + CreatedAt: now, 87 + }); err != nil { 88 + t.Fatalf("add domain: %v", err) 89 + } 90 + } 91 + 76 92 func TestRecover_LandingRendersForm(t *testing.T) { 77 93 h := NewRecoverHandler(&fakePublisher{}, newRecoverTestStore(t), "https://example.com", nil) 78 94 req := httptest.NewRequest(http.MethodGet, "/account", nil) ··· 85 101 t.Fatalf("status = %d, want 200", rec.Code) 86 102 } 87 103 body := rec.Body.String() 88 - if !strings.Contains(body, `name="domain"`) { 89 - t.Error("landing page missing domain input") 104 + if !strings.Contains(body, `name="identity"`) { 105 + t.Error("landing page missing identity input") 106 + } 107 + if !strings.Contains(body, `>Your handle<`) { 108 + t.Error("landing page should use protocol-neutral handle label") 109 + } 110 + if !strings.Contains(body, `href="https://atproto.com/specs/handle"`) { 111 + t.Error("landing page should link to the handle spec") 112 + } 113 + if strings.Contains(body, `name="domain"`) { 114 + t.Error("landing page should not ask for a domain before OAuth") 90 115 } 91 116 if !strings.Contains(body, `action="/account/start"`) { 92 117 t.Error("landing page missing form action") ··· 101 126 pub := &fakePublisher{returnURL: "https://pds.example/oauth/authorize?foo=bar"} 102 127 h := NewRecoverHandler(pub, store, "https://example.com", nil) 103 128 104 - form := url.Values{"domain": {"recover.example.com"}} 129 + form := url.Values{"did": {did}, "identity": {did}} 105 130 req := httptest.NewRequest(http.MethodPost, "/account/start", strings.NewReader(form.Encode())) 106 131 req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 107 132 req.Header.Set("Origin", "https://example.com") ··· 122 147 if pub.lastOpts.ExpectedDID != did { 123 148 t.Errorf("ExpectedDID = %q, want %q", pub.lastOpts.ExpectedDID, did) 124 149 } 150 + if pub.lastOpts.Domain != "" { 151 + t.Errorf("Domain = %q, want empty before post-OAuth domain selection", pub.lastOpts.Domain) 152 + } 125 153 if len(pub.lastOpts.Attestation) != 0 { 126 154 t.Errorf("Attestation should be empty for recovery flow, got %d bytes", len(pub.lastOpts.Attestation)) 127 155 } 128 156 } 129 157 130 - func TestRecover_StartRejectsUnknownDomain(t *testing.T) { 131 - h := NewRecoverHandler(&fakePublisher{}, newRecoverTestStore(t), "https://example.com", nil) 132 - form := url.Values{"domain": {"ghost.example"}} 158 + func TestRecover_StartDoesNotRequireKnownDomainBeforeOAuth(t *testing.T) { 159 + pub := &fakePublisher{returnURL: "https://pds.example/oauth/authorize?foo=bar"} 160 + h := NewRecoverHandler(pub, newRecoverTestStore(t), "https://example.com", nil) 161 + form := url.Values{"did": {"did:plc:recover1111111111111aa"}} 133 162 req := httptest.NewRequest(http.MethodPost, "/account/start", strings.NewReader(form.Encode())) 134 163 req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 135 164 req.Header.Set("Origin", "https://example.com") ··· 138 167 h.RegisterRoutes(mux) 139 168 mux.ServeHTTP(rec, req) 140 169 170 + if rec.Code != http.StatusFound { 171 + t.Fatalf("status = %d, want 302; body=%q", rec.Code, rec.Body.String()) 172 + } 173 + if got := rec.Header().Get("Location"); got != pub.returnURL { 174 + t.Errorf("redirect to %q, want %q", got, pub.returnURL) 175 + } 176 + if pub.lastOpts.Domain != "" { 177 + t.Errorf("Domain = %q, want empty before post-OAuth domain selection", pub.lastOpts.Domain) 178 + } 179 + } 180 + 181 + func TestRecover_SelectDomainRejectsDIDDomainMismatch(t *testing.T) { 182 + store := newRecoverTestStore(t) 183 + seedRecoverMember(t, store, "did:plc:recover1111111111111aa", "recover.example.com") 184 + 185 + h := NewRecoverHandler(&fakePublisher{}, store, "https://example.com", nil) 186 + target := h.IssueRecoveryTicket("did:plc:other222222222222222", "") 187 + ticket := strings.TrimPrefix(target, "/account/manage?ticket=") 188 + form := url.Values{"domain": {"recover.example.com"}} 189 + req := httptest.NewRequest(http.MethodPost, "/account/select-domain", strings.NewReader(form.Encode())) 190 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 191 + req.Header.Set("Origin", "https://example.com") 192 + req.AddCookie(&http.Cookie{Name: RecoveryCookieName, Value: ticket}) 193 + rec := httptest.NewRecorder() 194 + mux := http.NewServeMux() 195 + h.RegisterRoutes(mux) 196 + mux.ServeHTTP(rec, req) 197 + 141 198 if rec.Code != http.StatusBadRequest { 142 199 t.Fatalf("status = %d, want 400", rec.Code) 143 200 } 144 - // Don't leak a "domain not found" oracle; assertion: the message is 145 - // generic and the form re-renders for retry. 146 - body := rec.Body.String() 147 - if !strings.Contains(body, `name="domain"`) { 148 - t.Error("expected landing form re-rendered on error") 201 + } 202 + 203 + func TestRecover_StartServerResolvesHandleWhenDIDMissing(t *testing.T) { 204 + store := newRecoverTestStore(t) 205 + did := "did:plc:recover1111111111111aa" 206 + seedRecoverMember(t, store, did, "recover.example.com") 207 + 208 + pub := &fakePublisher{returnURL: "https://pds.example/oauth/authorize?foo=bar"} 209 + h := NewRecoverHandler(pub, store, "https://example.com", nil) 210 + h.SetHandleResolver(&fakeResolver{did: did}) 211 + form := url.Values{"identity": {"recover.example.com"}} 212 + req := httptest.NewRequest(http.MethodPost, "/account/start", strings.NewReader(form.Encode())) 213 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 214 + req.Header.Set("Origin", "https://example.com") 215 + rec := httptest.NewRecorder() 216 + mux := http.NewServeMux() 217 + h.RegisterRoutes(mux) 218 + mux.ServeHTTP(rec, req) 219 + 220 + if rec.Code != http.StatusFound { 221 + t.Fatalf("status = %d, want 302; body=%q", rec.Code, rec.Body.String()) 222 + } 223 + if pub.lastIdentifier != did { 224 + t.Errorf("published identifier = %q, want %q", pub.lastIdentifier, did) 149 225 } 150 226 } 151 227 ··· 192 268 } 193 269 } 194 270 271 + func TestRecover_ManageAutoSelectsOnlyDomain(t *testing.T) { 272 + store := newRecoverTestStore(t) 273 + did := "did:plc:single11111111111111" 274 + domain := "single.example.com" 275 + seedRecoverMember(t, store, did, domain) 276 + 277 + h := NewRecoverHandler(&fakePublisher{}, store, "https://example.com", nil) 278 + target := h.IssueRecoveryTicket(did, "") 279 + ticket := strings.TrimPrefix(target, "/account/manage?ticket=") 280 + 281 + req := httptest.NewRequest(http.MethodGet, "/account/manage", nil) 282 + req.AddCookie(&http.Cookie{Name: RecoveryCookieName, Value: ticket}) 283 + rec := httptest.NewRecorder() 284 + mux := http.NewServeMux() 285 + h.RegisterRoutes(mux) 286 + mux.ServeHTTP(rec, req) 287 + 288 + if rec.Code != http.StatusOK { 289 + t.Fatalf("status = %d, want 200", rec.Code) 290 + } 291 + body := rec.Body.String() 292 + if !strings.Contains(body, domain) { 293 + t.Error("manage page should auto-select the only enrolled domain") 294 + } 295 + if strings.Contains(body, `action="/account/select-domain"`) { 296 + t.Error("single-domain account should not show the domain picker") 297 + } 298 + } 299 + 300 + func TestRecover_ManagePromptsWhenMultipleDomains(t *testing.T) { 301 + store := newRecoverTestStore(t) 302 + did := "did:plc:multi111111111111111" 303 + seedRecoverMember(t, store, did, "one.example.com") 304 + addRecoverDomain(t, store, did, "two.example.com") 305 + 306 + h := NewRecoverHandler(&fakePublisher{}, store, "https://example.com", nil) 307 + target := h.IssueRecoveryTicket(did, "") 308 + ticket := strings.TrimPrefix(target, "/account/manage?ticket=") 309 + 310 + req := httptest.NewRequest(http.MethodGet, "/account/manage", nil) 311 + req.AddCookie(&http.Cookie{Name: RecoveryCookieName, Value: ticket}) 312 + rec := httptest.NewRecorder() 313 + mux := http.NewServeMux() 314 + h.RegisterRoutes(mux) 315 + mux.ServeHTTP(rec, req) 316 + 317 + if rec.Code != http.StatusOK { 318 + t.Fatalf("status = %d, want 200", rec.Code) 319 + } 320 + body := rec.Body.String() 321 + if !strings.Contains(body, `action="/account/select-domain"`) { 322 + t.Error("multi-domain account should render the domain picker") 323 + } 324 + for _, domain := range []string{"one.example.com", "two.example.com"} { 325 + if !strings.Contains(body, domain) { 326 + t.Errorf("domain picker missing %s", domain) 327 + } 328 + } 329 + if strings.Contains(body, `action="/account/regenerate"`) { 330 + t.Error("domain picker should not expose domain-scoped actions yet") 331 + } 332 + } 333 + 334 + func TestRecover_ManageRejectsWhenNoDomains(t *testing.T) { 335 + h := NewRecoverHandler(&fakePublisher{}, newRecoverTestStore(t), "https://example.com", nil) 336 + target := h.IssueRecoveryTicket("did:plc:none111111111111111", "") 337 + ticket := strings.TrimPrefix(target, "/account/manage?ticket=") 338 + 339 + req := httptest.NewRequest(http.MethodGet, "/account/manage", nil) 340 + req.AddCookie(&http.Cookie{Name: RecoveryCookieName, Value: ticket}) 341 + rec := httptest.NewRecorder() 342 + mux := http.NewServeMux() 343 + h.RegisterRoutes(mux) 344 + mux.ServeHTTP(rec, req) 345 + 346 + if rec.Code != http.StatusBadRequest { 347 + t.Fatalf("status = %d, want 400", rec.Code) 348 + } 349 + body := rec.Body.String() 350 + if !strings.Contains(body, `name="identity"`) { 351 + t.Error("expected landing form re-rendered on no-domain error") 352 + } 353 + } 354 + 355 + func TestRecover_SelectDomainSetsDomainAndRedirects(t *testing.T) { 356 + store := newRecoverTestStore(t) 357 + did := "did:plc:select1111111111111" 358 + seedRecoverMember(t, store, did, "one.example.com") 359 + addRecoverDomain(t, store, did, "two.example.com") 360 + 361 + h := NewRecoverHandler(&fakePublisher{}, store, "https://example.com", nil) 362 + target := h.IssueRecoveryTicket(did, "") 363 + ticket := strings.TrimPrefix(target, "/account/manage?ticket=") 364 + mux := http.NewServeMux() 365 + h.RegisterRoutes(mux) 366 + 367 + form := url.Values{"domain": {"two.example.com"}} 368 + req := httptest.NewRequest(http.MethodPost, "/account/select-domain", strings.NewReader(form.Encode())) 369 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 370 + req.Header.Set("Origin", "https://example.com") 371 + req.AddCookie(&http.Cookie{Name: RecoveryCookieName, Value: ticket}) 372 + rec := httptest.NewRecorder() 373 + mux.ServeHTTP(rec, req) 374 + 375 + if rec.Code != http.StatusFound { 376 + t.Fatalf("select status = %d, want 302; body=%q", rec.Code, rec.Body.String()) 377 + } 378 + 379 + req = httptest.NewRequest(http.MethodGet, "/account/manage", nil) 380 + req.AddCookie(&http.Cookie{Name: RecoveryCookieName, Value: ticket}) 381 + rec = httptest.NewRecorder() 382 + mux.ServeHTTP(rec, req) 383 + if rec.Code != http.StatusOK { 384 + t.Fatalf("manage status = %d, want 200", rec.Code) 385 + } 386 + if !strings.Contains(rec.Body.String(), "two.example.com") { 387 + t.Error("manage page should render selected domain") 388 + } 389 + } 390 + 195 391 func TestRecover_ManageRejectsInvalidTicket(t *testing.T) { 196 392 h := NewRecoverHandler(&fakePublisher{}, newRecoverTestStore(t), "https://example.com", nil) 197 393 // Post-CRIT-#152: ticket lives in a cookie. A bogus cookie value ··· 304 500 305 501 // --- Audit #156: validate domain + contact_email before log/store --- 306 502 307 - // TestRecoverStart_RejectsInvalidDomain ensures the /account/start 308 - // handler rejects syntactically-invalid domains (including CRLF 309 - // injection attempts) with a 400 and never reaches the store. 310 - func TestRecoverStart_RejectsInvalidDomain(t *testing.T) { 503 + // TestRecoverSelectDomain_RejectsInvalidDomain ensures the post-OAuth 504 + // domain picker rejects syntactically-invalid domains (including CRLF 505 + // injection attempts) with a 400. 506 + func TestRecoverSelectDomain_RejectsInvalidDomain(t *testing.T) { 311 507 cases := []string{ 312 508 "not a domain", 313 509 "foo.com\r\nFAKE: injected=1", ··· 318 514 for _, in := range cases { 319 515 t.Run(in, func(t *testing.T) { 320 516 store := newRecoverTestStore(t) 321 - pub := &fakePublisher{returnURL: "https://should.not/redirect"} 322 - h := NewRecoverHandler(pub, store, "https://example.com", nil) 517 + did := "did:plc:recover1111111111111aa" 518 + seedRecoverMember(t, store, did, "valid.example.com") 519 + h := NewRecoverHandler(&fakePublisher{}, store, "https://example.com", nil) 520 + target := h.IssueRecoveryTicket(did, "") 521 + ticket := strings.TrimPrefix(target, "/account/manage?ticket=") 323 522 mux := http.NewServeMux() 324 523 h.RegisterRoutes(mux) 325 524 326 525 form := url.Values{"domain": {in}} 327 - req := httptest.NewRequest(http.MethodPost, "/account/start", strings.NewReader(form.Encode())) 526 + req := httptest.NewRequest(http.MethodPost, "/account/select-domain", strings.NewReader(form.Encode())) 328 527 req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 329 528 req.Header.Set("Origin", "https://example.com") 529 + req.AddCookie(&http.Cookie{Name: RecoveryCookieName, Value: ticket}) 330 530 rec := httptest.NewRecorder() 331 531 mux.ServeHTTP(rec, req) 332 532 333 533 if rec.Code != http.StatusBadRequest { 334 534 t.Errorf("status = %d, want 400 for invalid domain %q", rec.Code, in) 335 - } 336 - if pub.lastIdentifier != "" { 337 - t.Errorf("publisher was called (identifier=%q) for invalid domain %q", 338 - pub.lastIdentifier, in) 339 535 } 340 536 }) 341 537 } ··· 401 597 mux := http.NewServeMux() 402 598 h.RegisterRoutes(mux) 403 599 404 - form := url.Values{"domain": {domain}} 600 + form := url.Values{"did": {did}, "domain": {domain}} 405 601 req := httptest.NewRequest(http.MethodPost, "/account/start", strings.NewReader(form.Encode())) 406 602 req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 407 603 req.Header.Set("Origin", "https://example.com")
+454 -103
internal/admin/ui/templates/enroll.templ
··· 286 286 border-color: var(--ink); 287 287 box-shadow: inset 0 -2px 0 0 var(--accent); 288 288 } 289 + .handle-input-wrapper { 290 + position: relative; 291 + } 292 + .handle-input-wrapper input[type=text] { 293 + padding-left: 1.75rem; 294 + } 295 + .handle-input-wrapper::before { 296 + content: "@"; 297 + position: absolute; 298 + left: 0.75rem; 299 + top: 0.6rem; 300 + font-family: 'JetBrains Mono', 'Menlo', monospace; 301 + font-size: var(--t-m); 302 + color: var(--muted); 303 + pointer-events: none; 304 + z-index: 1; 305 + } 306 + .handle-suggestions { 307 + position: absolute; 308 + left: 0; 309 + right: 0; 310 + bottom: 100%; 311 + background: var(--ink); 312 + color: var(--bg); 313 + border-radius: 2px 2px 0 0; 314 + z-index: 10; 315 + max-height: 260px; 316 + overflow-y: auto; 317 + box-shadow: 0 -4px 16px rgba(0,0,0,0.15); 318 + } 319 + .handle-suggestion { 320 + display: flex; 321 + align-items: center; 322 + gap: 0.6rem; 323 + padding: 0.5rem 0.75rem; 324 + cursor: pointer; 325 + transition: background 0.1s; 326 + } 327 + .handle-suggestion:hover, 328 + .handle-suggestion.active { 329 + background: oklch(0.30 0.02 70); 330 + } 331 + .suggestion-avatar { 332 + width: 32px; 333 + height: 32px; 334 + border-radius: 50%; 335 + flex-shrink: 0; 336 + object-fit: cover; 337 + } 338 + .suggestion-avatar-placeholder { 339 + width: 32px; 340 + height: 32px; 341 + border-radius: 50%; 342 + flex-shrink: 0; 343 + background: oklch(0.40 0.01 70); 344 + } 345 + .suggestion-text { 346 + display: flex; 347 + flex-direction: column; 348 + min-width: 0; 349 + } 350 + .suggestion-name { 351 + font-weight: 700; 352 + font-size: var(--t-s); 353 + overflow: hidden; 354 + text-overflow: ellipsis; 355 + white-space: nowrap; 356 + } 357 + .suggestion-handle { 358 + font-size: var(--t-xs); 359 + color: oklch(0.65 0.01 70); 360 + overflow: hidden; 361 + text-overflow: ellipsis; 362 + white-space: nowrap; 363 + } 289 364 textarea { 290 365 resize: vertical; 291 366 line-height: 1.4; ··· 474 549 // to take (DID, domain, contact email) and start the DNS-verification 475 550 // handshake. Uses masthead-sub (no drop-cap) because the drop-cap is 476 551 // reserved for the root page's brand mark. 477 - templ EnrollLanding() { 552 + templ EnrollLanding(authDID, authHandle string, requireAuth bool, existingDomains []string) { 478 553 @publicLayout("Enroll", false) { 479 554 <h1 class="masthead masthead-sub">Enroll</h1> 480 - <p class="lede" style="margin-bottom: 1.25rem;"> 481 - Three fields, one DNS record, and you're in. We'll issue credentials to start sending mail through the relay. 482 - </p> 555 + 556 + if authDID == "" { 557 + if requireAuth { 558 + <p class="lede" style="margin-bottom: 1.25rem;"> 559 + First, verify your handle. We'll redirect you to your PDS to confirm you own the account. 560 + </p> 561 + } else { 562 + <p class="lede" style="margin-bottom: 1.25rem;"> 563 + Three fields, one DNS record, and you're in. We'll issue credentials to start sending mail through the relay. 564 + </p> 565 + } 483 566 484 - <section class="section" style="margin-top: 1.25rem; padding-top: 0.75rem;"> 485 - <form id="enroll-form" action="/enroll/start" method="POST"> 486 - <label for="identity">Your Bluesky handle</label> 487 - <small>The handle of the PDS admin account you're enrolling with — e.g. <code>alice.bsky.social</code> or a custom domain handle. You can also paste a full <code>did:plc:…</code>.</small> 488 - <input 489 - type="text" 490 - id="identity" 491 - name="identity" 492 - placeholder="alice.bsky.social" 493 - required 494 - autocomplete="off" 495 - spellcheck="false" 496 - autocapitalize="off" 497 - /> 498 - <div id="resolver-hint" class="resolver-hint" aria-live="polite"></div> 499 - <!-- Hidden field submitted to /enroll/start. Populated by JS 500 - from the identity input (or directly if the user pasted a DID). --> 501 - <input type="hidden" id="did" name="did" value=""/> 567 + <section class="section" style="margin-top: 1.25rem; padding-top: 0.75rem;"> 568 + <form id="enroll-form" action={ enrollLandingAction(requireAuth) } method="POST"> 569 + <label for="identity">Your handle</label> 570 + <small>The <a href="https://atproto.com/specs/handle">handle</a> of the account you're enrolling with — e.g. <code>alice.bsky.social</code> or a custom domain handle. You can also paste a full <code>did:plc:…</code>.</small> 571 + <input 572 + type="text" 573 + id="identity" 574 + name="identity" 575 + placeholder="alice.bsky.social" 576 + required 577 + autocomplete="off" 578 + spellcheck="false" 579 + autocapitalize="off" 580 + /> 581 + <div id="resolver-hint" class="resolver-hint" aria-live="polite"></div> 582 + <input type="hidden" id="did" name="did" value=""/> 502 583 503 - <label for="domain">Sending domain</label> 504 - <small>The domain that appears after the @ in emails sent through the relay — e.g. <code>example.com</code> or <code>mail.example.com</code>. Must be a domain you control; you'll add a DNS record to verify ownership.</small> 505 - <input 506 - type="text" 507 - id="domain" 508 - name="domain" 509 - placeholder="example.com" 510 - required 511 - pattern="[a-z0-9][a-z0-9\-.]*\.[a-z]{2,}" 512 - autocomplete="off" 513 - spellcheck="false" 514 - autocapitalize="off" 515 - /> 584 + if !requireAuth { 585 + <label for="domain">Sending domain</label> 586 + <small>The domain that appears after the @ in emails sent through the relay — e.g. <code>example.com</code> or <code>mail.example.com</code>. Must be a domain you control; you'll add a DNS record to verify ownership.</small> 587 + <input 588 + type="text" 589 + id="domain" 590 + name="domain" 591 + placeholder="example.com" 592 + required 593 + pattern="[a-z0-9][a-z0-9\-.]*\.[a-z]{2,}" 594 + autocomplete="off" 595 + spellcheck="false" 596 + autocapitalize="off" 597 + /> 516 598 517 - <label for="contact_email">Contact email</label> 518 - <small>Where we'll send your approval notice and any account notifications. This can be any email you check — it doesn't need to be at your sending domain. Stored privately; never displayed publicly.</small> 519 - <input 520 - type="email" 521 - id="contact_email" 522 - name="contact_email" 523 - placeholder="you@example.com" 524 - required 525 - autocomplete="email" 526 - spellcheck="false" 527 - autocapitalize="off" 528 - /> 599 + <label for="contact_email">Contact email</label> 600 + <small>Where we'll send your approval notice and any account notifications. This can be any email you check — it doesn't need to be at your sending domain. Stored privately; never displayed publicly.</small> 601 + <input 602 + type="email" 603 + id="contact_email" 604 + name="contact_email" 605 + placeholder="you@example.com" 606 + required 607 + autocomplete="email" 608 + spellcheck="false" 609 + autocapitalize="off" 610 + /> 529 611 530 - <button type="submit" id="enroll-submit">Start enrollment</button> 531 - </form> 532 - <p class="section-lede" style="margin-top: 1rem; margin-bottom: 0;"> 533 - Already enrolled? Sign in at <a href="/account">Account</a> to see DKIM records, rotate your API key, or update your contact email. 534 - </p> 535 - <p class="section-lede" style="margin-top: 0.5rem; margin-bottom: 0; font-size: var(--t-xs);"> 536 - New here? The <a href="/">landing page</a> covers how this works. 537 - </p> 538 - <script> 539 - // Handle↔DID resolver. Typing a DID (starts with "did:") bypasses 540 - // the network round-trip. Typing anything else is treated as a 612 + <label style="display: flex; align-items: flex-start; gap: 0.5rem; margin-top: 1.25rem; font-weight: 400; cursor: pointer;"> 613 + <input 614 + type="checkbox" 615 + name="terms_accepted" 616 + id="terms_accepted" 617 + required 618 + style="margin-top: 0.25rem; accent-color: var(--accent);" 619 + /> 620 + <span style="font-size: var(--t-s);"> 621 + I agree to the <a href="/terms" target="_blank">Terms of Service</a> and 622 + <a href="/aup" target="_blank">Acceptable Use Policy</a>, 623 + which may be updated with reasonable notice. 624 + </span> 625 + </label> 541 626 // handle and resolved against /enroll/resolve on blur or submit. 542 627 (function() { 543 628 const form = document.getElementById('enroll-form'); ··· 550 635 return /^did:(plc|web):[A-Za-z0-9._%\-]+$/.test(s.trim()); 551 636 } 552 637 553 - function setHint(text, cls) { 554 - hint.textContent = text; 555 - hint.className = 'resolver-hint ' + (cls || ''); 556 - } 638 + <button type="submit" id="enroll-submit" data-default-text={ enrollLandingSubmitText(requireAuth) }>{ enrollLandingSubmitText(requireAuth) }</button> 639 + </form> 640 + <p class="section-lede" style="margin-top: 1rem; margin-bottom: 0;"> 641 + Already enrolled? Sign in at <a href="/account">Account</a> to see DKIM records, rotate your API key, or update your contact email. 642 + </p> 643 + <p class="section-lede" style="margin-top: 0.5rem; margin-bottom: 0; font-size: var(--t-xs);"> 644 + New here? The <a href="/">landing page</a> covers how this works. 645 + </p> 646 + <script> 647 + (function() { 648 + var SEARCH_API = 'https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead'; 649 + var DEBOUNCE_MS = 250; 650 + var MIN_QUERY = 2; 651 + var MAX_RESULTS = 6; 652 + 653 + var form = document.getElementById('enroll-form'); 654 + var identity = document.getElementById('identity'); 655 + var didField = document.getElementById('did'); 656 + var hint = document.getElementById('resolver-hint'); 657 + var submit = document.getElementById('enroll-submit'); 658 + 659 + var debounceTimer = null; 660 + var abortCtrl = null; 661 + var activeIndex = -1; 662 + var currentResults = []; 663 + 664 + function esc(s) { 665 + return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); 666 + } 667 + function isDID(s) { 668 + return /^did:(plc|web):[A-Za-z0-9._%\-]+$/.test(s.trim()); 669 + } 670 + function setHint(text, cls) { 671 + hint.textContent = text; 672 + hint.className = 'resolver-hint ' + (cls || ''); 673 + } 674 + 675 + var wrapper = document.createElement('div'); 676 + wrapper.className = 'handle-input-wrapper'; 677 + identity.parentElement.insertBefore(wrapper, identity); 678 + wrapper.appendChild(identity); 679 + wrapper.parentElement.insertBefore(hint, wrapper.nextSibling); 557 680 558 - async function resolve(raw) { 559 - const v = (raw || '').replace(/^@/, '').trim(); 560 - if (!v) { setHint('', ''); didField.value = ''; return; } 561 - if (isDID(v)) { setHint(v, 'is-ok'); didField.value = v; return; } 681 + var dropdown = document.createElement('div'); 682 + dropdown.className = 'handle-suggestions'; 683 + dropdown.setAttribute('role', 'listbox'); 684 + dropdown.style.display = 'none'; 685 + wrapper.appendChild(dropdown); 686 + identity.setAttribute('role', 'combobox'); 687 + identity.setAttribute('aria-autocomplete', 'list'); 688 + identity.setAttribute('aria-expanded', 'false'); 562 689 563 - setHint('Resolving ' + v + '…', 'is-loading'); 564 - didField.value = ''; 565 - try { 566 - const r = await fetch('/enroll/resolve?handle=' + encodeURIComponent(v), { 567 - headers: { Accept: 'application/json' }, 568 - }); 569 - if (!r.ok) { 570 - const body = await r.json().catch(() => ({error: 'resolution failed'})); 571 - setHint(body.error || 'resolution failed', 'is-err'); 690 + function renderSuggestions(results) { 691 + if (!results.length) { 692 + dropdown.style.display = 'none'; 693 + identity.setAttribute('aria-expanded', 'false'); 572 694 return; 573 695 } 574 - const data = await r.json(); 575 - if (data.did) { 576 - setHint(data.did, 'is-ok'); 577 - didField.value = data.did; 696 + dropdown.innerHTML = results.map(function(r, i) { 697 + return '<div class="handle-suggestion" role="option" data-index="' + i + '" data-handle="' + esc(r.handle) + '">' 698 + + (r.avatar 699 + ? '<img src="' + esc(r.avatar) + '" alt="" class="suggestion-avatar"/>' 700 + : '<div class="suggestion-avatar-placeholder"></div>') 701 + + '<div class="suggestion-text">' 702 + + '<span class="suggestion-name">' + esc(r.displayName) + '</span>' 703 + + '<span class="suggestion-handle">@' + esc(r.handle) + '</span>' 704 + + '</div></div>'; 705 + }).join(''); 706 + dropdown.style.display = ''; 707 + identity.setAttribute('aria-expanded', 'true'); 708 + } 709 + 710 + function updateActive() { 711 + var items = dropdown.querySelectorAll('.handle-suggestion'); 712 + for (var i = 0; i < items.length; i++) { 713 + if (i === activeIndex) items[i].classList.add('active'); 714 + else items[i].classList.remove('active'); 578 715 } 579 - } catch (e) { 580 - setHint('Network error — try again or paste a DID directly', 'is-err'); 716 + } 717 + 718 + function selectHandle(handle) { 719 + identity.value = handle; 720 + dropdown.style.display = 'none'; 721 + identity.setAttribute('aria-expanded', 'false'); 722 + currentResults = []; 723 + activeIndex = -1; 724 + resolve(handle); 581 725 } 582 - } 583 726 584 - identity.addEventListener('blur', () => resolve(identity.value)); 585 - identity.addEventListener('input', () => { 586 - // Clear the resolved DID while the user is editing; they'll 587 - // re-resolve on blur. Prevents stale DID from being submitted 588 - // if the user changes their mind mid-type. 589 - if (didField.value && identity.value.trim() !== didField.value) { 727 + function searchHandles(query) { 728 + if (abortCtrl) abortCtrl.abort(); 729 + if (query.length < MIN_QUERY) return Promise.resolve([]); 730 + abortCtrl = new AbortController(); 731 + return fetch(SEARCH_API + '?q=' + encodeURIComponent(query) + '&limit=' + MAX_RESULTS, { signal: abortCtrl.signal }) 732 + .then(function(r) { return r.ok ? r.json() : { actors: [] }; }) 733 + .then(function(data) { 734 + return (data.actors || []).map(function(a) { 735 + return { handle: a.handle, displayName: a.displayName || a.handle, avatar: a.avatar || null }; 736 + }); 737 + }) 738 + .catch(function() { return []; }); 739 + } 740 + 741 + function debouncedSearch(query) { 742 + if (debounceTimer) clearTimeout(debounceTimer); 743 + if (query.length < MIN_QUERY) { renderSuggestions([]); return; } 744 + debounceTimer = setTimeout(function() { 745 + searchHandles(query).then(function(results) { 746 + currentResults = results; 747 + activeIndex = -1; 748 + renderSuggestions(results); 749 + }); 750 + }, DEBOUNCE_MS); 751 + } 752 + 753 + async function resolve(raw) { 754 + var v = (raw || '').replace(/^@/, '').trim(); 755 + if (!v) { setHint('', ''); didField.value = ''; return; } 756 + if (isDID(v)) { setHint(v, 'is-ok'); didField.value = v; return; } 757 + setHint('Resolving ' + v + '…', 'is-loading'); 590 758 didField.value = ''; 591 - setHint('', ''); 759 + try { 760 + var r = await fetch('/enroll/resolve?handle=' + encodeURIComponent(v), { 761 + headers: { Accept: 'application/json' }, 762 + }); 763 + if (!r.ok) { 764 + var body = await r.json().catch(function() { return {error:'resolution failed'}; }); 765 + setHint(body.error || 'resolution failed', 'is-err'); 766 + return; 767 + } 768 + var data = await r.json(); 769 + if (data.did) { 770 + setHint(data.did, 'is-ok'); 771 + didField.value = data.did; 772 + } 773 + } catch (e) { 774 + setHint('Network error — try again or paste a DID directly', 'is-err'); 775 + } 592 776 } 593 - }); 777 + 778 + identity.addEventListener('input', function() { 779 + var q = identity.value.trim().replace(/^@/, ''); 780 + if (didField.value) { didField.value = ''; setHint('', ''); } 781 + if (isDID(q)) { 782 + renderSuggestions([]); 783 + setHint(q, 'is-ok'); 784 + didField.value = q; 785 + return; 786 + } 787 + debouncedSearch(q); 788 + }); 789 + 790 + identity.addEventListener('keydown', function(e) { 791 + if (!currentResults.length) return; 792 + if (e.key === 'ArrowDown') { 793 + e.preventDefault(); 794 + activeIndex = Math.min(activeIndex + 1, currentResults.length - 1); 795 + updateActive(); 796 + } else if (e.key === 'ArrowUp') { 797 + e.preventDefault(); 798 + activeIndex = Math.max(activeIndex - 1, 0); 799 + updateActive(); 800 + } else if (e.key === 'Enter' && activeIndex >= 0) { 801 + e.preventDefault(); 802 + e.stopPropagation(); 803 + selectHandle(currentResults[activeIndex].handle); 804 + } else if (e.key === 'Escape') { 805 + dropdown.style.display = 'none'; 806 + identity.setAttribute('aria-expanded', 'false'); 807 + activeIndex = -1; 808 + } 809 + }); 594 810 595 - form.addEventListener('submit', async (ev) => { 596 - if (didField.value) return; // already resolved 597 - ev.preventDefault(); 598 - submit.disabled = true; 599 - submit.textContent = 'Resolving identity…'; 600 - await resolve(identity.value); 601 - submit.disabled = false; 602 - submit.textContent = 'Start enrollment'; 603 - if (didField.value) form.submit(); 604 - }); 811 + dropdown.addEventListener('mousedown', function(e) { 812 + e.preventDefault(); 813 + var target = e.target.closest('.handle-suggestion'); 814 + if (target) selectHandle(target.dataset.handle); 815 + }); 605 816 606 - })(); 607 - </script> 608 - </section> 817 + identity.addEventListener('blur', function() { 818 + setTimeout(function() { 819 + dropdown.style.display = 'none'; 820 + identity.setAttribute('aria-expanded', 'false'); 821 + }, 150); 822 + if (!didField.value) resolve(identity.value); 823 + }); 824 + 825 + form.addEventListener('submit', async function(ev) { 826 + if (didField.value) return; 827 + ev.preventDefault(); 828 + submit.disabled = true; 829 + submit.textContent = 'Resolving identity…'; 830 + await resolve(identity.value); 831 + submit.disabled = false; 832 + submit.textContent = submit.getAttribute('data-default-text') || 'Start enrollment'; 833 + if (didField.value) form.submit(); 834 + }); 835 + })(); 836 + </script> 837 + </section> 838 + } else { 839 + <section class="section" style="margin-top: 1.25rem; padding-top: 0.75rem;"> 840 + <div class="credential" style="margin-top: 0; margin-bottom: 1.5rem;"> 841 + <div class="credential-label">Verified identity</div> 842 + <p style="margin: 0.5rem 0 0; font-family: 'JetBrains Mono', monospace; font-size: var(--t-s); word-break: break-all;"> 843 + if authHandle != "" && authHandle != authDID { 844 + { "@" + authHandle }<br/> 845 + } 846 + <span style="color: var(--muted); font-size: var(--t-xs);">{ authDID }</span> 847 + </p> 848 + </div> 849 + 850 + if len(existingDomains) > 0 { 851 + <div class="credential" style="margin-top: 0; margin-bottom: 1.5rem;"> 852 + <div class="credential-label">Your enrolled domains</div> 853 + for _, d := range existingDomains { 854 + <p style="margin: 0.5rem 0 0; font-family: 'JetBrains Mono', monospace; font-size: var(--t-s);">{ d }</p> 855 + } 856 + </div> 857 + } 858 + 859 + if len(existingDomains) >= 2 { 860 + <p class="lede" style="margin-bottom: 1.25rem;"> 861 + You've reached the maximum of 2 sending domains for this alpha. 862 + <a href="/enroll/manage">Manage your account</a> to view DKIM records, rotate your API key, or update your contact email. 863 + </p> 864 + } else if len(existingDomains) == 1 { 865 + <p class="lede" style="margin-bottom: 1.25rem;"> 866 + You can add one more sending domain during this alpha. 867 + </p> 868 + 869 + <form action="/enroll/start" method="POST"> 870 + <label for="domain">Sending domain</label> 871 + <small>The domain that appears after the @ in emails sent through the relay — e.g. <code>example.com</code> or <code>mail.example.com</code>. Must be a domain you control; you'll add a DNS record to verify ownership.</small> 872 + <input 873 + type="text" 874 + id="domain" 875 + name="domain" 876 + placeholder="example.com" 877 + required 878 + pattern="[a-z0-9][a-z0-9\-.]*\.[a-z]{2,}" 879 + autocomplete="off" 880 + spellcheck="false" 881 + autocapitalize="off" 882 + /> 883 + 884 + <label for="contact_email">Contact email</label> 885 + <small>Where we'll send your approval notice and any account notifications. This can be any email you check — it doesn't need to be at your sending domain. Stored privately; never displayed publicly.</small> 886 + <input 887 + type="email" 888 + id="contact_email" 889 + name="contact_email" 890 + placeholder="you@example.com" 891 + required 892 + autocomplete="email" 893 + spellcheck="false" 894 + autocapitalize="off" 895 + /> 896 + 897 + <button type="submit">Add domain →</button> 898 + </form> 899 + } else { 900 + <p class="lede" style="margin-bottom: 1.25rem;"> 901 + Identity verified. Now tell us about the domain you want to send from. 902 + </p> 903 + 904 + <form action="/enroll/start" method="POST"> 905 + <label for="domain">Sending domain</label> 906 + <small>The domain that appears after the @ in emails sent through the relay — e.g. <code>example.com</code> or <code>mail.example.com</code>. Must be a domain you control; you'll add a DNS record to verify ownership.</small> 907 + <input 908 + type="text" 909 + id="domain" 910 + name="domain" 911 + placeholder="example.com" 912 + required 913 + pattern="[a-z0-9][a-z0-9\-.]*\.[a-z]{2,}" 914 + autocomplete="off" 915 + spellcheck="false" 916 + autocapitalize="off" 917 + /> 918 + 919 + <label for="contact_email">Contact email</label> 920 + <small>Where we'll send your approval notice and any account notifications. This can be any email you check — it doesn't need to be at your sending domain. Stored privately; never displayed publicly.</small> 921 + <input 922 + type="email" 923 + id="contact_email" 924 + name="contact_email" 925 + placeholder="you@example.com" 926 + required 927 + autocomplete="email" 928 + spellcheck="false" 929 + autocapitalize="off" 930 + /> 931 + 932 + <button type="submit">Start enrollment →</button> 933 + </form> 934 + } 935 + 936 + <p class="section-lede" style="margin-top: 1rem; margin-bottom: 0;"> 937 + <a href="/enroll/reset">← Use a different account</a> 938 + </p> 939 + if len(existingDomains) > 0 { 940 + <p class="section-lede" style="margin-top: 0.5rem; margin-bottom: 0;"> 941 + <a href="/enroll/manage">Manage your account →</a> 942 + </p> 943 + } 944 + </section> 945 + } 609 946 } 947 + } 948 + 949 + func enrollLandingAction(requireAuth bool) templ.SafeURL { 950 + if requireAuth { 951 + return templ.SafeURL("/enroll/auth") 952 + } 953 + return templ.SafeURL("/enroll/start") 954 + } 955 + 956 + func enrollLandingSubmitText(requireAuth bool) string { 957 + if requireAuth { 958 + return "Verify identity →" 959 + } 960 + return "Start enrollment" 610 961 } 611 962 612 963 // EnrollStep2 shows the DNS TXT record the user needs to publish + a
+397 -221
internal/admin/ui/templates/enroll_templ.go
··· 82 82 if templ_7745c5c3_Err != nil { 83 83 return templ_7745c5c3_Err 84 84 } 85 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"><link rel=\"preconnect\" href=\"https://fonts.googleapis.com\"><link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin><link href=\"https://fonts.googleapis.com/css2?family=Young+Serif&family=Atkinson+Hyperlegible:ital,wght@0,400;0,700;1,400;1,700&display=swap\" rel=\"stylesheet\"><style>\n\t\t\t\t:root {\n\t\t\t\t\t/* OKLCH color tokens — neutrals tinted warm (hue ~70) toward the paper base. */\n\t\t\t\t\t--bg: oklch(0.98 0.005 70);\n\t\t\t\t\t--ink: oklch(0.22 0.02 70);\n\t\t\t\t\t--muted: oklch(0.50 0.01 70);\n\t\t\t\t\t--line: oklch(0.85 0.01 70);\n\t\t\t\t\t--accent: oklch(0.55 0.22 25); /* stamp-red */\n\t\t\t\t\t--accent-ink: oklch(0.38 0.18 25); /* darker for hover/underline */\n\t\t\t\t\t--surface: oklch(1 0 0); /* pure white for credential boxes to contrast paper */\n\n\t\t\t\t\t--font-display: 'Young Serif', 'Iowan Old Style', 'Palatino Linotype', Palatino, serif;\n\t\t\t\t\t--font-body: 'Atkinson Hyperlegible', 'Charter', 'Georgia', serif;\n\n\t\t\t\t\t/* Type scale: 1.25 ratio, fixed rem (product UI, not marketing).\n\t\t\t\t\t Masthead is tuned to fit above the fold on a 720-line\n\t\t\t\t\t laptop without sacrificing the newspaper grammar. */\n\t\t\t\t\t--t-xs: 0.8125rem; /* 13px */\n\t\t\t\t\t--t-s: 0.9375rem; /* 15px */\n\t\t\t\t\t--t-m: 1.0625rem; /* 17px */\n\t\t\t\t\t--t-l: 1.1875rem; /* 19px */\n\t\t\t\t\t--t-xl: 1.375rem; /* 22px */\n\t\t\t\t\t--t-2xl: 2rem; /* 32px */\n\t\t\t\t\t--t-3xl: 3rem; /* 48px — masthead */\n\t\t\t\t}\n\t\t\t\t* { box-sizing: border-box; }\n\t\t\t\thtml, body {\n\t\t\t\t\tmargin: 0;\n\t\t\t\t\tpadding: 0;\n\t\t\t\t\tbackground: var(--bg);\n\t\t\t\t\tcolor: var(--ink);\n\t\t\t\t\tfont-family: var(--font-body);\n\t\t\t\t\tfont-size: var(--t-m);\n\t\t\t\t\tline-height: 1.45;\n\t\t\t\t\t-webkit-font-smoothing: antialiased;\n\t\t\t\t\t-moz-osx-font-smoothing: grayscale;\n\t\t\t\t}\n\t\t\t\t/* Body is a flex column so the footer can stick to the viewport\n\t\t\t\t bottom on short pages (see `footer { margin-top: auto; }` below).\n\t\t\t\t Without this, the footer floats mid-page when the content\n\t\t\t\t column is shorter than the viewport. */\n\t\t\t\thtml { min-height: 100%; }\n\t\t\t\tbody { min-height: 100vh; display: flex; flex-direction: column; }\n\t\t\t\t/* Body links are ink with an accent underline. Reserving stamp-red\n\t\t\t\t for the drop-cap, primary button, and credential callout keeps\n\t\t\t\t the accent color heroic — not stippled across every paragraph. */\n\t\t\t\ta {\n\t\t\t\t\tcolor: var(--ink);\n\t\t\t\t\ttext-decoration: underline;\n\t\t\t\t\ttext-decoration-color: var(--accent);\n\t\t\t\t\ttext-decoration-thickness: 1.5px;\n\t\t\t\t\ttext-underline-offset: 3px;\n\t\t\t\t}\n\t\t\t\ta:hover {\n\t\t\t\t\tcolor: var(--accent-ink);\n\t\t\t\t\ttext-decoration-color: var(--accent-ink);\n\t\t\t\t}\n\t\t\t\tcode {\n\t\t\t\t\tfont-family: 'JetBrains Mono', 'Menlo', 'Consolas', monospace;\n\t\t\t\t\tfont-size: 0.95em;\n\t\t\t\t\t/* Pure white fill against warm paper — same treatment\n\t\t\t\t\t as <pre> and credential boxes. Reads as \"data chit\n\t\t\t\t\t on stationery\" rather than a bordered same-color\n\t\t\t\t\t region that blurs into prose. */\n\t\t\t\t\tbackground: var(--surface);\n\t\t\t\t\tpadding: 0 0.25em;\n\t\t\t\t\tborder: 1px solid var(--line);\n\t\t\t\t\tborder-radius: 2px;\n\t\t\t\t}\n\t\t\t\tpre {\n\t\t\t\t\tfont-family: 'JetBrains Mono', 'Menlo', 'Consolas', monospace;\n\t\t\t\t\tfont-size: 0.9em;\n\t\t\t\t\tbackground: var(--surface);\n\t\t\t\t\tcolor: var(--ink);\n\t\t\t\t\tborder: 1px solid var(--line);\n\t\t\t\t\tpadding: 1rem 1.25rem;\n\t\t\t\t\tborder-radius: 2px;\n\t\t\t\t\toverflow-x: auto;\n\t\t\t\t\twhite-space: pre-wrap;\n\t\t\t\t\tword-break: break-all;\n\t\t\t\t\tline-height: 1.5;\n\t\t\t\t}\n\t\t\t\tpre code { background: none; border: none; padding: 0; }\n\n\t\t\t\t/* Top-of-page nav — subtle home link so every page can\n\t\t\t\t return to the marketing landing with one click. Lives\n\t\t\t\t above the masthead; typography-sized so it doesn't\n\t\t\t\t visually compete with the section mastheads below. */\n\t\t\t\t.topnav {\n\t\t\t\t\tfont-family: var(--font-body);\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\ttext-transform: uppercase;\n\t\t\t\t\tletter-spacing: 0.12em;\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\tmargin-bottom: 1.5rem;\n\t\t\t\t\tpadding-bottom: 0.5rem;\n\t\t\t\t\tborder-bottom: 1px solid var(--line);\n\t\t\t\t}\n\t\t\t\t.topnav-home {\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\ttext-decoration: none;\n\t\t\t\t\tfont-weight: 700;\n\t\t\t\t}\n\t\t\t\t.topnav-home:hover {\n\t\t\t\t\tcolor: var(--accent);\n\t\t\t\t}\n\n\t\t\t\t.page {\n\t\t\t\t\tmax-width: 680px;\n\t\t\t\t\twidth: 100%;\n\t\t\t\t\tmargin: 0 auto;\n\t\t\t\t\tpadding: 2rem 2rem 2.5rem;\n\t\t\t\t\t/* Flex column so the footer can grow to fill vertical space\n\t\t\t\t\t via `margin-top: auto`, pinning it to the viewport bottom\n\t\t\t\t\t on short pages. */\n\t\t\t\t\tflex: 1 0 auto;\n\t\t\t\t\tdisplay: flex;\n\t\t\t\t\tflex-direction: column;\n\t\t\t\t}\n\n\t\t\t\t/* Masthead — deliberately large, newspaper-style. The leading 'A'\n\t\t\t\t of \"Atmosphere\" gets the accent color; the rest is ink. */\n\t\t\t\t.masthead {\n\t\t\t\t\tfont-family: var(--font-display);\n\t\t\t\t\tfont-size: var(--t-3xl);\n\t\t\t\t\tline-height: 1;\n\t\t\t\t\tletter-spacing: -0.03em;\n\t\t\t\t\tmargin: 0 0 0.5rem;\n\t\t\t\t\tcolor: var(--ink);\n\t\t\t\t}\n\t\t\t\t.masthead .drop {\n\t\t\t\t\tcolor: var(--accent);\n\t\t\t\t}\n\t\t\t\t/* Sub-page masthead — smaller, no drop-cap. The drop-cap is reserved\n\t\t\t\t for the landing's brand mark; pages like /terms /privacy /aup /about\n\t\t\t\t are reference docs that cede visual authority to the landing. */\n\t\t\t\t.masthead-sub {\n\t\t\t\t\tfont-size: var(--t-2xl);\n\t\t\t\t\tmargin-bottom: 0.25rem;\n\t\t\t\t}\n\t\t\t\t/* Effective-date marginalia — the typographic convention for legal\n\t\t\t\t documents. Sits directly under the title in small-caps, before\n\t\t\t\t the lede. Replaces the awkward \"Effective X. Lorem ipsum...\"\n\t\t\t\t sentence-opener pattern used in the first draft. */\n\t\t\t\t.effective {\n\t\t\t\t\tfont-family: var(--font-body);\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\tfont-weight: 700;\n\t\t\t\t\ttext-transform: uppercase;\n\t\t\t\t\tletter-spacing: 0.12em;\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\tmargin: 0 0 1.25rem;\n\t\t\t\t}\n\t\t\t\t.lede {\n\t\t\t\t\tfont-family: var(--font-body);\n\t\t\t\t\tfont-style: italic;\n\t\t\t\t\tfont-size: var(--t-l);\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\tline-height: 1.4;\n\t\t\t\t\tmargin: 0 0 1rem;\n\t\t\t\t\tmax-width: 32em;\n\t\t\t\t}\n\n\t\t\t\t/* Section heading — Young Serif, smaller than masthead, with a\n\t\t\t\t hairline rule above. Evokes a typeset page break. */\n\t\t\t\t.section {\n\t\t\t\t\tmargin-top: 1.75rem;\n\t\t\t\t\tpadding-top: 1rem;\n\t\t\t\t\tborder-top: 1px solid var(--line);\n\t\t\t\t}\n\t\t\t\t.section h2 {\n\t\t\t\t\tfont-family: var(--font-display);\n\t\t\t\t\tfont-size: var(--t-xl);\n\t\t\t\t\tfont-weight: 400;\n\t\t\t\t\tletter-spacing: -0.01em;\n\t\t\t\t\tmargin: 0 0 0.35rem;\n\t\t\t\t}\n\t\t\t\t.section p {\n\t\t\t\t\tmargin: 0.35rem 0 0.6rem;\n\t\t\t\t}\n\t\t\t\t.section-lede {\n\t\t\t\t\tfont-size: var(--t-s);\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\tmargin: 0 0 1rem;\n\t\t\t\t\tmax-width: 36em;\n\t\t\t\t}\n\n\t\t\t\t/* Step number — small-caps marginalia, NOT a boxed card number. */\n\t\t\t\t.step-marker {\n\t\t\t\t\tdisplay: block;\n\t\t\t\t\tfont-family: var(--font-body);\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\tfont-weight: 700;\n\t\t\t\t\ttext-transform: uppercase;\n\t\t\t\t\tletter-spacing: 0.12em;\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\tmargin-bottom: 0.25rem;\n\t\t\t\t}\n\n\t\t\t\t/* Form — boxed fields on white surface. Earlier iterations used\n\t\t\t\t hairline-underline inputs (no box, just a bottom border), but\n\t\t\t\t those collided visually with the section dividers and readers\n\t\t\t\t couldn't tell what was input vs. structure. A subtle surface\n\t\t\t\t fill + 1px frame makes \"this is a typeable field\" unambiguous\n\t\t\t\t without dragging the page toward a generic webform aesthetic. */\n\t\t\t\tlabel {\n\t\t\t\t\tdisplay: block;\n\t\t\t\t\tfont-size: var(--t-s);\n\t\t\t\t\tfont-weight: 700;\n\t\t\t\t\tmargin-top: 1rem;\n\t\t\t\t\tmargin-bottom: 0.25rem;\n\t\t\t\t}\n\t\t\t\tlabel + small {\n\t\t\t\t\tdisplay: block;\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\tmargin-bottom: 0.5rem;\n\t\t\t\t}\n\t\t\t\tinput[type=text],\n\t\t\t\tinput[type=email],\n\t\t\t\ttextarea {\n\t\t\t\t\tdisplay: block;\n\t\t\t\t\twidth: 100%;\n\t\t\t\t\tfont-family: inherit;\n\t\t\t\t\tfont-size: var(--t-m);\n\t\t\t\t\tcolor: var(--ink);\n\t\t\t\t\tbackground: var(--surface);\n\t\t\t\t\tborder: 1px solid var(--line);\n\t\t\t\t\tborder-radius: 2px;\n\t\t\t\t\tpadding: 0.6rem 0.75rem;\n\t\t\t\t\tmargin-bottom: 0.5rem;\n\t\t\t\t\toutline: none;\n\t\t\t\t\ttransition: border-color 120ms ease, box-shadow 120ms ease;\n\t\t\t\t}\n\t\t\t\tinput[type=text]:focus,\n\t\t\t\tinput[type=email]:focus,\n\t\t\t\ttextarea:focus {\n\t\t\t\t\tborder-color: var(--ink);\n\t\t\t\t\tbox-shadow: inset 0 -2px 0 0 var(--accent);\n\t\t\t\t}\n\t\t\t\ttextarea {\n\t\t\t\t\tresize: vertical;\n\t\t\t\t\tline-height: 1.4;\n\t\t\t\t\tfont-family: 'JetBrains Mono', 'Menlo', monospace;\n\t\t\t\t\tfont-size: var(--t-s);\n\t\t\t\t}\n\t\t\t\t/* Buttons and button-styled links share one base. .btn-secondary\n\t\t\t\t is the ghost variant — muted, reserved for withdrawal\n\t\t\t\t actions (sign out) and secondary CTAs (sign in next to\n\t\t\t\t \"Enroll a domain\"). Keeping them as class variants on a\n\t\t\t\t single base means the aesthetic stays consistent when we\n\t\t\t\t change padding or weight in one place. */\n\t\t\t\tbutton,\n\t\t\t\t.btn {\n\t\t\t\t\tdisplay: inline-block;\n\t\t\t\t\tfont-family: inherit;\n\t\t\t\t\tfont-size: var(--t-s);\n\t\t\t\t\tfont-weight: 700;\n\t\t\t\t\tletter-spacing: 0.02em;\n\t\t\t\t\tcolor: var(--bg);\n\t\t\t\t\tbackground: var(--ink);\n\t\t\t\t\tborder: 1px solid var(--ink);\n\t\t\t\t\tpadding: 0.65rem 1.5rem;\n\t\t\t\t\tmargin-top: 1.25rem;\n\t\t\t\t\tcursor: pointer;\n\t\t\t\t\ttext-decoration: none;\n\t\t\t\t\ttransition: background 120ms ease, color 120ms ease, border-color 120ms ease;\n\t\t\t\t}\n\t\t\t\t.btn-secondary,\n\t\t\t\tbutton.btn-secondary {\n\t\t\t\t\tcolor: var(--ink);\n\t\t\t\t\tbackground: transparent;\n\t\t\t\t\tborder-color: var(--line);\n\t\t\t\t}\n\t\t\t\tbutton:hover,\n\t\t\t\t.btn:hover {\n\t\t\t\t\tbackground: var(--accent);\n\t\t\t\t\tborder-color: var(--accent);\n\t\t\t\t\tcolor: var(--bg);\n\t\t\t\t}\n\t\t\t\t.btn-secondary:hover,\n\t\t\t\tbutton.btn-secondary:hover {\n\t\t\t\t\tbackground: var(--ink);\n\t\t\t\t\tborder-color: var(--ink);\n\t\t\t\t\tcolor: var(--bg);\n\t\t\t\t}\n\n\t\t\t\t/* Credential box — inverse of the page (surface white on paper).\n\t\t\t\t This is the ONE boxed element on the success page; everything\n\t\t\t\t else is just typography. Makes the API key impossible to miss. */\n\t\t\t\t.credential {\n\t\t\t\t\tmargin: 1.5rem 0;\n\t\t\t\t\tpadding: 1.25rem 1.5rem;\n\t\t\t\t\tbackground: var(--surface);\n\t\t\t\t\tborder: 1px solid var(--line);\n\t\t\t\t\tborder-left: 3px solid var(--accent);\n\t\t\t\t}\n\t\t\t\t.credential-label {\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\tfont-weight: 700;\n\t\t\t\t\ttext-transform: uppercase;\n\t\t\t\t\tletter-spacing: 0.12em;\n\t\t\t\t\tcolor: var(--accent);\n\t\t\t\t\tmargin-bottom: 0.5rem;\n\t\t\t\t}\n\t\t\t\t.credential-note {\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\tfont-style: italic;\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\tmargin-top: 0.75rem;\n\t\t\t\t}\n\n\t\t\t\t.dns-block {\n\t\t\t\t\tmargin: 1.25rem 0;\n\t\t\t\t}\n\t\t\t\t.dns-block-label {\n\t\t\t\t\tfont-family: 'JetBrains Mono', monospace;\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\tmargin-bottom: 0.25rem;\n\t\t\t\t}\n\n\t\t\t\t.bullets {\n\t\t\t\t\tmargin: 0.5rem 0;\n\t\t\t\t\tpadding-left: 1.25rem;\n\t\t\t\t}\n\t\t\t\t.bullets li {\n\t\t\t\t\tmargin-bottom: 0.25rem;\n\t\t\t\t}\n\n\t\t\t\t/* Footer — light, one line, hairline rule. `margin-top: auto`\n\t\t\t\t on a flex child in a column container pushes the footer to\n\t\t\t\t the bottom of whatever space is left, so it sticks to the\n\t\t\t\t viewport bottom on short pages. The 2.25rem minimum keeps\n\t\t\t\t a comfortable gap from tall content pages (Terms, Privacy)\n\t\t\t\t because `auto` collapses to 0 when the flex parent is\n\t\t\t\t already at or beyond its main size. */\n\t\t\t\tfooter {\n\t\t\t\t\tmargin-top: auto;\n\t\t\t\t\tpadding-top: 1rem;\n\t\t\t\t\tborder-top: 1px solid var(--line);\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\ttext-align: left;\n\t\t\t\t\tline-height: 1.6;\n\t\t\t\t}\n\t\t\t\t/* Reserve breathing room above the footer when content is tall.\n\t\t\t\t `margin-top: auto` alone would push the footer against the\n\t\t\t\t content when the page overflows; this gives it its historical\n\t\t\t\t 2.25rem gap on long pages. */\n\t\t\t\tfooter::before {\n\t\t\t\t\tcontent: \"\";\n\t\t\t\t\tdisplay: block;\n\t\t\t\t\theight: 2.25rem;\n\t\t\t\t}\n\t\t\t\tfooter a { color: var(--muted); text-decoration-color: var(--line); }\n\t\t\t\tfooter a:hover { color: var(--ink); text-decoration-color: var(--ink); }\n\n\t\t\t\t/* Error state — same visual grammar, accent underlines the issue. */\n\t\t\t\t.error-note {\n\t\t\t\t\tmargin: 2rem 0;\n\t\t\t\t\tpadding: 1rem 1.25rem;\n\t\t\t\t\tborder-left: 3px solid var(--accent);\n\t\t\t\t\tbackground: var(--surface);\n\t\t\t\t\tcolor: var(--ink);\n\t\t\t\t\tfont-size: var(--t-s);\n\t\t\t\t}\n\n\t\t\t\t/* Resolver hint — small inline feedback below the identity input\n\t\t\t\t while a handle is being resolved to its DID. */\n\t\t\t\t.resolver-hint {\n\t\t\t\t\tdisplay: none;\n\t\t\t\t\tfont-family: 'JetBrains Mono', 'Menlo', monospace;\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\tmargin: 0.25rem 0 0.5rem;\n\t\t\t\t\tline-height: 1.4;\n\t\t\t\t}\n\t\t\t\t.resolver-hint.is-loading { display: block; color: var(--muted); font-style: italic; font-family: var(--font-body); }\n\t\t\t\t.resolver-hint.is-ok { display: block; color: var(--ink); }\n\t\t\t\t.resolver-hint.is-ok::before { content: \"→ \"; color: var(--accent); font-weight: 700; }\n\t\t\t\t.resolver-hint.is-err { display: block; color: var(--accent-ink); }\n\t\t\t\t.resolver-hint.is-err::before { content: \"⚠ \"; }\n\n\t\t\t\t/* Mobile tightening — the 680px reading column + 2rem padding\n\t\t\t\t already mostly handles this, but at narrow widths the\n\t\t\t\t masthead is too big and forms get cramped. */\n\t\t\t\t@media (max-width: 520px) {\n\t\t\t\t\t.page { padding: 1.5rem 1.25rem 2rem; }\n\t\t\t\t\t.masthead { font-size: 2.25rem; }\n\t\t\t\t\t/* Sub-page masthead must stay smaller than the landing masthead\n\t\t\t\t\t on mobile too — without this rule, the later .masthead size\n\t\t\t\t\t wins by source order (same specificity) and the Round 2\n\t\t\t\t\t landing-only drop-cap motif dilutes on phones. */\n\t\t\t\t\t.masthead-sub { font-size: 1.625rem; }\n\t\t\t\t\t.lede { font-size: var(--t-m); margin-bottom: 1rem; }\n\t\t\t\t\t.section { margin-top: 1.25rem; padding-top: 0.75rem; }\n\t\t\t\t}\n\t\t\t</style></head><body><main class=\"page\">") 85 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"><link rel=\"preconnect\" href=\"https://fonts.googleapis.com\"><link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin><link href=\"https://fonts.googleapis.com/css2?family=Young+Serif&family=Atkinson+Hyperlegible:ital,wght@0,400;0,700;1,400;1,700&display=swap\" rel=\"stylesheet\"><style>\n\t\t\t\t:root {\n\t\t\t\t\t/* OKLCH color tokens — neutrals tinted warm (hue ~70) toward the paper base. */\n\t\t\t\t\t--bg: oklch(0.98 0.005 70);\n\t\t\t\t\t--ink: oklch(0.22 0.02 70);\n\t\t\t\t\t--muted: oklch(0.50 0.01 70);\n\t\t\t\t\t--line: oklch(0.85 0.01 70);\n\t\t\t\t\t--accent: oklch(0.55 0.22 25); /* stamp-red */\n\t\t\t\t\t--accent-ink: oklch(0.38 0.18 25); /* darker for hover/underline */\n\t\t\t\t\t--surface: oklch(1 0 0); /* pure white for credential boxes to contrast paper */\n\n\t\t\t\t\t--font-display: 'Young Serif', 'Iowan Old Style', 'Palatino Linotype', Palatino, serif;\n\t\t\t\t\t--font-body: 'Atkinson Hyperlegible', 'Charter', 'Georgia', serif;\n\n\t\t\t\t\t/* Type scale: 1.25 ratio, fixed rem (product UI, not marketing).\n\t\t\t\t\t Masthead is tuned to fit above the fold on a 720-line\n\t\t\t\t\t laptop without sacrificing the newspaper grammar. */\n\t\t\t\t\t--t-xs: 0.8125rem; /* 13px */\n\t\t\t\t\t--t-s: 0.9375rem; /* 15px */\n\t\t\t\t\t--t-m: 1.0625rem; /* 17px */\n\t\t\t\t\t--t-l: 1.1875rem; /* 19px */\n\t\t\t\t\t--t-xl: 1.375rem; /* 22px */\n\t\t\t\t\t--t-2xl: 2rem; /* 32px */\n\t\t\t\t\t--t-3xl: 3rem; /* 48px — masthead */\n\t\t\t\t}\n\t\t\t\t* { box-sizing: border-box; }\n\t\t\t\thtml, body {\n\t\t\t\t\tmargin: 0;\n\t\t\t\t\tpadding: 0;\n\t\t\t\t\tbackground: var(--bg);\n\t\t\t\t\tcolor: var(--ink);\n\t\t\t\t\tfont-family: var(--font-body);\n\t\t\t\t\tfont-size: var(--t-m);\n\t\t\t\t\tline-height: 1.45;\n\t\t\t\t\t-webkit-font-smoothing: antialiased;\n\t\t\t\t\t-moz-osx-font-smoothing: grayscale;\n\t\t\t\t}\n\t\t\t\t/* Body is a flex column so the footer can stick to the viewport\n\t\t\t\t bottom on short pages (see `footer { margin-top: auto; }` below).\n\t\t\t\t Without this, the footer floats mid-page when the content\n\t\t\t\t column is shorter than the viewport. */\n\t\t\t\thtml { min-height: 100%; }\n\t\t\t\tbody { min-height: 100vh; display: flex; flex-direction: column; }\n\t\t\t\t/* Body links are ink with an accent underline. Reserving stamp-red\n\t\t\t\t for the drop-cap, primary button, and credential callout keeps\n\t\t\t\t the accent color heroic — not stippled across every paragraph. */\n\t\t\t\ta {\n\t\t\t\t\tcolor: var(--ink);\n\t\t\t\t\ttext-decoration: underline;\n\t\t\t\t\ttext-decoration-color: var(--accent);\n\t\t\t\t\ttext-decoration-thickness: 1.5px;\n\t\t\t\t\ttext-underline-offset: 3px;\n\t\t\t\t}\n\t\t\t\ta:hover {\n\t\t\t\t\tcolor: var(--accent-ink);\n\t\t\t\t\ttext-decoration-color: var(--accent-ink);\n\t\t\t\t}\n\t\t\t\tcode {\n\t\t\t\t\tfont-family: 'JetBrains Mono', 'Menlo', 'Consolas', monospace;\n\t\t\t\t\tfont-size: 0.95em;\n\t\t\t\t\t/* Pure white fill against warm paper — same treatment\n\t\t\t\t\t as <pre> and credential boxes. Reads as \"data chit\n\t\t\t\t\t on stationery\" rather than a bordered same-color\n\t\t\t\t\t region that blurs into prose. */\n\t\t\t\t\tbackground: var(--surface);\n\t\t\t\t\tpadding: 0 0.25em;\n\t\t\t\t\tborder: 1px solid var(--line);\n\t\t\t\t\tborder-radius: 2px;\n\t\t\t\t}\n\t\t\t\tpre {\n\t\t\t\t\tfont-family: 'JetBrains Mono', 'Menlo', 'Consolas', monospace;\n\t\t\t\t\tfont-size: 0.9em;\n\t\t\t\t\tbackground: var(--surface);\n\t\t\t\t\tcolor: var(--ink);\n\t\t\t\t\tborder: 1px solid var(--line);\n\t\t\t\t\tpadding: 1rem 1.25rem;\n\t\t\t\t\tborder-radius: 2px;\n\t\t\t\t\toverflow-x: auto;\n\t\t\t\t\twhite-space: pre-wrap;\n\t\t\t\t\tword-break: break-all;\n\t\t\t\t\tline-height: 1.5;\n\t\t\t\t}\n\t\t\t\tpre code { background: none; border: none; padding: 0; }\n\n\t\t\t\t/* Top-of-page nav — subtle home link so every page can\n\t\t\t\t return to the marketing landing with one click. Lives\n\t\t\t\t above the masthead; typography-sized so it doesn't\n\t\t\t\t visually compete with the section mastheads below. */\n\t\t\t\t.topnav {\n\t\t\t\t\tfont-family: var(--font-body);\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\ttext-transform: uppercase;\n\t\t\t\t\tletter-spacing: 0.12em;\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\tmargin-bottom: 1.5rem;\n\t\t\t\t\tpadding-bottom: 0.5rem;\n\t\t\t\t\tborder-bottom: 1px solid var(--line);\n\t\t\t\t}\n\t\t\t\t.topnav-home {\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\ttext-decoration: none;\n\t\t\t\t\tfont-weight: 700;\n\t\t\t\t}\n\t\t\t\t.topnav-home:hover {\n\t\t\t\t\tcolor: var(--accent);\n\t\t\t\t}\n\n\t\t\t\t.page {\n\t\t\t\t\tmax-width: 680px;\n\t\t\t\t\twidth: 100%;\n\t\t\t\t\tmargin: 0 auto;\n\t\t\t\t\tpadding: 2rem 2rem 2.5rem;\n\t\t\t\t\t/* Flex column so the footer can grow to fill vertical space\n\t\t\t\t\t via `margin-top: auto`, pinning it to the viewport bottom\n\t\t\t\t\t on short pages. */\n\t\t\t\t\tflex: 1 0 auto;\n\t\t\t\t\tdisplay: flex;\n\t\t\t\t\tflex-direction: column;\n\t\t\t\t}\n\n\t\t\t\t/* Masthead — deliberately large, newspaper-style. The leading 'A'\n\t\t\t\t of \"Atmosphere\" gets the accent color; the rest is ink. */\n\t\t\t\t.masthead {\n\t\t\t\t\tfont-family: var(--font-display);\n\t\t\t\t\tfont-size: var(--t-3xl);\n\t\t\t\t\tline-height: 1;\n\t\t\t\t\tletter-spacing: -0.03em;\n\t\t\t\t\tmargin: 0 0 0.5rem;\n\t\t\t\t\tcolor: var(--ink);\n\t\t\t\t}\n\t\t\t\t.masthead .drop {\n\t\t\t\t\tcolor: var(--accent);\n\t\t\t\t}\n\t\t\t\t/* Sub-page masthead — smaller, no drop-cap. The drop-cap is reserved\n\t\t\t\t for the landing's brand mark; pages like /terms /privacy /aup /about\n\t\t\t\t are reference docs that cede visual authority to the landing. */\n\t\t\t\t.masthead-sub {\n\t\t\t\t\tfont-size: var(--t-2xl);\n\t\t\t\t\tmargin-bottom: 0.25rem;\n\t\t\t\t}\n\t\t\t\t/* Effective-date marginalia — the typographic convention for legal\n\t\t\t\t documents. Sits directly under the title in small-caps, before\n\t\t\t\t the lede. Replaces the awkward \"Effective X. Lorem ipsum...\"\n\t\t\t\t sentence-opener pattern used in the first draft. */\n\t\t\t\t.effective {\n\t\t\t\t\tfont-family: var(--font-body);\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\tfont-weight: 700;\n\t\t\t\t\ttext-transform: uppercase;\n\t\t\t\t\tletter-spacing: 0.12em;\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\tmargin: 0 0 1.25rem;\n\t\t\t\t}\n\t\t\t\t.lede {\n\t\t\t\t\tfont-family: var(--font-body);\n\t\t\t\t\tfont-style: italic;\n\t\t\t\t\tfont-size: var(--t-l);\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\tline-height: 1.4;\n\t\t\t\t\tmargin: 0 0 1rem;\n\t\t\t\t\tmax-width: 32em;\n\t\t\t\t}\n\n\t\t\t\t/* Section heading — Young Serif, smaller than masthead, with a\n\t\t\t\t hairline rule above. Evokes a typeset page break. */\n\t\t\t\t.section {\n\t\t\t\t\tmargin-top: 1.75rem;\n\t\t\t\t\tpadding-top: 1rem;\n\t\t\t\t\tborder-top: 1px solid var(--line);\n\t\t\t\t}\n\t\t\t\t.section h2 {\n\t\t\t\t\tfont-family: var(--font-display);\n\t\t\t\t\tfont-size: var(--t-xl);\n\t\t\t\t\tfont-weight: 400;\n\t\t\t\t\tletter-spacing: -0.01em;\n\t\t\t\t\tmargin: 0 0 0.35rem;\n\t\t\t\t}\n\t\t\t\t.section p {\n\t\t\t\t\tmargin: 0.35rem 0 0.6rem;\n\t\t\t\t}\n\t\t\t\t.section-lede {\n\t\t\t\t\tfont-size: var(--t-s);\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\tmargin: 0 0 1rem;\n\t\t\t\t\tmax-width: 36em;\n\t\t\t\t}\n\n\t\t\t\t/* Step number — small-caps marginalia, NOT a boxed card number. */\n\t\t\t\t.step-marker {\n\t\t\t\t\tdisplay: block;\n\t\t\t\t\tfont-family: var(--font-body);\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\tfont-weight: 700;\n\t\t\t\t\ttext-transform: uppercase;\n\t\t\t\t\tletter-spacing: 0.12em;\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\tmargin-bottom: 0.25rem;\n\t\t\t\t}\n\n\t\t\t\t/* Form — boxed fields on white surface. Earlier iterations used\n\t\t\t\t hairline-underline inputs (no box, just a bottom border), but\n\t\t\t\t those collided visually with the section dividers and readers\n\t\t\t\t couldn't tell what was input vs. structure. A subtle surface\n\t\t\t\t fill + 1px frame makes \"this is a typeable field\" unambiguous\n\t\t\t\t without dragging the page toward a generic webform aesthetic. */\n\t\t\t\tlabel {\n\t\t\t\t\tdisplay: block;\n\t\t\t\t\tfont-size: var(--t-s);\n\t\t\t\t\tfont-weight: 700;\n\t\t\t\t\tmargin-top: 1rem;\n\t\t\t\t\tmargin-bottom: 0.25rem;\n\t\t\t\t}\n\t\t\t\tlabel + small {\n\t\t\t\t\tdisplay: block;\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\tmargin-bottom: 0.5rem;\n\t\t\t\t}\n\t\t\t\tinput[type=text],\n\t\t\t\tinput[type=email],\n\t\t\t\ttextarea {\n\t\t\t\t\tdisplay: block;\n\t\t\t\t\twidth: 100%;\n\t\t\t\t\tfont-family: inherit;\n\t\t\t\t\tfont-size: var(--t-m);\n\t\t\t\t\tcolor: var(--ink);\n\t\t\t\t\tbackground: var(--surface);\n\t\t\t\t\tborder: 1px solid var(--line);\n\t\t\t\t\tborder-radius: 2px;\n\t\t\t\t\tpadding: 0.6rem 0.75rem;\n\t\t\t\t\tmargin-bottom: 0.5rem;\n\t\t\t\t\toutline: none;\n\t\t\t\t\ttransition: border-color 120ms ease, box-shadow 120ms ease;\n\t\t\t\t}\n\t\t\t\tinput[type=text]:focus,\n\t\t\t\tinput[type=email]:focus,\n\t\t\t\ttextarea:focus {\n\t\t\t\t\tborder-color: var(--ink);\n\t\t\t\t\tbox-shadow: inset 0 -2px 0 0 var(--accent);\n\t\t\t\t}\n\t\t\t\t.handle-input-wrapper {\n\t\t\t\t\tposition: relative;\n\t\t\t\t}\n\t\t\t\t.handle-input-wrapper input[type=text] {\n\t\t\t\t\tpadding-left: 1.75rem;\n\t\t\t\t}\n\t\t\t\t.handle-input-wrapper::before {\n\t\t\t\t\tcontent: \"@\";\n\t\t\t\t\tposition: absolute;\n\t\t\t\t\tleft: 0.75rem;\n\t\t\t\t\ttop: 0.6rem;\n\t\t\t\t\tfont-family: 'JetBrains Mono', 'Menlo', monospace;\n\t\t\t\t\tfont-size: var(--t-m);\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\tpointer-events: none;\n\t\t\t\t\tz-index: 1;\n\t\t\t\t}\n\t\t\t\t.handle-suggestions {\n\t\t\t\t\tposition: absolute;\n\t\t\t\t\tleft: 0;\n\t\t\t\t\tright: 0;\n\t\t\t\t\tbottom: 100%;\n\t\t\t\t\tbackground: var(--ink);\n\t\t\t\t\tcolor: var(--bg);\n\t\t\t\t\tborder-radius: 2px 2px 0 0;\n\t\t\t\t\tz-index: 10;\n\t\t\t\t\tmax-height: 260px;\n\t\t\t\t\toverflow-y: auto;\n\t\t\t\t\tbox-shadow: 0 -4px 16px rgba(0,0,0,0.15);\n\t\t\t\t}\n\t\t\t\t.handle-suggestion {\n\t\t\t\t\tdisplay: flex;\n\t\t\t\t\talign-items: center;\n\t\t\t\t\tgap: 0.6rem;\n\t\t\t\t\tpadding: 0.5rem 0.75rem;\n\t\t\t\t\tcursor: pointer;\n\t\t\t\t\ttransition: background 0.1s;\n\t\t\t\t}\n\t\t\t\t.handle-suggestion:hover,\n\t\t\t\t.handle-suggestion.active {\n\t\t\t\t\tbackground: oklch(0.30 0.02 70);\n\t\t\t\t}\n\t\t\t\t.suggestion-avatar {\n\t\t\t\t\twidth: 32px;\n\t\t\t\t\theight: 32px;\n\t\t\t\t\tborder-radius: 50%;\n\t\t\t\t\tflex-shrink: 0;\n\t\t\t\t\tobject-fit: cover;\n\t\t\t\t}\n\t\t\t\t.suggestion-avatar-placeholder {\n\t\t\t\t\twidth: 32px;\n\t\t\t\t\theight: 32px;\n\t\t\t\t\tborder-radius: 50%;\n\t\t\t\t\tflex-shrink: 0;\n\t\t\t\t\tbackground: oklch(0.40 0.01 70);\n\t\t\t\t}\n\t\t\t\t.suggestion-text {\n\t\t\t\t\tdisplay: flex;\n\t\t\t\t\tflex-direction: column;\n\t\t\t\t\tmin-width: 0;\n\t\t\t\t}\n\t\t\t\t.suggestion-name {\n\t\t\t\t\tfont-weight: 700;\n\t\t\t\t\tfont-size: var(--t-s);\n\t\t\t\t\toverflow: hidden;\n\t\t\t\t\ttext-overflow: ellipsis;\n\t\t\t\t\twhite-space: nowrap;\n\t\t\t\t}\n\t\t\t\t.suggestion-handle {\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\tcolor: oklch(0.65 0.01 70);\n\t\t\t\t\toverflow: hidden;\n\t\t\t\t\ttext-overflow: ellipsis;\n\t\t\t\t\twhite-space: nowrap;\n\t\t\t\t}\n\t\t\t\ttextarea {\n\t\t\t\t\tresize: vertical;\n\t\t\t\t\tline-height: 1.4;\n\t\t\t\t\tfont-family: 'JetBrains Mono', 'Menlo', monospace;\n\t\t\t\t\tfont-size: var(--t-s);\n\t\t\t\t}\n\t\t\t\t/* Buttons and button-styled links share one base. .btn-secondary\n\t\t\t\t is the ghost variant — muted, reserved for withdrawal\n\t\t\t\t actions (sign out) and secondary CTAs (sign in next to\n\t\t\t\t \"Enroll a domain\"). Keeping them as class variants on a\n\t\t\t\t single base means the aesthetic stays consistent when we\n\t\t\t\t change padding or weight in one place. */\n\t\t\t\tbutton,\n\t\t\t\t.btn {\n\t\t\t\t\tdisplay: inline-block;\n\t\t\t\t\tfont-family: inherit;\n\t\t\t\t\tfont-size: var(--t-s);\n\t\t\t\t\tfont-weight: 700;\n\t\t\t\t\tletter-spacing: 0.02em;\n\t\t\t\t\tcolor: var(--bg);\n\t\t\t\t\tbackground: var(--ink);\n\t\t\t\t\tborder: 1px solid var(--ink);\n\t\t\t\t\tpadding: 0.65rem 1.5rem;\n\t\t\t\t\tmargin-top: 1.25rem;\n\t\t\t\t\tcursor: pointer;\n\t\t\t\t\ttext-decoration: none;\n\t\t\t\t\ttransition: background 120ms ease, color 120ms ease, border-color 120ms ease;\n\t\t\t\t}\n\t\t\t\t.btn-secondary,\n\t\t\t\tbutton.btn-secondary {\n\t\t\t\t\tcolor: var(--ink);\n\t\t\t\t\tbackground: transparent;\n\t\t\t\t\tborder-color: var(--line);\n\t\t\t\t}\n\t\t\t\tbutton:hover,\n\t\t\t\t.btn:hover {\n\t\t\t\t\tbackground: var(--accent);\n\t\t\t\t\tborder-color: var(--accent);\n\t\t\t\t\tcolor: var(--bg);\n\t\t\t\t}\n\t\t\t\t.btn-secondary:hover,\n\t\t\t\tbutton.btn-secondary:hover {\n\t\t\t\t\tbackground: var(--ink);\n\t\t\t\t\tborder-color: var(--ink);\n\t\t\t\t\tcolor: var(--bg);\n\t\t\t\t}\n\n\t\t\t\t/* Credential box — inverse of the page (surface white on paper).\n\t\t\t\t This is the ONE boxed element on the success page; everything\n\t\t\t\t else is just typography. Makes the API key impossible to miss. */\n\t\t\t\t.credential {\n\t\t\t\t\tmargin: 1.5rem 0;\n\t\t\t\t\tpadding: 1.25rem 1.5rem;\n\t\t\t\t\tbackground: var(--surface);\n\t\t\t\t\tborder: 1px solid var(--line);\n\t\t\t\t\tborder-left: 3px solid var(--accent);\n\t\t\t\t}\n\t\t\t\t.credential-label {\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\tfont-weight: 700;\n\t\t\t\t\ttext-transform: uppercase;\n\t\t\t\t\tletter-spacing: 0.12em;\n\t\t\t\t\tcolor: var(--accent);\n\t\t\t\t\tmargin-bottom: 0.5rem;\n\t\t\t\t}\n\t\t\t\t.credential-note {\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\tfont-style: italic;\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\tmargin-top: 0.75rem;\n\t\t\t\t}\n\n\t\t\t\t.dns-block {\n\t\t\t\t\tmargin: 1.25rem 0;\n\t\t\t\t}\n\t\t\t\t.dns-block-label {\n\t\t\t\t\tfont-family: 'JetBrains Mono', monospace;\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\tmargin-bottom: 0.25rem;\n\t\t\t\t}\n\n\t\t\t\t.bullets {\n\t\t\t\t\tmargin: 0.5rem 0;\n\t\t\t\t\tpadding-left: 1.25rem;\n\t\t\t\t}\n\t\t\t\t.bullets li {\n\t\t\t\t\tmargin-bottom: 0.25rem;\n\t\t\t\t}\n\n\t\t\t\t/* Footer — light, one line, hairline rule. `margin-top: auto`\n\t\t\t\t on a flex child in a column container pushes the footer to\n\t\t\t\t the bottom of whatever space is left, so it sticks to the\n\t\t\t\t viewport bottom on short pages. The 2.25rem minimum keeps\n\t\t\t\t a comfortable gap from tall content pages (Terms, Privacy)\n\t\t\t\t because `auto` collapses to 0 when the flex parent is\n\t\t\t\t already at or beyond its main size. */\n\t\t\t\tfooter {\n\t\t\t\t\tmargin-top: auto;\n\t\t\t\t\tpadding-top: 1rem;\n\t\t\t\t\tborder-top: 1px solid var(--line);\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\tcolor: var(--muted);\n\t\t\t\t\ttext-align: left;\n\t\t\t\t\tline-height: 1.6;\n\t\t\t\t}\n\t\t\t\t/* Reserve breathing room above the footer when content is tall.\n\t\t\t\t `margin-top: auto` alone would push the footer against the\n\t\t\t\t content when the page overflows; this gives it its historical\n\t\t\t\t 2.25rem gap on long pages. */\n\t\t\t\tfooter::before {\n\t\t\t\t\tcontent: \"\";\n\t\t\t\t\tdisplay: block;\n\t\t\t\t\theight: 2.25rem;\n\t\t\t\t}\n\t\t\t\tfooter a { color: var(--muted); text-decoration-color: var(--line); }\n\t\t\t\tfooter a:hover { color: var(--ink); text-decoration-color: var(--ink); }\n\n\t\t\t\t/* Error state — same visual grammar, accent underlines the issue. */\n\t\t\t\t.error-note {\n\t\t\t\t\tmargin: 2rem 0;\n\t\t\t\t\tpadding: 1rem 1.25rem;\n\t\t\t\t\tborder-left: 3px solid var(--accent);\n\t\t\t\t\tbackground: var(--surface);\n\t\t\t\t\tcolor: var(--ink);\n\t\t\t\t\tfont-size: var(--t-s);\n\t\t\t\t}\n\n\t\t\t\t/* Resolver hint — small inline feedback below the identity input\n\t\t\t\t while a handle is being resolved to its DID. */\n\t\t\t\t.resolver-hint {\n\t\t\t\t\tdisplay: none;\n\t\t\t\t\tfont-family: 'JetBrains Mono', 'Menlo', monospace;\n\t\t\t\t\tfont-size: var(--t-xs);\n\t\t\t\t\tmargin: 0.25rem 0 0.5rem;\n\t\t\t\t\tline-height: 1.4;\n\t\t\t\t}\n\t\t\t\t.resolver-hint.is-loading { display: block; color: var(--muted); font-style: italic; font-family: var(--font-body); }\n\t\t\t\t.resolver-hint.is-ok { display: block; color: var(--ink); }\n\t\t\t\t.resolver-hint.is-ok::before { content: \"→ \"; color: var(--accent); font-weight: 700; }\n\t\t\t\t.resolver-hint.is-err { display: block; color: var(--accent-ink); }\n\t\t\t\t.resolver-hint.is-err::before { content: \"⚠ \"; }\n\n\t\t\t\t/* Mobile tightening — the 680px reading column + 2rem padding\n\t\t\t\t already mostly handles this, but at narrow widths the\n\t\t\t\t masthead is too big and forms get cramped. */\n\t\t\t\t@media (max-width: 520px) {\n\t\t\t\t\t.page { padding: 1.5rem 1.25rem 2rem; }\n\t\t\t\t\t.masthead { font-size: 2.25rem; }\n\t\t\t\t\t/* Sub-page masthead must stay smaller than the landing masthead\n\t\t\t\t\t on mobile too — without this rule, the later .masthead size\n\t\t\t\t\t wins by source order (same specificity) and the Round 2\n\t\t\t\t\t landing-only drop-cap motif dilutes on phones. */\n\t\t\t\t\t.masthead-sub { font-size: 1.625rem; }\n\t\t\t\t\t.lede { font-size: var(--t-m); margin-bottom: 1rem; }\n\t\t\t\t\t.section { margin-top: 1.25rem; padding-top: 0.75rem; }\n\t\t\t\t}\n\t\t\t</style></head><body><main class=\"page\">") 86 86 if templ_7745c5c3_Err != nil { 87 87 return templ_7745c5c3_Err 88 88 } ··· 110 110 // to take (DID, domain, contact email) and start the DNS-verification 111 111 // handshake. Uses masthead-sub (no drop-cap) because the drop-cap is 112 112 // reserved for the root page's brand mark. 113 - func EnrollLanding() templ.Component { 113 + func EnrollLanding(authDID, authHandle string, requireAuth bool, existingDomains []string) templ.Component { 114 114 return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 115 115 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 116 116 if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { ··· 143 143 }() 144 144 } 145 145 ctx = templ.InitializeContext(ctx) 146 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<h1 class=\"masthead masthead-sub\">Enroll</h1><p class=\"lede\" style=\"margin-bottom: 1.25rem;\">Three fields, one DNS TXT record, and we issue you SMTP credentials plus DKIM keys.</p><section class=\"section\" style=\"margin-top: 1.25rem; padding-top: 0.75rem;\"><form id=\"enroll-form\" action=\"/enroll/start\" method=\"POST\"><label for=\"identity\">Your handle or DID</label> <small>Handle like <code>scottlanoue.com</code> or a full <code>did:plc:…</code>.</small> <input type=\"text\" id=\"identity\" name=\"identity\" placeholder=\"scottlanoue.com\" required autocomplete=\"off\" spellcheck=\"false\" autocapitalize=\"off\"><div id=\"resolver-hint\" class=\"resolver-hint\" aria-live=\"polite\"></div><!-- Hidden field submitted to /enroll/start. Populated by JS\n\t\t\t\t from the identity input (or directly if the user pasted a DID). --><input type=\"hidden\" id=\"did\" name=\"did\" value=\"\"> <label for=\"domain\">Sending domain</label> <small>You'll publish DKIM records here after enrollment. Must be a domain you control.</small> <input type=\"text\" id=\"domain\" name=\"domain\" placeholder=\"mail.yourhandle.com\" required pattern=\"[a-z0-9][a-z0-9\\-.]*\\.[a-z]{2,}\" autocomplete=\"off\" spellcheck=\"false\" autocapitalize=\"off\"> <label for=\"contact_email\">Contact email</label> <small>Where we'll send your approval notice and any account notifications. Stored with your member record; not displayed publicly.</small> <input type=\"email\" id=\"contact_email\" name=\"contact_email\" placeholder=\"you@yourhandle.com\" required autocomplete=\"email\" spellcheck=\"false\" autocapitalize=\"off\"> <button type=\"submit\" id=\"enroll-submit\">Start enrollment</button></form><p class=\"section-lede\" style=\"margin-top: 1rem; margin-bottom: 0;\">Already enrolled? Sign in at <a href=\"/account\">Account</a> to see DKIM records, rotate your API key, or update your contact email.</p><p class=\"section-lede\" style=\"margin-top: 0.5rem; margin-bottom: 0; font-size: var(--t-xs);\">New here? The <a href=\"/\">landing page</a> covers how this works.</p><script>\n\t\t\t\t// Handle↔DID resolver. Typing a DID (starts with \"did:\") bypasses\n\t\t\t\t// the network round-trip. Typing anything else is treated as a\n\t\t\t\t// handle and resolved against /enroll/resolve on blur or submit.\n\t\t\t\t(function() {\n\t\t\t\t\tconst form = document.getElementById('enroll-form');\n\t\t\t\t\tconst identity = document.getElementById('identity');\n\t\t\t\t\tconst didField = document.getElementById('did');\n\t\t\t\t\tconst hint = document.getElementById('resolver-hint');\n\t\t\t\t\tconst submit = document.getElementById('enroll-submit');\n\n\t\t\t\t\tfunction isDID(s) {\n\t\t\t\t\t\treturn /^did:(plc|web):[A-Za-z0-9._%\\-]+$/.test(s.trim());\n\t\t\t\t\t}\n\n\t\t\t\t\tfunction setHint(text, cls) {\n\t\t\t\t\t\thint.textContent = text;\n\t\t\t\t\t\thint.className = 'resolver-hint ' + (cls || '');\n\t\t\t\t\t}\n\n\t\t\t\t\tasync function resolve(raw) {\n\t\t\t\t\t\tconst v = (raw || '').replace(/^@/, '').trim();\n\t\t\t\t\t\tif (!v) { setHint('', ''); didField.value = ''; return; }\n\t\t\t\t\t\tif (isDID(v)) { setHint(v, 'is-ok'); didField.value = v; return; }\n\n\t\t\t\t\t\tsetHint('Resolving ' + v + '…', 'is-loading');\n\t\t\t\t\t\tdidField.value = '';\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tconst r = await fetch('/enroll/resolve?handle=' + encodeURIComponent(v), {\n\t\t\t\t\t\t\t\theaders: { Accept: 'application/json' },\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\tif (!r.ok) {\n\t\t\t\t\t\t\t\tconst body = await r.json().catch(() => ({error: 'resolution failed'}));\n\t\t\t\t\t\t\t\tsetHint(body.error || 'resolution failed', 'is-err');\n\t\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tconst data = await r.json();\n\t\t\t\t\t\t\tif (data.did) {\n\t\t\t\t\t\t\t\tsetHint(data.did, 'is-ok');\n\t\t\t\t\t\t\t\tdidField.value = data.did;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\t\tsetHint('Network error — try again or paste a DID directly', 'is-err');\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tidentity.addEventListener('blur', () => resolve(identity.value));\n\t\t\t\t\tidentity.addEventListener('input', () => {\n\t\t\t\t\t\t// Clear the resolved DID while the user is editing; they'll\n\t\t\t\t\t\t// re-resolve on blur. Prevents stale DID from being submitted\n\t\t\t\t\t\t// if the user changes their mind mid-type.\n\t\t\t\t\t\tif (didField.value && identity.value.trim() !== didField.value) {\n\t\t\t\t\t\t\tdidField.value = '';\n\t\t\t\t\t\t\tsetHint('', '');\n\t\t\t\t\t\t}\n\t\t\t\t\t});\n\n\t\t\t\t\tform.addEventListener('submit', async (ev) => {\n\t\t\t\t\t\tif (didField.value) return; // already resolved\n\t\t\t\t\t\tev.preventDefault();\n\t\t\t\t\t\tsubmit.disabled = true;\n\t\t\t\t\t\tsubmit.textContent = 'Resolving identity…';\n\t\t\t\t\t\tawait resolve(identity.value);\n\t\t\t\t\t\tsubmit.disabled = false;\n\t\t\t\t\t\tsubmit.textContent = 'Start enrollment';\n\t\t\t\t\t\tif (didField.value) form.submit();\n\t\t\t\t\t});\n\n\t\t\t\t\t// Dynamic contact-email placeholder. We can't pre-fill the\n\t\t\t\t\t// value (the user's real address may not live at the\n\t\t\t\t\t// sending domain — transactional-only senders often have\n\t\t\t\t\t// `mail.example.com` as the send-from and `ops@example.com`\n\t\t\t\t\t// as contact) but the placeholder is a strong hint about\n\t\t\t\t\t// what shape we expect.\n\t\t\t\t\tconst domainField = document.getElementById('domain');\n\t\t\t\t\tconst contactField = document.getElementById('contact_email');\n\t\t\t\t\tif (domainField && contactField) {\n\t\t\t\t\t\tconst updatePlaceholder = () => {\n\t\t\t\t\t\t\tconst d = domainField.value.trim().toLowerCase();\n\t\t\t\t\t\t\tcontactField.placeholder = d\n\t\t\t\t\t\t\t\t? 'you@' + d\n\t\t\t\t\t\t\t\t: 'you@yourhandle.com';\n\t\t\t\t\t\t};\n\t\t\t\t\t\tdomainField.addEventListener('input', updatePlaceholder);\n\t\t\t\t\t\tdomainField.addEventListener('blur', updatePlaceholder);\n\t\t\t\t\t}\n\t\t\t\t})();\n\t\t\t</script></section>") 146 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<h1 class=\"masthead masthead-sub\">Enroll</h1>") 147 147 if templ_7745c5c3_Err != nil { 148 148 return templ_7745c5c3_Err 149 149 } 150 + if authDID == "" { 151 + if requireAuth { 152 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<p class=\"lede\" style=\"margin-bottom: 1.25rem;\">First, verify your handle. We'll redirect you to your PDS to confirm you own the account.</p>") 153 + if templ_7745c5c3_Err != nil { 154 + return templ_7745c5c3_Err 155 + } 156 + } else { 157 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<p class=\"lede\" style=\"margin-bottom: 1.25rem;\">Three fields, one DNS record, and you're in. We'll issue credentials to start sending mail through the relay.</p>") 158 + if templ_7745c5c3_Err != nil { 159 + return templ_7745c5c3_Err 160 + } 161 + } 162 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " <section class=\"section\" style=\"margin-top: 1.25rem; padding-top: 0.75rem;\"><form id=\"enroll-form\" action=\"") 163 + if templ_7745c5c3_Err != nil { 164 + return templ_7745c5c3_Err 165 + } 166 + var templ_7745c5c3_Var6 templ.SafeURL 167 + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(enrollLandingAction(requireAuth)) 168 + if templ_7745c5c3_Err != nil { 169 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 568, Col: 68} 170 + } 171 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) 172 + if templ_7745c5c3_Err != nil { 173 + return templ_7745c5c3_Err 174 + } 175 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" method=\"POST\"><label for=\"identity\">Your handle</label> <small>The <a href=\"https://atproto.com/specs/handle\">handle</a> of the account you're enrolling with — e.g. <code>alice.bsky.social</code> or a custom domain handle. You can also paste a full <code>did:plc:…</code>.</small> <input type=\"text\" id=\"identity\" name=\"identity\" placeholder=\"alice.bsky.social\" required autocomplete=\"off\" spellcheck=\"false\" autocapitalize=\"off\"><div id=\"resolver-hint\" class=\"resolver-hint\" aria-live=\"polite\"></div><input type=\"hidden\" id=\"did\" name=\"did\" value=\"\"> ") 176 + if templ_7745c5c3_Err != nil { 177 + return templ_7745c5c3_Err 178 + } 179 + if !requireAuth { 180 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<label for=\"domain\">Sending domain</label> <small>The domain that appears after the @ in emails sent through the relay — e.g. <code>example.com</code> or <code>mail.example.com</code>. Must be a domain you control; you'll add a DNS record to verify ownership.</small> <input type=\"text\" id=\"domain\" name=\"domain\" placeholder=\"example.com\" required pattern=\"[a-z0-9][a-z0-9\\-.]*\\.[a-z]{2,}\" autocomplete=\"off\" spellcheck=\"false\" autocapitalize=\"off\"> <label for=\"contact_email\">Contact email</label> <small>Where we'll send your approval notice and any account notifications. This can be any email you check — it doesn't need to be at your sending domain. Stored privately; never displayed publicly.</small> <input type=\"email\" id=\"contact_email\" name=\"contact_email\" placeholder=\"you@example.com\" required autocomplete=\"email\" spellcheck=\"false\" autocapitalize=\"off\"> <label style=\"display: flex; align-items: flex-start; gap: 0.5rem; margin-top: 1.25rem; font-weight: 400; cursor: pointer;\"><input type=\"checkbox\" name=\"terms_accepted\" id=\"terms_accepted\" required style=\"margin-top: 0.25rem; accent-color: var(--accent);\"> <span style=\"font-size: var(--t-s);\">I agree to the <a href=\"/terms\" target=\"_blank\">Terms of Service</a> and <a href=\"/aup\" target=\"_blank\">Acceptable Use Policy</a>, which may be updated with reasonable notice.</span></label> ") 181 + if templ_7745c5c3_Err != nil { 182 + return templ_7745c5c3_Err 183 + } 184 + } 185 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<button type=\"submit\" id=\"enroll-submit\" data-default-text=\"") 186 + if templ_7745c5c3_Err != nil { 187 + return templ_7745c5c3_Err 188 + } 189 + var templ_7745c5c3_Var7 string 190 + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(enrollLandingSubmitText(requireAuth)) 191 + if templ_7745c5c3_Err != nil { 192 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 613, Col: 102} 193 + } 194 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) 195 + if templ_7745c5c3_Err != nil { 196 + return templ_7745c5c3_Err 197 + } 198 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\">") 199 + if templ_7745c5c3_Err != nil { 200 + return templ_7745c5c3_Err 201 + } 202 + var templ_7745c5c3_Var8 string 203 + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(enrollLandingSubmitText(requireAuth)) 204 + if templ_7745c5c3_Err != nil { 205 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 613, Col: 143} 206 + } 207 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) 208 + if templ_7745c5c3_Err != nil { 209 + return templ_7745c5c3_Err 210 + } 211 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</button></form><p class=\"section-lede\" style=\"margin-top: 1rem; margin-bottom: 0;\">Already enrolled? Sign in at <a href=\"/account\">Account</a> to see DKIM records, rotate your API key, or update your contact email.</p><p class=\"section-lede\" style=\"margin-top: 0.5rem; margin-bottom: 0; font-size: var(--t-xs);\">New here? The <a href=\"/\">landing page</a> covers how this works.</p><script>\n\t\t\t\t\t(function() {\n\t\t\t\t\t\tvar SEARCH_API = 'https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead';\n\t\t\t\t\t\tvar DEBOUNCE_MS = 250;\n\t\t\t\t\t\tvar MIN_QUERY = 2;\n\t\t\t\t\t\tvar MAX_RESULTS = 6;\n\n\t\t\t\t\t\tvar form = document.getElementById('enroll-form');\n\t\t\t\t\t\tvar identity = document.getElementById('identity');\n\t\t\t\t\t\tvar didField = document.getElementById('did');\n\t\t\t\t\t\tvar hint = document.getElementById('resolver-hint');\n\t\t\t\t\t\tvar submit = document.getElementById('enroll-submit');\n\n\t\t\t\t\t\tvar debounceTimer = null;\n\t\t\t\t\t\tvar abortCtrl = null;\n\t\t\t\t\t\tvar activeIndex = -1;\n\t\t\t\t\t\tvar currentResults = [];\n\n\t\t\t\t\t\tfunction esc(s) {\n\t\t\t\t\t\t\treturn s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\"/g,'&quot;');\n\t\t\t\t\t\t}\n\t\t\t\t\t\tfunction isDID(s) {\n\t\t\t\t\t\t\treturn /^did:(plc|web):[A-Za-z0-9._%\\-]+$/.test(s.trim());\n\t\t\t\t\t\t}\n\t\t\t\t\t\tfunction setHint(text, cls) {\n\t\t\t\t\t\t\thint.textContent = text;\n\t\t\t\t\t\t\thint.className = 'resolver-hint ' + (cls || '');\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tvar wrapper = document.createElement('div');\n\t\t\t\t\t\twrapper.className = 'handle-input-wrapper';\n\t\t\t\t\t\tidentity.parentElement.insertBefore(wrapper, identity);\n\t\t\t\t\t\twrapper.appendChild(identity);\n\t\t\t\t\t\twrapper.parentElement.insertBefore(hint, wrapper.nextSibling);\n\n\t\t\t\t\t\tvar dropdown = document.createElement('div');\n\t\t\t\t\t\tdropdown.className = 'handle-suggestions';\n\t\t\t\t\t\tdropdown.setAttribute('role', 'listbox');\n\t\t\t\t\t\tdropdown.style.display = 'none';\n\t\t\t\t\t\twrapper.appendChild(dropdown);\n\t\t\t\t\t\tidentity.setAttribute('role', 'combobox');\n\t\t\t\t\t\tidentity.setAttribute('aria-autocomplete', 'list');\n\t\t\t\t\t\tidentity.setAttribute('aria-expanded', 'false');\n\n\t\t\t\t\t\tfunction renderSuggestions(results) {\n\t\t\t\t\t\t\tif (!results.length) {\n\t\t\t\t\t\t\t\tdropdown.style.display = 'none';\n\t\t\t\t\t\t\t\tidentity.setAttribute('aria-expanded', 'false');\n\t\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tdropdown.innerHTML = results.map(function(r, i) {\n\t\t\t\t\t\t\t\treturn '<div class=\"handle-suggestion\" role=\"option\" data-index=\"' + i + '\" data-handle=\"' + esc(r.handle) + '\">'\n\t\t\t\t\t\t\t\t\t+ (r.avatar\n\t\t\t\t\t\t\t\t\t\t? '<img src=\"' + esc(r.avatar) + '\" alt=\"\" class=\"suggestion-avatar\"/>'\n\t\t\t\t\t\t\t\t\t\t: '<div class=\"suggestion-avatar-placeholder\"></div>')\n\t\t\t\t\t\t\t\t\t+ '<div class=\"suggestion-text\">'\n\t\t\t\t\t\t\t\t\t+ '<span class=\"suggestion-name\">' + esc(r.displayName) + '</span>'\n\t\t\t\t\t\t\t\t\t+ '<span class=\"suggestion-handle\">@' + esc(r.handle) + '</span>'\n\t\t\t\t\t\t\t\t\t+ '</div></div>';\n\t\t\t\t\t\t\t}).join('');\n\t\t\t\t\t\t\tdropdown.style.display = '';\n\t\t\t\t\t\t\tidentity.setAttribute('aria-expanded', 'true');\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tfunction updateActive() {\n\t\t\t\t\t\t\tvar items = dropdown.querySelectorAll('.handle-suggestion');\n\t\t\t\t\t\t\tfor (var i = 0; i < items.length; i++) {\n\t\t\t\t\t\t\t\tif (i === activeIndex) items[i].classList.add('active');\n\t\t\t\t\t\t\t\telse items[i].classList.remove('active');\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tfunction selectHandle(handle) {\n\t\t\t\t\t\t\tidentity.value = handle;\n\t\t\t\t\t\t\tdropdown.style.display = 'none';\n\t\t\t\t\t\t\tidentity.setAttribute('aria-expanded', 'false');\n\t\t\t\t\t\t\tcurrentResults = [];\n\t\t\t\t\t\t\tactiveIndex = -1;\n\t\t\t\t\t\t\tresolve(handle);\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tfunction searchHandles(query) {\n\t\t\t\t\t\t\tif (abortCtrl) abortCtrl.abort();\n\t\t\t\t\t\t\tif (query.length < MIN_QUERY) return Promise.resolve([]);\n\t\t\t\t\t\t\tabortCtrl = new AbortController();\n\t\t\t\t\t\t\treturn fetch(SEARCH_API + '?q=' + encodeURIComponent(query) + '&limit=' + MAX_RESULTS, { signal: abortCtrl.signal })\n\t\t\t\t\t\t\t\t.then(function(r) { return r.ok ? r.json() : { actors: [] }; })\n\t\t\t\t\t\t\t\t.then(function(data) {\n\t\t\t\t\t\t\t\t\treturn (data.actors || []).map(function(a) {\n\t\t\t\t\t\t\t\t\t\treturn { handle: a.handle, displayName: a.displayName || a.handle, avatar: a.avatar || null };\n\t\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t\t.catch(function() { return []; });\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tfunction debouncedSearch(query) {\n\t\t\t\t\t\t\tif (debounceTimer) clearTimeout(debounceTimer);\n\t\t\t\t\t\t\tif (query.length < MIN_QUERY) { renderSuggestions([]); return; }\n\t\t\t\t\t\t\tdebounceTimer = setTimeout(function() {\n\t\t\t\t\t\t\t\tsearchHandles(query).then(function(results) {\n\t\t\t\t\t\t\t\t\tcurrentResults = results;\n\t\t\t\t\t\t\t\t\tactiveIndex = -1;\n\t\t\t\t\t\t\t\t\trenderSuggestions(results);\n\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t}, DEBOUNCE_MS);\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tasync function resolve(raw) {\n\t\t\t\t\t\t\tvar v = (raw || '').replace(/^@/, '').trim();\n\t\t\t\t\t\t\tif (!v) { setHint('', ''); didField.value = ''; return; }\n\t\t\t\t\t\t\tif (isDID(v)) { setHint(v, 'is-ok'); didField.value = v; return; }\n\t\t\t\t\t\t\tsetHint('Resolving ' + v + '…', 'is-loading');\n\t\t\t\t\t\t\tdidField.value = '';\n\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\tvar r = await fetch('/enroll/resolve?handle=' + encodeURIComponent(v), {\n\t\t\t\t\t\t\t\t\theaders: { Accept: 'application/json' },\n\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t\tif (!r.ok) {\n\t\t\t\t\t\t\t\t\tvar body = await r.json().catch(function() { return {error:'resolution failed'}; });\n\t\t\t\t\t\t\t\t\tsetHint(body.error || 'resolution failed', 'is-err');\n\t\t\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tvar data = await r.json();\n\t\t\t\t\t\t\t\tif (data.did) {\n\t\t\t\t\t\t\t\t\tsetHint(data.did, 'is-ok');\n\t\t\t\t\t\t\t\t\tdidField.value = data.did;\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\t\t\tsetHint('Network error — try again or paste a DID directly', 'is-err');\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tidentity.addEventListener('input', function() {\n\t\t\t\t\t\t\tvar q = identity.value.trim().replace(/^@/, '');\n\t\t\t\t\t\t\tif (didField.value) { didField.value = ''; setHint('', ''); }\n\t\t\t\t\t\t\tif (isDID(q)) {\n\t\t\t\t\t\t\t\trenderSuggestions([]);\n\t\t\t\t\t\t\t\tsetHint(q, 'is-ok');\n\t\t\t\t\t\t\t\tdidField.value = q;\n\t\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tdebouncedSearch(q);\n\t\t\t\t\t\t});\n\n\t\t\t\t\t\tidentity.addEventListener('keydown', function(e) {\n\t\t\t\t\t\t\tif (!currentResults.length) return;\n\t\t\t\t\t\t\tif (e.key === 'ArrowDown') {\n\t\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\t\tactiveIndex = Math.min(activeIndex + 1, currentResults.length - 1);\n\t\t\t\t\t\t\t\tupdateActive();\n\t\t\t\t\t\t\t} else if (e.key === 'ArrowUp') {\n\t\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\t\tactiveIndex = Math.max(activeIndex - 1, 0);\n\t\t\t\t\t\t\t\tupdateActive();\n\t\t\t\t\t\t\t} else if (e.key === 'Enter' && activeIndex >= 0) {\n\t\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\t\te.stopPropagation();\n\t\t\t\t\t\t\t\tselectHandle(currentResults[activeIndex].handle);\n\t\t\t\t\t\t\t} else if (e.key === 'Escape') {\n\t\t\t\t\t\t\t\tdropdown.style.display = 'none';\n\t\t\t\t\t\t\t\tidentity.setAttribute('aria-expanded', 'false');\n\t\t\t\t\t\t\t\tactiveIndex = -1;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t});\n\n\t\t\t\t\t\tdropdown.addEventListener('mousedown', function(e) {\n\t\t\t\t\t\t\te.preventDefault();\n\t\t\t\t\t\t\tvar target = e.target.closest('.handle-suggestion');\n\t\t\t\t\t\t\tif (target) selectHandle(target.dataset.handle);\n\t\t\t\t\t\t});\n\n\t\t\t\t\t\tidentity.addEventListener('blur', function() {\n\t\t\t\t\t\t\tsetTimeout(function() {\n\t\t\t\t\t\t\t\tdropdown.style.display = 'none';\n\t\t\t\t\t\t\t\tidentity.setAttribute('aria-expanded', 'false');\n\t\t\t\t\t\t\t}, 150);\n\t\t\t\t\t\t\tif (!didField.value) resolve(identity.value);\n\t\t\t\t\t\t});\n\n\t\t\t\t\t\tform.addEventListener('submit', async function(ev) {\n\t\t\t\t\t\t\tif (didField.value) return;\n\t\t\t\t\t\t\tev.preventDefault();\n\t\t\t\t\t\t\tsubmit.disabled = true;\n\t\t\t\t\t\t\tsubmit.textContent = 'Resolving identity…';\n\t\t\t\t\t\t\tawait resolve(identity.value);\n\t\t\t\t\t\t\tsubmit.disabled = false;\n\t\t\t\t\t\t\tsubmit.textContent = submit.getAttribute('data-default-text') || 'Start enrollment';\n\t\t\t\t\t\t\tif (didField.value) form.submit();\n\t\t\t\t\t\t});\n\t\t\t\t\t})();\n\t\t\t\t</script></section>") 212 + if templ_7745c5c3_Err != nil { 213 + return templ_7745c5c3_Err 214 + } 215 + } else { 216 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<section class=\"section\" style=\"margin-top: 1.25rem; padding-top: 0.75rem;\"><div class=\"credential\" style=\"margin-top: 0; margin-bottom: 1.5rem;\"><div class=\"credential-label\">Verified identity</div><p style=\"margin: 0.5rem 0 0; font-family: 'JetBrains Mono', monospace; font-size: var(--t-s); word-break: break-all;\">") 217 + if templ_7745c5c3_Err != nil { 218 + return templ_7745c5c3_Err 219 + } 220 + if authHandle != "" && authHandle != authDID { 221 + var templ_7745c5c3_Var9 string 222 + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs("@" + authHandle) 223 + if templ_7745c5c3_Err != nil { 224 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 819, Col: 25} 225 + } 226 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) 227 + if templ_7745c5c3_Err != nil { 228 + return templ_7745c5c3_Err 229 + } 230 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<br>") 231 + if templ_7745c5c3_Err != nil { 232 + return templ_7745c5c3_Err 233 + } 234 + } 235 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<span style=\"color: var(--muted); font-size: var(--t-xs);\">") 236 + if templ_7745c5c3_Err != nil { 237 + return templ_7745c5c3_Err 238 + } 239 + var templ_7745c5c3_Var10 string 240 + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(authDID) 241 + if templ_7745c5c3_Err != nil { 242 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 821, Col: 74} 243 + } 244 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) 245 + if templ_7745c5c3_Err != nil { 246 + return templ_7745c5c3_Err 247 + } 248 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</span></p></div>") 249 + if templ_7745c5c3_Err != nil { 250 + return templ_7745c5c3_Err 251 + } 252 + if len(existingDomains) > 0 { 253 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<div class=\"credential\" style=\"margin-top: 0; margin-bottom: 1.5rem;\"><div class=\"credential-label\">Your enrolled domains</div>") 254 + if templ_7745c5c3_Err != nil { 255 + return templ_7745c5c3_Err 256 + } 257 + for _, d := range existingDomains { 258 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<p style=\"margin: 0.5rem 0 0; font-family: 'JetBrains Mono', monospace; font-size: var(--t-s);\">") 259 + if templ_7745c5c3_Err != nil { 260 + return templ_7745c5c3_Err 261 + } 262 + var templ_7745c5c3_Var11 string 263 + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(d) 264 + if templ_7745c5c3_Err != nil { 265 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 829, Col: 106} 266 + } 267 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) 268 + if templ_7745c5c3_Err != nil { 269 + return templ_7745c5c3_Err 270 + } 271 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</p>") 272 + if templ_7745c5c3_Err != nil { 273 + return templ_7745c5c3_Err 274 + } 275 + } 276 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div>") 277 + if templ_7745c5c3_Err != nil { 278 + return templ_7745c5c3_Err 279 + } 280 + } 281 + if len(existingDomains) >= 2 { 282 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<p class=\"lede\" style=\"margin-bottom: 1.25rem;\">You've reached the maximum of 2 sending domains for this alpha. <a href=\"/enroll/manage\">Manage your account</a> to view DKIM records, rotate your API key, or update your contact email.</p>") 283 + if templ_7745c5c3_Err != nil { 284 + return templ_7745c5c3_Err 285 + } 286 + } else if len(existingDomains) == 1 { 287 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<p class=\"lede\" style=\"margin-bottom: 1.25rem;\">You can add one more sending domain during this alpha.</p><form action=\"/enroll/start\" method=\"POST\"><label for=\"domain\">Sending domain</label> <small>The domain that appears after the @ in emails sent through the relay — e.g. <code>example.com</code> or <code>mail.example.com</code>. Must be a domain you control; you'll add a DNS record to verify ownership.</small> <input type=\"text\" id=\"domain\" name=\"domain\" placeholder=\"example.com\" required pattern=\"[a-z0-9][a-z0-9\\-.]*\\.[a-z]{2,}\" autocomplete=\"off\" spellcheck=\"false\" autocapitalize=\"off\"> <label for=\"contact_email\">Contact email</label> <small>Where we'll send your approval notice and any account notifications. This can be any email you check — it doesn't need to be at your sending domain. Stored privately; never displayed publicly.</small> <input type=\"email\" id=\"contact_email\" name=\"contact_email\" placeholder=\"you@example.com\" required autocomplete=\"email\" spellcheck=\"false\" autocapitalize=\"off\"> <button type=\"submit\">Add domain →</button></form>") 288 + if templ_7745c5c3_Err != nil { 289 + return templ_7745c5c3_Err 290 + } 291 + } else { 292 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<p class=\"lede\" style=\"margin-bottom: 1.25rem;\">Identity verified. Now tell us about the domain you want to send from.</p><form action=\"/enroll/start\" method=\"POST\"><label for=\"domain\">Sending domain</label> <small>The domain that appears after the @ in emails sent through the relay — e.g. <code>example.com</code> or <code>mail.example.com</code>. Must be a domain you control; you'll add a DNS record to verify ownership.</small> <input type=\"text\" id=\"domain\" name=\"domain\" placeholder=\"example.com\" required pattern=\"[a-z0-9][a-z0-9\\-.]*\\.[a-z]{2,}\" autocomplete=\"off\" spellcheck=\"false\" autocapitalize=\"off\"> <label for=\"contact_email\">Contact email</label> <small>Where we'll send your approval notice and any account notifications. This can be any email you check — it doesn't need to be at your sending domain. Stored privately; never displayed publicly.</small> <input type=\"email\" id=\"contact_email\" name=\"contact_email\" placeholder=\"you@example.com\" required autocomplete=\"email\" spellcheck=\"false\" autocapitalize=\"off\"> <button type=\"submit\">Start enrollment →</button></form>") 293 + if templ_7745c5c3_Err != nil { 294 + return templ_7745c5c3_Err 295 + } 296 + } 297 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<p class=\"section-lede\" style=\"margin-top: 1rem; margin-bottom: 0;\"><a href=\"/enroll/reset\">← Use a different account</a></p>") 298 + if templ_7745c5c3_Err != nil { 299 + return templ_7745c5c3_Err 300 + } 301 + if len(existingDomains) > 0 { 302 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<p class=\"section-lede\" style=\"margin-top: 0.5rem; margin-bottom: 0;\"><a href=\"/enroll/manage\">Manage your account →</a></p>") 303 + if templ_7745c5c3_Err != nil { 304 + return templ_7745c5c3_Err 305 + } 306 + } 307 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</section>") 308 + if templ_7745c5c3_Err != nil { 309 + return templ_7745c5c3_Err 310 + } 311 + } 150 312 return nil 151 313 }) 152 314 templ_7745c5c3_Err = publicLayout("Enroll", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var5), templ_7745c5c3_Buffer) ··· 157 319 }) 158 320 } 159 321 322 + func enrollLandingAction(requireAuth bool) templ.SafeURL { 323 + if requireAuth { 324 + return templ.SafeURL("/enroll/auth") 325 + } 326 + return templ.SafeURL("/enroll/start") 327 + } 328 + 329 + func enrollLandingSubmitText(requireAuth bool) string { 330 + if requireAuth { 331 + return "Verify identity →" 332 + } 333 + return "Start enrollment" 334 + } 335 + 160 336 // EnrollStep2 shows the DNS TXT record the user needs to publish + a 161 337 // "verify DNS" button that re-resolves and finalizes enrollment. 162 338 // ··· 185 361 }() 186 362 } 187 363 ctx = templ.InitializeContext(ctx) 188 - templ_7745c5c3_Var6 := templ.GetChildren(ctx) 189 - if templ_7745c5c3_Var6 == nil { 190 - templ_7745c5c3_Var6 = templ.NopComponent 364 + templ_7745c5c3_Var12 := templ.GetChildren(ctx) 365 + if templ_7745c5c3_Var12 == nil { 366 + templ_7745c5c3_Var12 = templ.NopComponent 191 367 } 192 368 ctx = templ.ClearChildren(ctx) 193 - templ_7745c5c3_Var7 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 369 + templ_7745c5c3_Var13 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 194 370 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 195 371 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 196 372 if !templ_7745c5c3_IsBuffer { ··· 202 378 }() 203 379 } 204 380 ctx = templ.InitializeContext(ctx) 205 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<h1 class=\"masthead\"><span class=\"drop\">P</span>ublish a TXT record</h1><p class=\"lede\">Prove you control <code>") 381 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<h1 class=\"masthead\"><span class=\"drop\">P</span>ublish a TXT record</h1><p class=\"lede\">Prove you control <code>") 206 382 if templ_7745c5c3_Err != nil { 207 383 return templ_7745c5c3_Err 208 384 } 209 - var templ_7745c5c3_Var8 string 210 - templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(domain) 385 + var templ_7745c5c3_Var14 string 386 + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(domain) 211 387 if templ_7745c5c3_Err != nil { 212 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 645, Col: 35} 388 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 954, Col: 35} 213 389 } 214 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) 390 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) 215 391 if templ_7745c5c3_Err != nil { 216 392 return templ_7745c5c3_Err 217 393 } 218 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</code> by publishing a single DNS TXT record. We re-resolve it and enroll <code>") 394 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</code> by publishing a single DNS TXT record. We re-resolve it and enroll <code>") 219 395 if templ_7745c5c3_Err != nil { 220 396 return templ_7745c5c3_Err 221 397 } 222 - var templ_7745c5c3_Var9 string 223 - templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(did) 398 + var templ_7745c5c3_Var15 string 399 + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(did) 224 400 if templ_7745c5c3_Err != nil { 225 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 646, Col: 58} 401 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 955, Col: 58} 226 402 } 227 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) 403 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) 228 404 if templ_7745c5c3_Err != nil { 229 405 return templ_7745c5c3_Err 230 406 } 231 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</code> once it matches.</p>") 407 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</code> once it matches.</p>") 232 408 if templ_7745c5c3_Err != nil { 233 409 return templ_7745c5c3_Err 234 410 } 235 411 if errorMessage != "" { 236 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"error-note\" role=\"alert\"><strong>Verification failed:</strong> ") 412 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<div class=\"error-note\" role=\"alert\"><strong>Verification failed:</strong> ") 237 413 if templ_7745c5c3_Err != nil { 238 414 return templ_7745c5c3_Err 239 415 } 240 - var templ_7745c5c3_Var10 string 241 - templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage) 416 + var templ_7745c5c3_Var16 string 417 + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage) 242 418 if templ_7745c5c3_Err != nil { 243 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 652, Col: 56} 419 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 961, Col: 56} 244 420 } 245 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) 421 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) 246 422 if templ_7745c5c3_Err != nil { 247 423 return templ_7745c5c3_Err 248 424 } 249 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<br><small>Your token is still valid — fix the record below and click verify again.</small></div>") 425 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<br><small>Your token is still valid — fix the record below and click verify again.</small></div>") 250 426 if templ_7745c5c3_Err != nil { 251 427 return templ_7745c5c3_Err 252 428 } 253 429 } 254 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " <section class=\"section\"><span class=\"step-marker\">Step two · DNS</span><h2>Add this record at your registrar</h2><p class=\"section-lede\">Log into the DNS control panel for <code>") 430 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " <section class=\"section\"><span class=\"step-marker\">Step two · DNS</span><h2>Add this record at your registrar</h2><p class=\"section-lede\">Log into the DNS control panel for <code>") 255 431 if templ_7745c5c3_Err != nil { 256 432 return templ_7745c5c3_Err 257 433 } 258 - var templ_7745c5c3_Var11 string 259 - templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(domain) 434 + var templ_7745c5c3_Var17 string 435 + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(domain) 260 436 if templ_7745c5c3_Err != nil { 261 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 662, Col: 53} 437 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 971, Col: 53} 262 438 } 263 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) 439 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) 264 440 if templ_7745c5c3_Err != nil { 265 441 return templ_7745c5c3_Err 266 442 } 267 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</code> and create the TXT record below. The label is prefixed with <code>_atmos-enroll</code> so it won't collide with SPF, DMARC, or any other TXT records you already have.</p><div class=\"dns-block\"><div class=\"dns-block-label\">name</div><pre>") 443 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</code> and create the TXT record below. The label is prefixed with <code>_atmos-enroll</code> so it won't collide with SPF, DMARC, or any other TXT records you already have.</p><div class=\"dns-block\"><div class=\"dns-block-label\">name</div><pre>") 268 444 if templ_7745c5c3_Err != nil { 269 445 return templ_7745c5c3_Err 270 446 } 271 - var templ_7745c5c3_Var12 string 272 - templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(dnsName) 447 + var templ_7745c5c3_Var18 string 448 + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(dnsName) 273 449 if templ_7745c5c3_Err != nil { 274 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 670, Col: 18} 450 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 979, Col: 18} 275 451 } 276 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) 452 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) 277 453 if templ_7745c5c3_Err != nil { 278 454 return templ_7745c5c3_Err 279 455 } 280 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</pre></div><div class=\"dns-block\"><div class=\"dns-block-label\">type</div><pre>TXT</pre></div><div class=\"dns-block\"><div class=\"dns-block-label\">value</div><pre>") 456 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</pre></div><div class=\"dns-block\"><div class=\"dns-block-label\">type</div><pre>TXT</pre></div><div class=\"dns-block\"><div class=\"dns-block-label\">value</div><pre>") 281 457 if templ_7745c5c3_Err != nil { 282 458 return templ_7745c5c3_Err 283 459 } 284 - var templ_7745c5c3_Var13 string 285 - templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(dnsValue) 460 + var templ_7745c5c3_Var19 string 461 + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(dnsValue) 286 462 if templ_7745c5c3_Err != nil { 287 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 678, Col: 19} 463 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 987, Col: 19} 288 464 } 289 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) 465 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) 290 466 if templ_7745c5c3_Err != nil { 291 467 return templ_7745c5c3_Err 292 468 } 293 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</pre></div><p style=\"margin: 1rem 0; color: var(--muted); font-size: var(--t-s);\">Propagation is usually under a minute; occasionally it takes a few. If verification fails the first time, wait briefly and try again.</p><form action=\"/enroll/verify\" method=\"POST\"><input type=\"hidden\" name=\"did\" value=\"") 469 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</pre></div><p style=\"margin: 1rem 0; color: var(--muted); font-size: var(--t-s);\">Propagation is usually under a minute; occasionally it takes a few. If verification fails the first time, wait briefly and try again.</p><form action=\"/enroll/verify\" method=\"POST\"><input type=\"hidden\" name=\"did\" value=\"") 294 470 if templ_7745c5c3_Err != nil { 295 471 return templ_7745c5c3_Err 296 472 } 297 - var templ_7745c5c3_Var14 string 298 - templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(did) 473 + var templ_7745c5c3_Var20 string 474 + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(did) 299 475 if templ_7745c5c3_Err != nil { 300 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 687, Col: 47} 476 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 996, Col: 47} 301 477 } 302 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) 478 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) 303 479 if templ_7745c5c3_Err != nil { 304 480 return templ_7745c5c3_Err 305 481 } 306 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\"> <input type=\"hidden\" name=\"domain\" value=\"") 482 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\"> <input type=\"hidden\" name=\"domain\" value=\"") 307 483 if templ_7745c5c3_Err != nil { 308 484 return templ_7745c5c3_Err 309 485 } 310 - var templ_7745c5c3_Var15 string 311 - templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(domain) 486 + var templ_7745c5c3_Var21 string 487 + templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(domain) 312 488 if templ_7745c5c3_Err != nil { 313 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 688, Col: 53} 489 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 997, Col: 53} 314 490 } 315 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) 491 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) 316 492 if templ_7745c5c3_Err != nil { 317 493 return templ_7745c5c3_Err 318 494 } 319 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\"> <input type=\"hidden\" name=\"token\" value=\"") 495 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\"> <input type=\"hidden\" name=\"token\" value=\"") 320 496 if templ_7745c5c3_Err != nil { 321 497 return templ_7745c5c3_Err 322 498 } 323 - var templ_7745c5c3_Var16 string 324 - templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(token) 499 + var templ_7745c5c3_Var22 string 500 + templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(token) 325 501 if templ_7745c5c3_Err != nil { 326 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 689, Col: 51} 502 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 998, Col: 51} 327 503 } 328 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) 504 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) 329 505 if templ_7745c5c3_Err != nil { 330 506 return templ_7745c5c3_Err 331 507 } 332 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\"> <input type=\"hidden\" name=\"dnsName\" value=\"") 508 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\"> <input type=\"hidden\" name=\"dnsName\" value=\"") 333 509 if templ_7745c5c3_Err != nil { 334 510 return templ_7745c5c3_Err 335 511 } 336 - var templ_7745c5c3_Var17 string 337 - templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(dnsName) 512 + var templ_7745c5c3_Var23 string 513 + templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(dnsName) 338 514 if templ_7745c5c3_Err != nil { 339 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 690, Col: 55} 515 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 999, Col: 55} 340 516 } 341 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) 517 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) 342 518 if templ_7745c5c3_Err != nil { 343 519 return templ_7745c5c3_Err 344 520 } 345 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\"> <input type=\"hidden\" name=\"dnsValue\" value=\"") 521 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\"> <input type=\"hidden\" name=\"dnsValue\" value=\"") 346 522 if templ_7745c5c3_Err != nil { 347 523 return templ_7745c5c3_Err 348 524 } 349 - var templ_7745c5c3_Var18 string 350 - templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(dnsValue) 525 + var templ_7745c5c3_Var24 string 526 + templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(dnsValue) 351 527 if templ_7745c5c3_Err != nil { 352 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 691, Col: 57} 528 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1000, Col: 57} 353 529 } 354 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) 530 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) 355 531 if templ_7745c5c3_Err != nil { 356 532 return templ_7745c5c3_Err 357 533 } 358 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\"> <button type=\"submit\">Verify DNS &amp; enroll →</button></form><p style=\"margin-top: 2.5rem; font-size: var(--t-xs); color: var(--muted);\">Tokens expire after 24 hours. <a href=\"/enroll\">Start over</a>.</p></section>") 534 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\"> <button type=\"submit\">Verify DNS &amp; enroll →</button></form><p style=\"margin-top: 2.5rem; font-size: var(--t-xs); color: var(--muted);\">Tokens expire after 24 hours. <a href=\"/enroll\">Start over</a>.</p></section>") 359 535 if templ_7745c5c3_Err != nil { 360 536 return templ_7745c5c3_Err 361 537 } 362 538 return nil 363 539 }) 364 - templ_7745c5c3_Err = publicLayout("Publish TXT record", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var7), templ_7745c5c3_Buffer) 540 + templ_7745c5c3_Err = publicLayout("Publish TXT record", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var13), templ_7745c5c3_Buffer) 365 541 if templ_7745c5c3_Err != nil { 366 542 return templ_7745c5c3_Err 367 543 } ··· 386 562 }() 387 563 } 388 564 ctx = templ.InitializeContext(ctx) 389 - templ_7745c5c3_Var19 := templ.GetChildren(ctx) 390 - if templ_7745c5c3_Var19 == nil { 391 - templ_7745c5c3_Var19 = templ.NopComponent 565 + templ_7745c5c3_Var25 := templ.GetChildren(ctx) 566 + if templ_7745c5c3_Var25 == nil { 567 + templ_7745c5c3_Var25 = templ.NopComponent 392 568 } 393 569 ctx = templ.ClearChildren(ctx) 394 - templ_7745c5c3_Var20 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 570 + templ_7745c5c3_Var26 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 395 571 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 396 572 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 397 573 if !templ_7745c5c3_IsBuffer { ··· 403 579 }() 404 580 } 405 581 ctx = templ.InitializeContext(ctx) 406 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<h1 class=\"masthead\"><span class=\"drop\">E</span>nrolled</h1><p class=\"lede\">Save the API key below — this page is your only chance to copy it. Your account is pending operator approval; approval is typically within 24 hours.</p><section class=\"section\"><span class=\"step-marker\">Step three · credentials</span><h2>Your API key</h2><div class=\"credential\"><div class=\"credential-label\">api key · shown once</div><pre>") 582 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<h1 class=\"masthead\"><span class=\"drop\">E</span>nrolled</h1><p class=\"lede\">Save the API key below — this page is your only chance to copy it. Your account is pending operator approval; approval is typically within 24 hours.</p><section class=\"section\"><span class=\"step-marker\">Step three · credentials</span><h2>Your API key</h2><div class=\"credential\"><div class=\"credential-label\">api key · shown once</div><pre>") 407 583 if templ_7745c5c3_Err != nil { 408 584 return templ_7745c5c3_Err 409 585 } 410 - var templ_7745c5c3_Var21 string 411 - templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(result.APIKey) 586 + var templ_7745c5c3_Var27 string 587 + templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(result.APIKey) 412 588 if templ_7745c5c3_Err != nil { 413 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 718, Col: 24} 589 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1027, Col: 24} 414 590 } 415 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) 591 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) 416 592 if templ_7745c5c3_Err != nil { 417 593 return templ_7745c5c3_Err 418 594 } 419 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</pre><div class=\"credential-note\">Acts as your SMTP password. Store it somewhere you can retrieve — we only keep the hash.</div></div></section><section class=\"section\"><h2>SMTP submission</h2><ul class=\"bullets\"><li>Host: <code>") 595 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</pre><div class=\"credential-note\">Acts as your SMTP password. Store it somewhere you can retrieve — we only keep the hash.</div></div></section><section class=\"section\"><h2>SMTP submission</h2><ul class=\"bullets\"><li>Host: <code>") 420 596 if templ_7745c5c3_Err != nil { 421 597 return templ_7745c5c3_Err 422 598 } 423 - var templ_7745c5c3_Var22 string 424 - templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(result.SMTPHost) 599 + var templ_7745c5c3_Var28 string 600 + templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(result.SMTPHost) 425 601 if templ_7745c5c3_Err != nil { 426 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 728, Col: 37} 602 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1037, Col: 37} 427 603 } 428 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) 604 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) 429 605 if templ_7745c5c3_Err != nil { 430 606 return templ_7745c5c3_Err 431 607 } 432 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</code></li><li>Port: <code>") 608 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</code></li><li>Port: <code>") 433 609 if templ_7745c5c3_Err != nil { 434 610 return templ_7745c5c3_Err 435 611 } 436 - var templ_7745c5c3_Var23 string 437 - templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(intToStr(result.SMTPPort)) 612 + var templ_7745c5c3_Var29 string 613 + templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(intToStr(result.SMTPPort)) 438 614 if templ_7745c5c3_Err != nil { 439 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 729, Col: 47} 615 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1038, Col: 47} 440 616 } 441 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) 617 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) 442 618 if templ_7745c5c3_Err != nil { 443 619 return templ_7745c5c3_Err 444 620 } 445 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</code> (STARTTLS)</li><li>Username: <code>") 621 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</code> (STARTTLS)</li><li>Username: <code>") 446 622 if templ_7745c5c3_Err != nil { 447 623 return templ_7745c5c3_Err 448 624 } 449 - var templ_7745c5c3_Var24 string 450 - templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(result.DID) 625 + var templ_7745c5c3_Var30 string 626 + templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(result.DID) 451 627 if templ_7745c5c3_Err != nil { 452 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 730, Col: 36} 628 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1039, Col: 36} 453 629 } 454 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) 630 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) 455 631 if templ_7745c5c3_Err != nil { 456 632 return templ_7745c5c3_Err 457 633 } 458 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</code></li><li>Password: the API key above</li></ul></section><section class=\"section\"><h2>DKIM records to publish</h2><p class=\"section-lede\">Add these two TXT records in DNS for <code>") 634 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</code></li><li>Password: the API key above</li></ul></section><section class=\"section\"><h2>DKIM records to publish</h2><p class=\"section-lede\">Add these two TXT records in DNS for <code>") 459 635 if templ_7745c5c3_Err != nil { 460 636 return templ_7745c5c3_Err 461 637 } 462 - var templ_7745c5c3_Var25 string 463 - templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(result.Domain) 638 + var templ_7745c5c3_Var31 string 639 + templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(result.Domain) 464 640 if templ_7745c5c3_Err != nil { 465 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 738, Col: 62} 641 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1047, Col: 62} 466 642 } 467 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) 643 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) 468 644 if templ_7745c5c3_Err != nil { 469 645 return templ_7745c5c3_Err 470 646 } 471 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</code>. The labeler verifies them before issuing <code>verified-mail-operator</code>.</p><div class=\"dns-block\"><div class=\"dns-block-label\">") 647 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</code>. The labeler verifies them before issuing <code>verified-mail-operator</code>.</p><div class=\"dns-block\"><div class=\"dns-block-label\">") 472 648 if templ_7745c5c3_Err != nil { 473 649 return templ_7745c5c3_Err 474 650 } 475 - var templ_7745c5c3_Var26 string 476 - templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(result.DKIM.RSADNSName) 651 + var templ_7745c5c3_Var32 string 652 + templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(result.DKIM.RSADNSName) 477 653 if templ_7745c5c3_Err != nil { 478 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 743, Col: 57} 654 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1052, Col: 57} 479 655 } 480 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) 656 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) 481 657 if templ_7745c5c3_Err != nil { 482 658 return templ_7745c5c3_Err 483 659 } 484 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</div><pre>") 660 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</div><pre>") 485 661 if templ_7745c5c3_Err != nil { 486 662 return templ_7745c5c3_Err 487 663 } 488 - var templ_7745c5c3_Var27 string 489 - templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(result.DKIM.RSARecord) 664 + var templ_7745c5c3_Var33 string 665 + templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(result.DKIM.RSARecord) 490 666 if templ_7745c5c3_Err != nil { 491 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 744, Col: 32} 667 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1053, Col: 32} 492 668 } 493 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) 669 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) 494 670 if templ_7745c5c3_Err != nil { 495 671 return templ_7745c5c3_Err 496 672 } 497 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</pre></div><div class=\"dns-block\"><div class=\"dns-block-label\">") 673 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</pre></div><div class=\"dns-block\"><div class=\"dns-block-label\">") 498 674 if templ_7745c5c3_Err != nil { 499 675 return templ_7745c5c3_Err 500 676 } 501 - var templ_7745c5c3_Var28 string 502 - templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(result.DKIM.EdDNSName) 677 + var templ_7745c5c3_Var34 string 678 + templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(result.DKIM.EdDNSName) 503 679 if templ_7745c5c3_Err != nil { 504 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 747, Col: 56} 680 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1056, Col: 56} 505 681 } 506 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) 682 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) 507 683 if templ_7745c5c3_Err != nil { 508 684 return templ_7745c5c3_Err 509 685 } 510 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</div><pre>") 686 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</div><pre>") 511 687 if templ_7745c5c3_Err != nil { 512 688 return templ_7745c5c3_Err 513 689 } 514 - var templ_7745c5c3_Var29 string 515 - templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(result.DKIM.EdRecord) 690 + var templ_7745c5c3_Var35 string 691 + templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(result.DKIM.EdRecord) 516 692 if templ_7745c5c3_Err != nil { 517 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 748, Col: 31} 693 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1057, Col: 31} 518 694 } 519 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) 695 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) 520 696 if templ_7745c5c3_Err != nil { 521 697 return templ_7745c5c3_Err 522 698 } 523 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</pre></div></section><section class=\"section\"><h2>SPF and DMARC</h2><p class=\"section-lede\">Recommended. Big-provider inboxes weight these heavily.</p><pre>") 699 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</pre></div></section><section class=\"section\"><h2>SPF and DMARC</h2><p class=\"section-lede\">Recommended. Big-provider inboxes weight these heavily.</p><pre>") 524 700 if templ_7745c5c3_Err != nil { 525 701 return templ_7745c5c3_Err 526 702 } 527 - var templ_7745c5c3_Var30 string 528 - templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(`@ TXT "v=spf1 ip4:87.99.138.77 -all" 703 + var templ_7745c5c3_Var36 string 704 + templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(`@ TXT "v=spf1 ip4:87.99.138.77 -all" 529 705 _dmarc TXT "v=DMARC1; p=reject; adkim=r; aspf=r; rua=mailto:postmaster@atmos.email"`) 530 706 if templ_7745c5c3_Err != nil { 531 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 758, Col: 85} 707 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1067, Col: 85} 532 708 } 533 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) 709 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) 534 710 if templ_7745c5c3_Err != nil { 535 711 return templ_7745c5c3_Err 536 712 } 537 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</pre></section><section class=\"section\"><span class=\"step-marker\">Step four · attestation</span><h2>Publish your attestation</h2><p class=\"section-lede\">One more step: publish an <code>email.atmos.attestation</code> record to your PDS, signed by your DID. The labeler picks the record up and issues <code>verified-mail-operator</code> once your DKIM is live in DNS. The button below takes you to your PDS to approve the write — no app password, no copy-paste.</p>") 713 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</pre></section><section class=\"section\"><span class=\"step-marker\">Step four · attestation</span><h2>Publish your attestation</h2><p class=\"section-lede\">One more step: publish an <code>email.atmos.attestation</code> record to your PDS, signed by your DID. The labeler picks the record up and issues <code>verified-mail-operator</code> once your DKIM is live in DNS. The button below takes you to your PDS to approve the write — no app password, no copy-paste.</p>") 538 714 if templ_7745c5c3_Err != nil { 539 715 return templ_7745c5c3_Err 540 716 } 541 717 if result.AttestationPublished { 542 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<div class=\"credential\"><div class=\"credential-label\">attestation · already published</div><p style=\"margin: 0.5rem 0 0;\">Your attestation record for <code>") 718 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<div class=\"credential\"><div class=\"credential-label\">attestation · already published</div><p style=\"margin: 0.5rem 0 0;\">Your attestation record for <code>") 543 719 if templ_7745c5c3_Err != nil { 544 720 return templ_7745c5c3_Err 545 721 } 546 - var templ_7745c5c3_Var31 string 547 - templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(result.Domain) 722 + var templ_7745c5c3_Var37 string 723 + templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(result.Domain) 548 724 if templ_7745c5c3_Err != nil { 549 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 775, Col: 55} 725 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1084, Col: 55} 550 726 } 551 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) 727 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) 552 728 if templ_7745c5c3_Err != nil { 553 729 return templ_7745c5c3_Err 554 730 } 555 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</code> is live in your PDS. Labels typically appear within a minute.</p></div>") 731 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</code> is live in your PDS. Labels typically appear within a minute.</p></div>") 556 732 if templ_7745c5c3_Err != nil { 557 733 return templ_7745c5c3_Err 558 734 } ··· 561 737 return templ_7745c5c3_Err 562 738 } 563 739 } else { 564 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<div class=\"error-note\" role=\"alert\"><strong>Copy your API key and DKIM records before clicking.</strong> Publishing redirects you to your PDS and back to a confirmation page — this page (with the credentials above) is not re-shown afterwards, and we only store a hash of the key. If you lose the key, the only remedy is to re-enroll.</div><form action=\"/enroll/attest/start\" method=\"POST\"><input type=\"hidden\" name=\"did\" value=\"") 740 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<div class=\"error-note\" role=\"alert\"><strong>Copy your API key and DKIM records before clicking.</strong> Publishing redirects you to your PDS and back to a confirmation page — this page (with the credentials above) is not re-shown afterwards, and we only store a hash of the key. If you lose the key, the only remedy is to re-enroll.</div><form action=\"/enroll/attest/start\" method=\"POST\"><input type=\"hidden\" name=\"did\" value=\"") 565 741 if templ_7745c5c3_Err != nil { 566 742 return templ_7745c5c3_Err 567 743 } 568 - var templ_7745c5c3_Var32 string 569 - templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(result.DID) 744 + var templ_7745c5c3_Var38 string 745 + templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(result.DID) 570 746 if templ_7745c5c3_Err != nil { 571 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 790, Col: 55} 747 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1099, Col: 55} 572 748 } 573 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) 749 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) 574 750 if templ_7745c5c3_Err != nil { 575 751 return templ_7745c5c3_Err 576 752 } 577 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\"> <input type=\"hidden\" name=\"domain\" value=\"") 753 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\"> <input type=\"hidden\" name=\"domain\" value=\"") 578 754 if templ_7745c5c3_Err != nil { 579 755 return templ_7745c5c3_Err 580 756 } 581 - var templ_7745c5c3_Var33 string 582 - templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(result.Domain) 757 + var templ_7745c5c3_Var39 string 758 + templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(result.Domain) 583 759 if templ_7745c5c3_Err != nil { 584 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 791, Col: 61} 760 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1100, Col: 61} 585 761 } 586 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) 762 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) 587 763 if templ_7745c5c3_Err != nil { 588 764 return templ_7745c5c3_Err 589 765 } 590 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\"> <input type=\"hidden\" name=\"dkim_selector\" value=\"") 766 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\"> <input type=\"hidden\" name=\"dkim_selector\" value=\"") 591 767 if templ_7745c5c3_Err != nil { 592 768 return templ_7745c5c3_Err 593 769 } 594 - var templ_7745c5c3_Var34 string 595 - templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(result.DKIM.Selector) 770 + var templ_7745c5c3_Var40 string 771 + templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(result.DKIM.Selector) 596 772 if templ_7745c5c3_Err != nil { 597 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 792, Col: 75} 773 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1101, Col: 75} 598 774 } 599 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) 775 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) 600 776 if templ_7745c5c3_Err != nil { 601 777 return templ_7745c5c3_Err 602 778 } 603 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\"> <button type=\"submit\">Publish email.atmos.attestation to my PDS →</button></form><p style=\"margin-top: 0.75rem; font-size: var(--t-xs); color: var(--muted);\">DKIM must be live in DNS before the labeler will issue the label — you can still publish now; the labeler rechecks periodically.</p>") 779 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\"> <button type=\"submit\">Publish email.atmos.attestation to my PDS →</button></form><p style=\"margin-top: 0.75rem; font-size: var(--t-xs); color: var(--muted);\">DKIM must be live in DNS before the labeler will issue the label — you can still publish now; the labeler rechecks periodically.</p>") 604 780 if templ_7745c5c3_Err != nil { 605 781 return templ_7745c5c3_Err 606 782 } 607 783 } 608 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</section><section class=\"section\"><span class=\"step-marker\">Step five · what happens next</span><h2>Pending operator approval</h2><p class=\"section-lede\">Your account exists but is <strong>not yet active</strong>. SMTP submission will reject with <code>535 5.7.8</code> until an operator approves the enrollment — usually within 24 hours. The manual gate is a shared-reputation safeguard, not a judgment of you; it exists because one bad sender burns deliverability for every other member on this relay.</p><ul class=\"bullets\"><li>Publish the DKIM and (optionally) SPF/DMARC records above.</li><li>DNS propagation is usually minutes, occasionally an hour.</li><li>Approval confirmation is sent to the operator's Matrix room automatically. Once approved your next SMTP submission will succeed — no ping from us required.</li><li>Questions, or enrollment stuck &gt;24h? <a href=\"https://bsky.app/profile/scottlanoue.com\">Contact the operator</a>.</li></ul></section><section class=\"section\"><h2>Verify once approved</h2><p class=\"section-lede\">Paste this into a terminal after approval lands. It sends a test message through the relay and prints the server response. Replace the destination address with somewhere you control.</p><pre>") 784 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</section><section class=\"section\"><span class=\"step-marker\">Step five · what happens next</span><h2>Pending operator approval</h2><p class=\"section-lede\">Your account exists but is <strong>not yet active</strong>. SMTP submission will reject with <code>535 5.7.8</code> until an operator approves the enrollment — usually within 24 hours. The manual gate is a shared-reputation safeguard, not a judgment of you; it exists because one bad sender burns deliverability for every other member on this relay.</p><ul class=\"bullets\"><li>Publish the DKIM and (optionally) SPF/DMARC records above.</li><li>DNS propagation is usually minutes, occasionally an hour.</li><li>Approval confirmation is sent to the operator's Matrix room automatically. Once approved your next SMTP submission will succeed — no ping from us required.</li><li>Questions, or enrollment stuck &gt;24h? <a href=\"https://bsky.app/profile/scottlanoue.com\">Contact the operator</a>.</li></ul></section><section class=\"section\"><h2>Verify once approved</h2><p class=\"section-lede\">Paste this into a terminal after approval lands. It sends a test message through the relay and prints the server response. Replace the destination address with somewhere you control.</p><pre>") 609 785 if templ_7745c5c3_Err != nil { 610 786 return templ_7745c5c3_Err 611 787 } 612 - var templ_7745c5c3_Var35 string 613 - templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(renderSwaksQuickstart(result)) 788 + var templ_7745c5c3_Var41 string 789 + templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(renderSwaksQuickstart(result)) 614 790 if templ_7745c5c3_Err != nil { 615 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 836, Col: 39} 791 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1145, Col: 39} 616 792 } 617 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) 793 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) 618 794 if templ_7745c5c3_Err != nil { 619 795 return templ_7745c5c3_Err 620 796 } 621 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</pre><p class=\"section-lede\" style=\"margin-top: 0.75rem;\">If you'd rather use a real client: configure your app with the SMTP host, port, username and password from the section above — they're the same credentials <code>swaks</code> uses here.</p></section><section class=\"section\"><span class=\"step-marker\">Step six · warmup</span><h2>Warm up before you scale</h2><p class=\"section-lede\">Your DKIM, SPF, DMARC, and attestation are all correct, but the IP sending for your domain is new to receivers. Gmail in particular treats mail from an unknown sender as suspicious on day one; expect your first few sends to land in spam regardless of auth cleanliness.</p><p class=\"section-lede\">The cheapest way to build reputation is to send slowly, to engaged recipients, and let them interact naturally:</p><ul class=\"bullets\"><li>Week one: ten or fewer messages per day, to recipients you control. Mark spam-folder arrivals as not-spam, open each one, reply to at least one per day.</li><li>Week two: ten to fifty per day, still mostly to engaged recipients. Start including real correspondence.</li><li>Week three onward: scale as Google postmaster tools reputation allows. The relay caps new members to a sane daily ceiling until that reputation is established.</li></ul><p class=\"section-lede\">The relay enforces a warming rate-cap automatically. You cannot accidentally blast the shared pool.</p></section>") 797 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</pre><p class=\"section-lede\" style=\"margin-top: 0.75rem;\">If you'd rather use a real client: configure your app with the SMTP host, port, username and password from the section above — they're the same credentials <code>swaks</code> uses here.</p></section><section class=\"section\"><span class=\"step-marker\">Step six · warmup</span><h2>Warm up before you scale</h2><p class=\"section-lede\">Your DKIM, SPF, DMARC, and attestation are all correct, but the IP sending for your domain is new to receivers. Gmail in particular treats mail from an unknown sender as suspicious on day one; expect your first few sends to land in spam regardless of auth cleanliness.</p><p class=\"section-lede\">The cheapest way to build reputation is to send slowly, to engaged recipients, and let them interact naturally:</p><ul class=\"bullets\"><li>Week one: ten or fewer messages per day, to recipients you control. Mark spam-folder arrivals as not-spam, open each one, reply to at least one per day.</li><li>Week two: ten to fifty per day, still mostly to engaged recipients. Start including real correspondence.</li><li>Week three onward: scale as Google postmaster tools reputation allows. The relay caps new members to a sane daily ceiling until that reputation is established.</li></ul><p class=\"section-lede\">The relay enforces a warming rate-cap automatically. You cannot accidentally blast the shared pool.</p></section>") 622 798 if templ_7745c5c3_Err != nil { 623 799 return templ_7745c5c3_Err 624 800 } 625 801 return nil 626 802 }) 627 - templ_7745c5c3_Err = publicLayout("Enrolled", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var20), templ_7745c5c3_Buffer) 803 + templ_7745c5c3_Err = publicLayout("Enrolled", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var26), templ_7745c5c3_Buffer) 628 804 if templ_7745c5c3_Err != nil { 629 805 return templ_7745c5c3_Err 630 806 } ··· 652 828 }() 653 829 } 654 830 ctx = templ.InitializeContext(ctx) 655 - templ_7745c5c3_Var36 := templ.GetChildren(ctx) 656 - if templ_7745c5c3_Var36 == nil { 657 - templ_7745c5c3_Var36 = templ.NopComponent 831 + templ_7745c5c3_Var42 := templ.GetChildren(ctx) 832 + if templ_7745c5c3_Var42 == nil { 833 + templ_7745c5c3_Var42 = templ.NopComponent 658 834 } 659 835 ctx = templ.ClearChildren(ctx) 660 - templ_7745c5c3_Var37 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 836 + templ_7745c5c3_Var43 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 661 837 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 662 838 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 663 839 if !templ_7745c5c3_IsBuffer { ··· 669 845 }() 670 846 } 671 847 ctx = templ.InitializeContext(ctx) 672 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<h1 class=\"masthead masthead-sub\">Attestation published</h1><p class=\"lede\">Your <code>email.atmos.attestation</code> record is live on your PDS, signed by <code>") 848 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<h1 class=\"masthead masthead-sub\">Attestation published</h1><p class=\"lede\">Your <code>email.atmos.attestation</code> record is live on your PDS, signed by <code>") 673 849 if templ_7745c5c3_Err != nil { 674 850 return templ_7745c5c3_Err 675 851 } 676 - var templ_7745c5c3_Var38 string 677 - templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(did) 852 + var templ_7745c5c3_Var44 string 853 + templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(did) 678 854 if templ_7745c5c3_Err != nil { 679 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 868, Col: 29} 855 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1177, Col: 29} 680 856 } 681 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) 857 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) 682 858 if templ_7745c5c3_Err != nil { 683 859 return templ_7745c5c3_Err 684 860 } 685 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</code>. Labels typically appear within a minute.</p><section class=\"section\"><span class=\"step-marker\">Done</span><h2>What happens next</h2><ul class=\"bullets\"><li>The labeler reads your record and verifies DKIM in DNS.</li><li>If DKIM checks out, your DID gets <code>verified-mail-operator</code> and (if you opted in) <code>relay-member</code>.</li><li>The labels are public — any consumer of the labeler can read them.</li><li>To revoke: delete the atproto record from your PDS. The labeler reconciles on its next pass.</li></ul><p style=\"margin-top: 1.5rem;\">Domain: <code>") 861 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</code>. Labels typically appear within a minute.</p><section class=\"section\"><span class=\"step-marker\">Done</span><h2>What happens next</h2><ul class=\"bullets\"><li>The labeler reads your record and verifies DKIM in DNS.</li><li>If DKIM checks out, your DID gets <code>verified-mail-operator</code> and (if you opted in) <code>relay-member</code>.</li><li>The labels are public — any consumer of the labeler can read them.</li><li>To revoke: delete the atproto record from your PDS. The labeler reconciles on its next pass.</li></ul><p style=\"margin-top: 1.5rem;\">Domain: <code>") 686 862 if templ_7745c5c3_Err != nil { 687 863 return templ_7745c5c3_Err 688 864 } 689 - var templ_7745c5c3_Var39 string 690 - templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(domain) 865 + var templ_7745c5c3_Var45 string 866 + templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(domain) 691 867 if templ_7745c5c3_Err != nil { 692 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 885, Col: 26} 868 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1194, Col: 26} 693 869 } 694 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) 870 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) 695 871 if templ_7745c5c3_Err != nil { 696 872 return templ_7745c5c3_Err 697 873 } 698 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</code></p></section>") 874 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</code></p></section>") 699 875 if templ_7745c5c3_Err != nil { 700 876 return templ_7745c5c3_Err 701 877 } 702 878 return nil 703 879 }) 704 - templ_7745c5c3_Err = publicLayout("Attestation published", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var37), templ_7745c5c3_Buffer) 880 + templ_7745c5c3_Err = publicLayout("Attestation published", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var43), templ_7745c5c3_Buffer) 705 881 if templ_7745c5c3_Err != nil { 706 882 return templ_7745c5c3_Err 707 883 } ··· 756 932 }() 757 933 } 758 934 ctx = templ.InitializeContext(ctx) 759 - templ_7745c5c3_Var40 := templ.GetChildren(ctx) 760 - if templ_7745c5c3_Var40 == nil { 761 - templ_7745c5c3_Var40 = templ.NopComponent 935 + templ_7745c5c3_Var46 := templ.GetChildren(ctx) 936 + if templ_7745c5c3_Var46 == nil { 937 + templ_7745c5c3_Var46 = templ.NopComponent 762 938 } 763 939 ctx = templ.ClearChildren(ctx) 764 - templ_7745c5c3_Var41 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 940 + templ_7745c5c3_Var47 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 765 941 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 766 942 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 767 943 if !templ_7745c5c3_IsBuffer { ··· 773 949 }() 774 950 } 775 951 ctx = templ.InitializeContext(ctx) 776 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<h1 class=\"masthead\"><span class=\"drop\">C</span>ouldn't enroll</h1><p class=\"lede\">") 952 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<h1 class=\"masthead\"><span class=\"drop\">C</span>ouldn't enroll</h1><p class=\"lede\">") 777 953 if templ_7745c5c3_Err != nil { 778 954 return templ_7745c5c3_Err 779 955 } 780 - var templ_7745c5c3_Var42 string 781 - templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(message) 956 + var templ_7745c5c3_Var48 string 957 + templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(message) 782 958 if templ_7745c5c3_Err != nil { 783 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 925, Col: 27} 959 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1234, Col: 27} 784 960 } 785 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) 961 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48)) 786 962 if templ_7745c5c3_Err != nil { 787 963 return templ_7745c5c3_Err 788 964 } 789 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</p><section class=\"section\"><p class=\"section-lede\">This is the relay's response verbatim. Most failures are recoverable — correct the input and start over.</p><p><a href=\"/enroll\">← Start over</a></p></section>") 965 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</p><section class=\"section\"><p class=\"section-lede\">This is the relay's response verbatim. Most failures are recoverable — correct the input and start over.</p><p><a href=\"/enroll\">← Start over</a></p></section>") 790 966 if templ_7745c5c3_Err != nil { 791 967 return templ_7745c5c3_Err 792 968 } 793 969 return nil 794 970 }) 795 - templ_7745c5c3_Err = publicLayout("Error", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var41), templ_7745c5c3_Buffer) 971 + templ_7745c5c3_Err = publicLayout("Error", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var47), templ_7745c5c3_Buffer) 796 972 if templ_7745c5c3_Err != nil { 797 973 return templ_7745c5c3_Err 798 974 } ··· 838 1014 }() 839 1015 } 840 1016 ctx = templ.InitializeContext(ctx) 841 - templ_7745c5c3_Var43 := templ.GetChildren(ctx) 842 - if templ_7745c5c3_Var43 == nil { 843 - templ_7745c5c3_Var43 = templ.NopComponent 1017 + templ_7745c5c3_Var49 := templ.GetChildren(ctx) 1018 + if templ_7745c5c3_Var49 == nil { 1019 + templ_7745c5c3_Var49 = templ.NopComponent 844 1020 } 845 1021 ctx = templ.ClearChildren(ctx) 846 - templ_7745c5c3_Var44 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 1022 + templ_7745c5c3_Var50 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 847 1023 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 848 1024 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 849 1025 if !templ_7745c5c3_IsBuffer { ··· 855 1031 }() 856 1032 } 857 1033 ctx = templ.InitializeContext(ctx) 858 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<h1 class=\"masthead masthead-sub\">Terms of Service</h1><p class=\"effective\">Effective ") 1034 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<h1 class=\"masthead masthead-sub\">Terms of Service</h1><p class=\"effective\">Effective ") 859 1035 if templ_7745c5c3_Err != nil { 860 1036 return templ_7745c5c3_Err 861 1037 } 862 - var templ_7745c5c3_Var45 string 863 - templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(legalEffectiveDate()) 1038 + var templ_7745c5c3_Var51 string 1039 + templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(legalEffectiveDate()) 864 1040 if templ_7745c5c3_Err != nil { 865 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 962, Col: 55} 1041 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1271, Col: 55} 866 1042 } 867 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) 1043 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51)) 868 1044 if templ_7745c5c3_Err != nil { 869 1045 return templ_7745c5c3_Err 870 1046 } 871 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</p><p class=\"lede\">By enrolling a DID and sending mail through the Atmosphere Mail relay, you agree to these terms.</p><section class=\"section\"><span class=\"step-marker\">§1 · The service</span><h2>What this is</h2><p>Atmosphere Mail is a cooperative SMTP submission relay operated by <strong>Atmosphere Mail LLC</strong> (\"we\", \"us\"). It accepts authenticated mail from members whose identity is verified via atproto DIDs, signs outbound mail with DKIM, and delivers it to recipient MTAs. The service is provided free of charge and on a best-effort basis to support the atproto ecosystem.</p></section><section class=\"section\"><span class=\"step-marker\">§2 · Your account</span><h2>Eligibility and authentication</h2><p>To enroll you must prove control of both a DID and the domain you intend to send from. We verify domain ownership via a DNS TXT record — the same primitive used by Let's Encrypt and Google Workspace. You are responsible for safeguarding the API key issued at enrollment. We may revoke access if your DID, domain, or handle is lost, transferred, or disputed.</p></section><section class=\"section\"><span class=\"step-marker\">§3 · Acceptable use</span><h2>What you may send</h2><p>Use of the relay is subject to our <a href=\"/aup\">Acceptable Use Policy</a>. Send only your own mail, to recipients who asked for it, and honor unsubscribe requests. Violating the AUP can result in throttling, labeling, or suspension without notice.</p></section><section class=\"section\"><span class=\"step-marker\">§4 · Warranties</span><h2>Service provided \"AS IS\"</h2><p>THE SERVICE IS PROVIDED <strong>AS IS</strong> AND <strong>AS AVAILABLE</strong>, WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, OR UNINTERRUPTED OPERATION. We do not warrant that mail submitted through the relay will be accepted, delivered, or placed in any particular inbox folder.</p></section><section class=\"section\"><span class=\"step-marker\">§5 · Liability</span><h2>Limitation of liability</h2><p>TO THE MAXIMUM EXTENT PERMITTED BY LAW, Atmosphere Mail LLC and its members, officers, and contributors WILL NOT BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR FOR LOST PROFITS, REVENUE, BUSINESS, OR REPUTATION, ARISING OUT OF OR IN CONNECTION WITH YOUR USE OF THE SERVICE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. Our aggregate liability for any claim arising out of or relating to the service is limited to one hundred U.S. dollars ($100).</p></section><section class=\"section\"><span class=\"step-marker\">§6 · Termination</span><h2>Ending the relationship</h2><p>You may stop using the relay at any time by contacting <a href=\"mailto:postmaster@atmos.email\">postmaster@atmos.email</a> to have your member record and domain entries removed. We may suspend or terminate access for any reason, including AUP violations, legal requirements, or discontinuation of the service. Upon termination we will stop accepting mail from your DID; terminal message logs are retained under the retention schedule disclosed in the <a href=\"/privacy\">Privacy Policy</a>.</p></section><section class=\"section\"><span class=\"step-marker\">§7 · Changes</span><h2>Updates to these terms</h2><p>We may update these terms as the service evolves. Material changes will be reflected here with a new effective date. Continued use of the relay after an update means you accept the revised terms.</p></section><section class=\"section\"><span class=\"step-marker\">§8 · Governing law</span><h2>Jurisdiction</h2><p>These terms are governed by the laws of the State of Washington, United States, without regard to its conflicts of law rules. Any dispute that cannot be resolved by correspondence with <a href=\"mailto:postmaster@atmos.email\">postmaster@atmos.email</a> will be heard in the state or federal courts sitting in King County, Washington.</p></section><section class=\"section\"><span class=\"step-marker\">§9 · Contact</span><h2>Reach us</h2><p>Atmosphere Mail LLC — <a href=\"mailto:postmaster@atmos.email\">postmaster@atmos.email</a>. For abuse reports use <a href=\"mailto:abuse@atmos.email\">abuse@atmos.email</a>.</p></section>") 1047 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</p><p class=\"lede\">By enrolling a DID and sending mail through the Atmosphere Mail relay, you agree to these terms.</p><section class=\"section\"><span class=\"step-marker\">§1 · The service</span><h2>What this is</h2><p>Atmosphere Mail is a cooperative SMTP submission relay operated by <strong>Atmosphere Mail LLC</strong> (\"we\", \"us\"). It accepts authenticated mail from members whose identity is verified via atproto DIDs, signs outbound mail with DKIM, and delivers it to recipient MTAs. The service is provided free of charge and on a best-effort basis to support the atproto ecosystem.</p></section><section class=\"section\"><span class=\"step-marker\">§2 · Your account</span><h2>Eligibility and authentication</h2><p>To enroll you must prove control of both a DID and the domain you intend to send from. We verify domain ownership via a DNS TXT record — the same primitive used by Let's Encrypt and Google Workspace. You are responsible for safeguarding the API key issued at enrollment. We may revoke access if your DID, domain, or handle is lost, transferred, or disputed.</p></section><section class=\"section\"><span class=\"step-marker\">§3 · Acceptable use</span><h2>What you may send</h2><p>Use of the relay is subject to our <a href=\"/aup\">Acceptable Use Policy</a>. Send only your own mail, to recipients who asked for it, and honor unsubscribe requests. Violating the AUP can result in throttling, labeling, or suspension without notice.</p></section><section class=\"section\"><span class=\"step-marker\">§4 · Warranties</span><h2>Service provided \"AS IS\"</h2><p>THE SERVICE IS PROVIDED <strong>AS IS</strong> AND <strong>AS AVAILABLE</strong>, WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, OR UNINTERRUPTED OPERATION. We do not warrant that mail submitted through the relay will be accepted, delivered, or placed in any particular inbox folder.</p></section><section class=\"section\"><span class=\"step-marker\">§5 · Liability</span><h2>Limitation of liability</h2><p>TO THE MAXIMUM EXTENT PERMITTED BY LAW, Atmosphere Mail LLC and its members, officers, and contributors WILL NOT BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR FOR LOST PROFITS, REVENUE, BUSINESS, OR REPUTATION, ARISING OUT OF OR IN CONNECTION WITH YOUR USE OF THE SERVICE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. Our aggregate liability for any claim arising out of or relating to the service is limited to one hundred U.S. dollars ($100).</p></section><section class=\"section\"><span class=\"step-marker\">§6 · Termination</span><h2>Ending the relationship</h2><p>You may stop using the relay at any time by contacting <a href=\"mailto:postmaster@atmos.email\">postmaster@atmos.email</a> to have your member record and domain entries removed. We may suspend or terminate access for any reason, including AUP violations, legal requirements, or discontinuation of the service. Upon termination we will stop accepting mail from your DID; terminal message logs are retained under the retention schedule disclosed in the <a href=\"/privacy\">Privacy Policy</a>.</p></section><section class=\"section\"><span class=\"step-marker\">§7 · Changes</span><h2>Updates to these terms</h2><p>We may update these terms as the service evolves. Material changes will be reflected here with a new effective date. Continued use of the relay after an update means you accept the revised terms.</p></section><section class=\"section\"><span class=\"step-marker\">§8 · Governing law</span><h2>Jurisdiction</h2><p>These terms are governed by the laws of the State of Washington, United States, without regard to its conflicts of law rules. Any dispute that cannot be resolved by correspondence with <a href=\"mailto:postmaster@atmos.email\">postmaster@atmos.email</a> will be heard in the state or federal courts sitting in King County, Washington.</p></section><section class=\"section\"><span class=\"step-marker\">§9 · Contact</span><h2>Reach us</h2><p>Atmosphere Mail LLC — <a href=\"mailto:postmaster@atmos.email\">postmaster@atmos.email</a>. For abuse reports use <a href=\"mailto:abuse@atmos.email\">abuse@atmos.email</a>.</p></section>") 872 1048 if templ_7745c5c3_Err != nil { 873 1049 return templ_7745c5c3_Err 874 1050 } 875 1051 return nil 876 1052 }) 877 - templ_7745c5c3_Err = publicLayout("Terms of Service", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var44), templ_7745c5c3_Buffer) 1053 + templ_7745c5c3_Err = publicLayout("Terms of Service", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var50), templ_7745c5c3_Buffer) 878 1054 if templ_7745c5c3_Err != nil { 879 1055 return templ_7745c5c3_Err 880 1056 } ··· 901 1077 }() 902 1078 } 903 1079 ctx = templ.InitializeContext(ctx) 904 - templ_7745c5c3_Var46 := templ.GetChildren(ctx) 905 - if templ_7745c5c3_Var46 == nil { 906 - templ_7745c5c3_Var46 = templ.NopComponent 1080 + templ_7745c5c3_Var52 := templ.GetChildren(ctx) 1081 + if templ_7745c5c3_Var52 == nil { 1082 + templ_7745c5c3_Var52 = templ.NopComponent 907 1083 } 908 1084 ctx = templ.ClearChildren(ctx) 909 - templ_7745c5c3_Var47 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 1085 + templ_7745c5c3_Var53 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 910 1086 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 911 1087 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 912 1088 if !templ_7745c5c3_IsBuffer { ··· 918 1094 }() 919 1095 } 920 1096 ctx = templ.InitializeContext(ctx) 921 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<h1 class=\"masthead masthead-sub\">Privacy Policy</h1><p class=\"effective\">Effective ") 1097 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<h1 class=\"masthead masthead-sub\">Privacy Policy</h1><p class=\"effective\">Effective ") 922 1098 if templ_7745c5c3_Err != nil { 923 1099 return templ_7745c5c3_Err 924 1100 } 925 - var templ_7745c5c3_Var48 string 926 - templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(legalEffectiveDate()) 1101 + var templ_7745c5c3_Var54 string 1102 + templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(legalEffectiveDate()) 927 1103 if templ_7745c5c3_Err != nil { 928 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1095, Col: 55} 1104 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1404, Col: 55} 929 1105 } 930 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48)) 1106 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54)) 931 1107 if templ_7745c5c3_Err != nil { 932 1108 return templ_7745c5c3_Err 933 1109 } 934 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</p><p class=\"lede\">Atmosphere Mail LLC operates the relay. Here is exactly what we collect, why, and for how long.</p><section class=\"section\"><span class=\"step-marker\">§1 · What we collect</span><h2>The data we hold</h2><ul class=\"bullets\"><li><strong>Your DID</strong> and registered sending domain(s).</li><li><strong>A salted hash of your API key</strong> — the plaintext key is only ever shown once, at enrollment.</li><li><strong>DKIM keypairs</strong> issued to your domain. Private keys are stored encrypted at rest and never leave our servers.</li><li><strong>Send logs</strong>: per-message sender DID, recipient address, From/To headers, timestamps, delivery status code, and bounce disposition. We do <em>not</em> store message bodies after handoff to the queue.</li><li><strong>Rate-limit counters</strong>: short-window send counts per DID used to enforce hourly and daily limits.</li><li><strong>Bounce records</strong>: inbound DSN classifications per DID so we can suspend senders with pathological bounce rates.</li><li><strong>Suppression list</strong>: recipients who used the one-click unsubscribe header, keyed per sender DID.</li><li><strong>IP addresses</strong> of SMTP clients, kept only in transient logs for abuse investigation and rotated out under the retention schedule below.</li></ul></section><section class=\"section\"><span class=\"step-marker\">§2 · What we do not collect</span><h2>Data we deliberately avoid</h2><p>We do not retain full message bodies past delivery. We do not set web tracking cookies, fingerprint browsers, or embed third-party analytics on any of our pages. We do not sell or rent member data to anyone, under any circumstances.</p></section><section class=\"section\"><span class=\"step-marker\">§3 · Retention</span><h2>How long we keep it</h2><ul class=\"bullets\"><li><strong>Terminal message logs</strong> (sent, bounced): 30 days, then purged.</li><li><strong>Rate-limit counters</strong>: 48 hours rolling window.</li><li><strong>Suppression entries</strong>: for the life of the member record — unsubscribes must persist.</li><li><strong>Member record</strong>: indefinitely while active; removed on request.</li></ul></section><section class=\"section\"><span class=\"step-marker\">§4 · Sharing</span><h2>Who else sees this</h2><p>Send events and bounce outcomes are evaluated by our internal Trust &amp; Safety rules engine (Osprey) to derive reputation labels (e.g. <code>highly_trusted</code>, <code>auto_suspended</code>). Labels are published via an atproto labeler and are intentionally public — any consumer of the labeler can read them. We do not share message content, recipient lists, or API keys with anyone.</p></section><section class=\"section\"><span class=\"step-marker\">§5 · Your rights</span><h2>Access, correction, deletion</h2><p>You can fetch your member status and current labels via the API-key-authenticated <code>/member/status</code> endpoint. To correct or delete your member record, write to <a href=\"mailto:postmaster@atmos.email\">postmaster@atmos.email</a> from a mailbox you can prove control of (or sign the request with your DID's signing key). We respond to verified requests within 14 days.</p></section><section class=\"section\"><span class=\"step-marker\">§6 · Security</span><h2>How we protect it</h2><p>API keys are stored as salted hashes. DKIM private keys are encrypted at rest. Host access is restricted to the LLC's operations team and uses hardware-keyed SSH. If we discover a breach that exposes member data we will notify affected members without undue delay.</p></section><section class=\"section\"><span class=\"step-marker\">§7 · Contact</span><h2>Reach us</h2><p>Atmosphere Mail LLC — <a href=\"mailto:postmaster@atmos.email\">postmaster@atmos.email</a></p></section>") 1110 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</p><p class=\"lede\">Atmosphere Mail LLC operates the relay. Here is exactly what we collect, why, and for how long.</p><section class=\"section\"><span class=\"step-marker\">§1 · What we collect</span><h2>The data we hold</h2><ul class=\"bullets\"><li><strong>Your DID</strong> and registered sending domain(s).</li><li><strong>A salted hash of your API key</strong> — the plaintext key is only ever shown once, at enrollment.</li><li><strong>DKIM keypairs</strong> issued to your domain. Private keys are stored encrypted at rest and never leave our servers.</li><li><strong>Send logs</strong>: per-message sender DID, recipient address, From/To headers, timestamps, delivery status code, and bounce disposition. We do <em>not</em> store message bodies after handoff to the queue.</li><li><strong>Rate-limit counters</strong>: short-window send counts per DID used to enforce hourly and daily limits.</li><li><strong>Bounce records</strong>: inbound DSN classifications per DID so we can suspend senders with pathological bounce rates.</li><li><strong>Suppression list</strong>: recipients who used the one-click unsubscribe header, keyed per sender DID.</li><li><strong>IP addresses</strong> of SMTP clients, kept only in transient logs for abuse investigation and rotated out under the retention schedule below.</li></ul></section><section class=\"section\"><span class=\"step-marker\">§2 · What we do not collect</span><h2>Data we deliberately avoid</h2><p>We do not retain full message bodies past delivery. We do not set web tracking cookies, fingerprint browsers, or embed third-party analytics on any of our pages. We do not sell or rent member data to anyone, under any circumstances.</p></section><section class=\"section\"><span class=\"step-marker\">§3 · Retention</span><h2>How long we keep it</h2><ul class=\"bullets\"><li><strong>Terminal message logs</strong> (sent, bounced): 30 days, then purged.</li><li><strong>Rate-limit counters</strong>: 48 hours rolling window.</li><li><strong>Suppression entries</strong>: for the life of the member record — unsubscribes must persist.</li><li><strong>Member record</strong>: indefinitely while active; removed on request.</li></ul></section><section class=\"section\"><span class=\"step-marker\">§4 · Sharing</span><h2>Who else sees this</h2><p>Send events and bounce outcomes are evaluated by our internal Trust &amp; Safety rules engine (Osprey) to derive reputation labels (e.g. <code>highly_trusted</code>, <code>auto_suspended</code>). Labels are published via an atproto labeler and are intentionally public — any consumer of the labeler can read them. We do not share message content, recipient lists, or API keys with anyone.</p></section><section class=\"section\"><span class=\"step-marker\">§5 · Your rights</span><h2>Access, correction, deletion</h2><p>You can fetch your member status and current labels via the API-key-authenticated <code>/member/status</code> endpoint. To correct or delete your member record, write to <a href=\"mailto:postmaster@atmos.email\">postmaster@atmos.email</a> from a mailbox you can prove control of (or sign the request with your DID's signing key). We respond to verified requests within 14 days.</p></section><section class=\"section\"><span class=\"step-marker\">§6 · Security</span><h2>How we protect it</h2><p>API keys are stored as salted hashes. DKIM private keys are encrypted at rest. Host access is restricted to the LLC's operations team and uses hardware-keyed SSH. If we discover a breach that exposes member data we will notify affected members without undue delay.</p></section><section class=\"section\"><span class=\"step-marker\">§7 · Contact</span><h2>Reach us</h2><p>Atmosphere Mail LLC — <a href=\"mailto:postmaster@atmos.email\">postmaster@atmos.email</a></p></section>") 935 1111 if templ_7745c5c3_Err != nil { 936 1112 return templ_7745c5c3_Err 937 1113 } 938 1114 return nil 939 1115 }) 940 - templ_7745c5c3_Err = publicLayout("Privacy Policy", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var47), templ_7745c5c3_Buffer) 1116 + templ_7745c5c3_Err = publicLayout("Privacy Policy", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var53), templ_7745c5c3_Buffer) 941 1117 if templ_7745c5c3_Err != nil { 942 1118 return templ_7745c5c3_Err 943 1119 } ··· 964 1140 }() 965 1141 } 966 1142 ctx = templ.InitializeContext(ctx) 967 - templ_7745c5c3_Var49 := templ.GetChildren(ctx) 968 - if templ_7745c5c3_Var49 == nil { 969 - templ_7745c5c3_Var49 = templ.NopComponent 1143 + templ_7745c5c3_Var55 := templ.GetChildren(ctx) 1144 + if templ_7745c5c3_Var55 == nil { 1145 + templ_7745c5c3_Var55 = templ.NopComponent 970 1146 } 971 1147 ctx = templ.ClearChildren(ctx) 972 - templ_7745c5c3_Var50 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 1148 + templ_7745c5c3_Var56 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 973 1149 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 974 1150 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 975 1151 if !templ_7745c5c3_IsBuffer { ··· 981 1157 }() 982 1158 } 983 1159 ctx = templ.InitializeContext(ctx) 984 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<h1 class=\"masthead masthead-sub\">Acceptable Use</h1><p class=\"effective\">Effective ") 1160 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<h1 class=\"masthead masthead-sub\">Acceptable Use</h1><p class=\"effective\">Effective ") 985 1161 if templ_7745c5c3_Err != nil { 986 1162 return templ_7745c5c3_Err 987 1163 } 988 - var templ_7745c5c3_Var51 string 989 - templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(legalEffectiveDate()) 1164 + var templ_7745c5c3_Var57 string 1165 + templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(legalEffectiveDate()) 990 1166 if templ_7745c5c3_Err != nil { 991 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1195, Col: 55} 1167 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1504, Col: 55} 992 1168 } 993 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51)) 1169 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57)) 994 1170 if templ_7745c5c3_Err != nil { 995 1171 return templ_7745c5c3_Err 996 1172 } 997 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</p><p class=\"lede\">Shared-IP email only works when every member sends responsibly. These rules are how we protect the pool's reputation on your behalf.</p><section class=\"section\"><span class=\"step-marker\">§1 · Your own mail only</span><h2>Send on your own behalf</h2><p>The relay is for mail originating from <em>you</em> — transactional, operational, or personal correspondence sent from the domain you enrolled. Do not resell relay credentials, relay mail for third parties, or use the service as a public-facing SMTP gateway.</p></section><section class=\"section\"><span class=\"step-marker\">§2 · No spam</span><h2>No unsolicited bulk mail</h2><p>You must have prior permission from every recipient. Scraped lists, purchased lists, and \"opt-out only\" mailing strategies are prohibited. We enforce volume caps, bounce rate thresholds, domain-spray detection, and velocity rules; crossing any of them will cost your DID its reputation labels and may trigger automatic suspension.</p></section><section class=\"section\"><span class=\"step-marker\">§3 · No abuse</span><h2>Prohibited content</h2><ul class=\"bullets\"><li>Phishing, credential harvesting, or impersonation of third parties.</li><li>Malware, ransomware, exploit payloads, or links to them.</li><li>Fraud, scams, illegal goods, or content that violates US federal or Washington state law.</li><li>Content targeting or harassing an individual, or inciting violence against a group.</li><li>Unauthorized use of another person's name, likeness, or identity.</li></ul></section><section class=\"section\"><span class=\"step-marker\">§4 · Honor unsubscribes</span><h2>One-click unsubscribe</h2><p>Every message sent through the relay carries RFC 8058 <code>List-Unsubscribe</code> and <code>List-Unsubscribe-Post</code> headers. When a recipient triggers an unsubscribe, that address is added to your suppression list and further attempts to send to it will be quietly dropped. Attempting to work around the suppression list — by re-enrolling the same address under a variant, rotating domains, or stripping the header — is a terminating offense.</p></section><section class=\"section\"><span class=\"step-marker\">§5 · Cooperate with investigations</span><h2>Abuse complaints</h2><p>If we receive an abuse report about mail from your DID we may ask you to explain it. Failure to respond within a reasonable window (48 hours by default) can result in suspension pending review. Report abuse by others to <a href=\"mailto:abuse@atmos.email\">abuse@atmos.email</a>.</p></section><section class=\"section\"><span class=\"step-marker\">§6 · Consequences</span><h2>What happens when you break the rules</h2><p>We apply the lightest intervention that fixes the problem. In order of increasing severity: a reputation label that throttles hourly volume; a temporary suspension pending operator review; permanent removal of the DID and its domains from the relay. Appeals go to <a href=\"mailto:postmaster@atmos.email\">postmaster@atmos.email</a>.</p></section>") 1173 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</p><p class=\"lede\">Shared-IP email only works when every member sends responsibly. These rules are how we protect the pool's reputation on your behalf.</p><section class=\"section\"><span class=\"step-marker\">§1 · Your own mail only</span><h2>Send on your own behalf</h2><p>The relay is for mail originating from <em>you</em> — transactional, operational, or personal correspondence sent from the domain you enrolled. Do not resell relay credentials, relay mail for third parties, or use the service as a public-facing SMTP gateway.</p></section><section class=\"section\"><span class=\"step-marker\">§2 · No spam</span><h2>No unsolicited bulk mail</h2><p>You must have prior permission from every recipient. Scraped lists, purchased lists, and \"opt-out only\" mailing strategies are prohibited. We enforce volume caps, bounce rate thresholds, domain-spray detection, and velocity rules; crossing any of them will cost your DID its reputation labels and may trigger automatic suspension.</p></section><section class=\"section\"><span class=\"step-marker\">§3 · No abuse</span><h2>Prohibited content</h2><ul class=\"bullets\"><li>Phishing, credential harvesting, or impersonation of third parties.</li><li>Malware, ransomware, exploit payloads, or links to them.</li><li>Fraud, scams, illegal goods, or content that violates US federal or Washington state law.</li><li>Content targeting or harassing an individual, or inciting violence against a group.</li><li>Unauthorized use of another person's name, likeness, or identity.</li></ul></section><section class=\"section\"><span class=\"step-marker\">§4 · Honor unsubscribes</span><h2>One-click unsubscribe</h2><p>Every message sent through the relay carries RFC 8058 <code>List-Unsubscribe</code> and <code>List-Unsubscribe-Post</code> headers. When a recipient triggers an unsubscribe, that address is added to your suppression list and further attempts to send to it will be quietly dropped. Attempting to work around the suppression list — by re-enrolling the same address under a variant, rotating domains, or stripping the header — is a terminating offense.</p></section><section class=\"section\"><span class=\"step-marker\">§5 · Cooperate with investigations</span><h2>Abuse complaints</h2><p>If we receive an abuse report about mail from your DID we may ask you to explain it. Failure to respond within a reasonable window (48 hours by default) can result in suspension pending review. Report abuse by others to <a href=\"mailto:abuse@atmos.email\">abuse@atmos.email</a>.</p></section><section class=\"section\"><span class=\"step-marker\">§6 · Consequences</span><h2>What happens when you break the rules</h2><p>We apply the lightest intervention that fixes the problem. In order of increasing severity: a reputation label that throttles hourly volume; a temporary suspension pending operator review; permanent removal of the DID and its domains from the relay. Appeals go to <a href=\"mailto:postmaster@atmos.email\">postmaster@atmos.email</a>.</p></section>") 998 1174 if templ_7745c5c3_Err != nil { 999 1175 return templ_7745c5c3_Err 1000 1176 } 1001 1177 return nil 1002 1178 }) 1003 - templ_7745c5c3_Err = publicLayout("Acceptable Use Policy", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var50), templ_7745c5c3_Buffer) 1179 + templ_7745c5c3_Err = publicLayout("Acceptable Use Policy", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var56), templ_7745c5c3_Buffer) 1004 1180 if templ_7745c5c3_Err != nil { 1005 1181 return templ_7745c5c3_Err 1006 1182 } ··· 1027 1203 }() 1028 1204 } 1029 1205 ctx = templ.InitializeContext(ctx) 1030 - templ_7745c5c3_Var52 := templ.GetChildren(ctx) 1031 - if templ_7745c5c3_Var52 == nil { 1032 - templ_7745c5c3_Var52 = templ.NopComponent 1206 + templ_7745c5c3_Var58 := templ.GetChildren(ctx) 1207 + if templ_7745c5c3_Var58 == nil { 1208 + templ_7745c5c3_Var58 = templ.NopComponent 1033 1209 } 1034 1210 ctx = templ.ClearChildren(ctx) 1035 - templ_7745c5c3_Var53 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 1211 + templ_7745c5c3_Var59 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 1036 1212 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 1037 1213 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 1038 1214 if !templ_7745c5c3_IsBuffer { ··· 1044 1220 }() 1045 1221 } 1046 1222 ctx = templ.InitializeContext(ctx) 1047 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<h1 class=\"masthead masthead-sub\">About</h1><p class=\"lede\">Atmosphere Mail is a cooperative SMTP relay for atproto identities. It lets small senders get their transactional mail past Gmail's reputation filters without paying enterprise SaaS prices or handing over their identity to a third-party provider.</p><section class=\"section\"><span class=\"step-marker\">§1 · Who we are</span><h2>The person behind this</h2><p>Atmosphere Mail is built and operated by <a href=\"https://bsky.app/profile/scottlanoue.com\" rel=\"me\">") 1223 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<h1 class=\"masthead masthead-sub\">About</h1><p class=\"lede\">Atmosphere Mail is a cooperative SMTP relay for atproto identities. It lets small senders get their transactional mail past Gmail's reputation filters without paying enterprise SaaS prices or handing over their identity to a third-party provider.</p><section class=\"section\"><span class=\"step-marker\">§1 · Who we are</span><h2>The person behind this</h2><p>Atmosphere Mail is built and operated by <a href=\"https://bsky.app/profile/scottlanoue.com\" rel=\"me\">") 1048 1224 if templ_7745c5c3_Err != nil { 1049 1225 return templ_7745c5c3_Err 1050 1226 } 1051 - var templ_7745c5c3_Var54 string 1052 - templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs("@scottlanoue.com") 1227 + var templ_7745c5c3_Var60 string 1228 + templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs("@scottlanoue.com") 1053 1229 if templ_7745c5c3_Err != nil { 1054 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1300, Col: 84} 1230 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1609, Col: 84} 1055 1231 } 1056 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54)) 1232 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60)) 1057 1233 if templ_7745c5c3_Err != nil { 1058 1234 return templ_7745c5c3_Err 1059 1235 } 1060 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</a> — a Washington-based software developer working on open-source infrastructure for the atproto ecosystem.</p><p>Freedom in software comes from open source and shared tooling. atproto already provides the portable identity primitive that other protocols still lack; email just needed the plumbing to route around the reputation bottleneck. The relay is MIT-licensed, the Osprey rules live in the open, and the labeler feed is public, so anyone with the source can audit how deliverability decisions are made.</p></section><section class=\"section\"><span class=\"step-marker\">§2 · The entity</span><h2>Who's on the contract</h2><p>The relay is operated by <strong>Atmosphere Mail LLC</strong>, a Washington State limited liability company formed in 2026 to give the project a stable legal counterparty. The LLC exists to sign agreements, hold infrastructure, and absorb liability on behalf of the cooperative — it does not operate for profit.</p></section><section class=\"section\"><span class=\"step-marker\">§3 · How it works</span><h2>Architecture</h2><p>Domain ownership is verified via DNS TXT record — the same primitive used by Let's Encrypt and Google Workspace. Each enrolled domain is issued a DKIM keypair (RSA and Ed25519) whose public keys you publish in DNS. The relay signs outbound mail on your behalf, tracks delivery and bounce outcomes, and emits those events to a Trust &amp; Safety rules engine (Osprey) that labels reputation via an atproto labeler. Labels drive throttling, warming, and suspension decisions.</p></section><section class=\"section\"><span class=\"step-marker\">§4 · Source</span><h2>Open, auditable</h2><p>The relay, admin UI, Osprey rules, and labeler code all live at <a href=\"https://tangled.org/scottlanoue.com/atmosphere-mail\">tangled.org/scottlanoue.com/atmosphere-mail</a>. Bug reports and patches welcome.</p></section><section class=\"section\"><span class=\"step-marker\">§5 · Contact</span><h2>Reach us</h2><p>Operational questions: <a href=\"mailto:postmaster@atmos.email\">postmaster@atmos.email</a>. Abuse reports: <a href=\"mailto:abuse@atmos.email\">abuse@atmos.email</a>.</p></section>") 1236 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</a> — a Washington-based software developer working on open-source infrastructure for the atproto ecosystem.</p><p>Freedom in software comes from open source and shared tooling. atproto already provides the portable identity primitive that other protocols still lack; email just needed the plumbing to route around the reputation bottleneck. The relay is MIT-licensed, the Osprey rules live in the open, and the labeler feed is public, so anyone with the source can audit how deliverability decisions are made.</p></section><section class=\"section\"><span class=\"step-marker\">§2 · The entity</span><h2>Who's on the contract</h2><p>The relay is operated by <strong>Atmosphere Mail LLC</strong>, a Washington State limited liability company formed in 2026 to give the project a stable legal counterparty. The LLC exists to sign agreements, hold infrastructure, and absorb liability on behalf of the cooperative — it does not operate for profit.</p></section><section class=\"section\"><span class=\"step-marker\">§3 · How it works</span><h2>Architecture</h2><p>Domain ownership is verified via DNS TXT record — the same primitive used by Let's Encrypt and Google Workspace. Each enrolled domain is issued a DKIM keypair (RSA and Ed25519) whose public keys you publish in DNS. The relay signs outbound mail on your behalf, tracks delivery and bounce outcomes, and emits those events to a Trust &amp; Safety rules engine (Osprey) that labels reputation via an atproto labeler. Labels drive throttling, warming, and suspension decisions.</p></section><section class=\"section\"><span class=\"step-marker\">§4 · Source</span><h2>Open, auditable</h2><p>The relay, admin UI, Osprey rules, and labeler code all live at <a href=\"https://tangled.org/scottlanoue.com/atmosphere-mail\">tangled.org/scottlanoue.com/atmosphere-mail</a>. Bug reports and patches welcome.</p></section><section class=\"section\"><span class=\"step-marker\">§5 · Contact</span><h2>Reach us</h2><p>Operational questions: <a href=\"mailto:postmaster@atmos.email\">postmaster@atmos.email</a>. Abuse reports: <a href=\"mailto:abuse@atmos.email\">abuse@atmos.email</a>.</p></section>") 1061 1237 if templ_7745c5c3_Err != nil { 1062 1238 return templ_7745c5c3_Err 1063 1239 } 1064 1240 return nil 1065 1241 }) 1066 - templ_7745c5c3_Err = publicLayout("About", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var53), templ_7745c5c3_Buffer) 1242 + templ_7745c5c3_Err = publicLayout("About", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var59), templ_7745c5c3_Buffer) 1067 1243 if templ_7745c5c3_Err != nil { 1068 1244 return templ_7745c5c3_Err 1069 1245 }
+42 -39
internal/admin/ui/templates/events_templ.go
··· 32 32 HasNext bool 33 33 TotalOnPage int 34 34 Filter EventsFilterState 35 - ShadowView bool 35 + // ShadowView, when true, signals the page is rendering the 36 + // /admin/shadow-verdicts surface. The template skips the filter 37 + // form and adds a banner explaining the shadow-mode convention. 38 + ShadowView bool 36 39 } 37 40 38 41 // EventsFilterState reflects the active query-param filters so the form ··· 119 122 var templ_7745c5c3_Var4 string 120 123 templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(data.Filter.SenderDID) 121 124 if templ_7745c5c3_Err != nil { 122 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 51, Col: 93} 125 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 55, Col: 93} 123 126 } 124 127 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) 125 128 if templ_7745c5c3_Err != nil { ··· 166 169 var templ_7745c5c3_Var5 string 167 170 templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(data.Filter.Since) 168 171 if templ_7745c5c3_Err != nil { 169 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 60, Col: 89} 172 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 64, Col: 89} 170 173 } 171 174 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) 172 175 if templ_7745c5c3_Err != nil { ··· 179 182 var templ_7745c5c3_Var6 string 180 183 templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(data.Filter.Until) 181 184 if templ_7745c5c3_Err != nil { 182 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 61, Col: 89} 185 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 65, Col: 89} 183 186 } 184 187 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) 185 188 if templ_7745c5c3_Err != nil { ··· 246 249 var templ_7745c5c3_Var8 string 247 250 templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(r.EventTimestamp) 248 251 if templ_7745c5c3_Err != nil { 249 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 94, Col: 31} 252 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 98, Col: 31} 250 253 } 251 254 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) 252 255 if templ_7745c5c3_Err != nil { ··· 259 262 var templ_7745c5c3_Var9 string 260 263 templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(r.ActionName) 261 264 if templ_7745c5c3_Err != nil { 262 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 95, Col: 20} 265 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 99, Col: 20} 263 266 } 264 267 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) 265 268 if templ_7745c5c3_Err != nil { ··· 282 285 var templ_7745c5c3_Var10 templ.SafeURL 283 286 templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/admin/members/" + r.SenderDID + "/events")) 284 287 if templ_7745c5c3_Err != nil { 285 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 100, Col: 72} 288 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 104, Col: 72} 286 289 } 287 290 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) 288 291 if templ_7745c5c3_Err != nil { ··· 295 298 var templ_7745c5c3_Var11 string 296 299 templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(r.SenderDID) 297 300 if templ_7745c5c3_Err != nil { 298 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 100, Col: 94} 301 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 104, Col: 94} 299 302 } 300 303 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) 301 304 if templ_7745c5c3_Err != nil { ··· 308 311 var templ_7745c5c3_Var12 string 309 312 templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(truncateDID(r.SenderDID)) 310 313 if templ_7745c5c3_Err != nil { 311 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 101, Col: 37} 314 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 105, Col: 37} 312 315 } 313 316 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) 314 317 if templ_7745c5c3_Err != nil { ··· 326 329 var templ_7745c5c3_Var13 string 327 330 templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(r.SenderDomain) 328 331 if templ_7745c5c3_Err != nil { 329 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 105, Col: 22} 332 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 109, Col: 22} 330 333 } 331 334 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) 332 335 if templ_7745c5c3_Err != nil { ··· 339 342 var templ_7745c5c3_Var14 string 340 343 templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(r.RecipientDomain) 341 344 if templ_7745c5c3_Err != nil { 342 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 106, Col: 25} 345 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 110, Col: 25} 343 346 } 344 347 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) 345 348 if templ_7745c5c3_Err != nil { ··· 353 356 var templ_7745c5c3_Var15 string 354 357 templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(r.SMTPCode) 355 358 if templ_7745c5c3_Err != nil { 356 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 109, Col: 16} 359 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 113, Col: 16} 357 360 } 358 361 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) 359 362 if templ_7745c5c3_Err != nil { ··· 372 375 var templ_7745c5c3_Var16 string 373 376 templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(r.DeliveryStatus) 374 377 if templ_7745c5c3_Err != nil { 375 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 112, Col: 30} 378 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 116, Col: 30} 376 379 } 377 380 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) 378 381 if templ_7745c5c3_Err != nil { ··· 390 393 var templ_7745c5c3_Var17 string 391 394 templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(r.VerdictSummary) 392 395 if templ_7745c5c3_Err != nil { 393 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 115, Col: 31} 396 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 119, Col: 31} 394 397 } 395 398 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) 396 399 if templ_7745c5c3_Err != nil { ··· 432 435 var templ_7745c5c3_Var19 string 433 436 templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.Page)) 434 437 if templ_7745c5c3_Err != nil { 435 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 122, Col: 45} 438 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 126, Col: 45} 436 439 } 437 440 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) 438 441 if templ_7745c5c3_Err != nil { ··· 445 448 var templ_7745c5c3_Var20 string 446 449 templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalOnPage)) 447 450 if templ_7745c5c3_Err != nil { 448 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 122, Col: 89} 451 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 126, Col: 89} 449 452 } 450 453 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) 451 454 if templ_7745c5c3_Err != nil { ··· 463 466 var templ_7745c5c3_Var21 templ.SafeURL 464 467 templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(prevPageURL(data)) 465 468 if templ_7745c5c3_Err != nil { 466 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 126, Col: 31} 469 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 130, Col: 31} 467 470 } 468 471 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) 469 472 if templ_7745c5c3_Err != nil { ··· 482 485 var templ_7745c5c3_Var22 templ.SafeURL 483 486 templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs(nextPageURL(data)) 484 487 if templ_7745c5c3_Err != nil { 485 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 129, Col: 31} 488 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 133, Col: 31} 486 489 } 487 490 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) 488 491 if templ_7745c5c3_Err != nil { ··· 530 533 var templ_7745c5c3_Var24 string 531 534 templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(name) 532 535 if templ_7745c5c3_Err != nil { 533 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 137, Col: 22} 536 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 141, Col: 22} 534 537 } 535 538 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) 536 539 if templ_7745c5c3_Err != nil { ··· 543 546 var templ_7745c5c3_Var25 string 544 547 templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(name) 545 548 if templ_7745c5c3_Err != nil { 546 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 137, Col: 40} 549 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 141, Col: 40} 547 550 } 548 551 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) 549 552 if templ_7745c5c3_Err != nil { ··· 561 564 var templ_7745c5c3_Var26 string 562 565 templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(name) 563 566 if templ_7745c5c3_Err != nil { 564 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 139, Col: 22} 567 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 143, Col: 22} 565 568 } 566 569 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) 567 570 if templ_7745c5c3_Err != nil { ··· 574 577 var templ_7745c5c3_Var27 string 575 578 templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(name) 576 579 if templ_7745c5c3_Err != nil { 577 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 139, Col: 31} 580 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 143, Col: 31} 578 581 } 579 582 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) 580 583 if templ_7745c5c3_Err != nil { ··· 679 682 var templ_7745c5c3_Var31 string 680 683 templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(data.DID) 681 684 if templ_7745c5c3_Err != nil { 682 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 167, Col: 21} 685 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 171, Col: 21} 683 686 } 684 687 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) 685 688 if templ_7745c5c3_Err != nil { ··· 692 695 var templ_7745c5c3_Var32 string 693 696 templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.Total)) 694 697 if templ_7745c5c3_Err != nil { 695 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 172, Col: 38} 698 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 176, Col: 38} 696 699 } 697 700 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) 698 701 if templ_7745c5c3_Err != nil { ··· 705 708 var templ_7745c5c3_Var33 string 706 709 templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.FailureCount)) 707 710 if templ_7745c5c3_Err != nil { 708 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 176, Col: 45} 711 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 180, Col: 45} 709 712 } 710 713 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) 711 714 if templ_7745c5c3_Err != nil { ··· 723 726 var templ_7745c5c3_Var34 string 724 727 templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", ac.Count)) 725 728 if templ_7745c5c3_Err != nil { 726 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 181, Col: 37} 729 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 185, Col: 37} 727 730 } 728 731 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) 729 732 if templ_7745c5c3_Err != nil { ··· 736 739 var templ_7745c5c3_Var35 string 737 740 templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(ac.ActionName) 738 741 if templ_7745c5c3_Err != nil { 739 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 182, Col: 22} 742 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 186, Col: 22} 740 743 } 741 744 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) 742 745 if templ_7745c5c3_Err != nil { ··· 769 772 var templ_7745c5c3_Var36 string 770 773 templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(r.EventTimestamp) 771 774 if templ_7745c5c3_Err != nil { 772 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 204, Col: 36} 775 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 208, Col: 36} 773 776 } 774 777 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) 775 778 if templ_7745c5c3_Err != nil { ··· 782 785 var templ_7745c5c3_Var37 string 783 786 templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(r.ActionName) 784 787 if templ_7745c5c3_Err != nil { 785 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 205, Col: 25} 788 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 209, Col: 25} 786 789 } 787 790 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) 788 791 if templ_7745c5c3_Err != nil { ··· 795 798 var templ_7745c5c3_Var38 string 796 799 templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(r.RecipientDomain) 797 800 if templ_7745c5c3_Err != nil { 798 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 206, Col: 30} 801 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 210, Col: 30} 799 802 } 800 803 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) 801 804 if templ_7745c5c3_Err != nil { ··· 809 812 var templ_7745c5c3_Var39 string 810 813 templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(r.SMTPCode) 811 814 if templ_7745c5c3_Err != nil { 812 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 209, Col: 21} 815 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 213, Col: 21} 813 816 } 814 817 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) 815 818 if templ_7745c5c3_Err != nil { ··· 828 831 var templ_7745c5c3_Var40 string 829 832 templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(r.DeliveryStatus) 830 833 if templ_7745c5c3_Err != nil { 831 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 212, Col: 35} 834 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 216, Col: 35} 832 835 } 833 836 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) 834 837 if templ_7745c5c3_Err != nil { ··· 846 849 var templ_7745c5c3_Var41 string 847 850 templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(r.VerdictSummary) 848 851 if templ_7745c5c3_Err != nil { 849 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 215, Col: 36} 852 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 219, Col: 36} 850 853 } 851 854 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) 852 855 if templ_7745c5c3_Err != nil { ··· 954 957 var templ_7745c5c3_Var45 string 955 958 templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.WindowHours)) 956 959 if templ_7745c5c3_Err != nil { 957 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 244, Col: 47} 960 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 248, Col: 47} 958 961 } 959 962 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) 960 963 if templ_7745c5c3_Err != nil { ··· 967 970 var templ_7745c5c3_Var46 string 968 971 templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalEvents)) 969 972 if templ_7745c5c3_Err != nil { 970 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 244, Col: 97} 973 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 248, Col: 97} 971 974 } 972 975 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) 973 976 if templ_7745c5c3_Err != nil { ··· 995 998 var templ_7745c5c3_Var47 string 996 999 templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(ac.ActionName) 997 1000 if templ_7745c5c3_Err != nil { 998 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 263, Col: 27} 1001 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 267, Col: 27} 999 1002 } 1000 1003 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) 1001 1004 if templ_7745c5c3_Err != nil { ··· 1008 1011 var templ_7745c5c3_Var48 string 1009 1012 templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", ac.Count)) 1010 1013 if templ_7745c5c3_Err != nil { 1011 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 264, Col: 68} 1014 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 268, Col: 68} 1012 1015 } 1013 1016 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48)) 1014 1017 if templ_7745c5c3_Err != nil { ··· 1046 1049 var templ_7745c5c3_Var49 string 1047 1050 templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(vc.ActionName) 1048 1051 if templ_7745c5c3_Err != nil { 1049 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 289, Col: 27} 1052 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 293, Col: 27} 1050 1053 } 1051 1054 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49)) 1052 1055 if templ_7745c5c3_Err != nil { ··· 1059 1062 var templ_7745c5c3_Var50 string 1060 1063 templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", vc.Count)) 1061 1064 if templ_7745c5c3_Err != nil { 1062 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 290, Col: 68} 1065 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/events.templ`, Line: 294, Col: 68} 1063 1066 } 1064 1067 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) 1065 1068 if templ_7745c5c3_Err != nil {
+34
internal/admin/ui/templates/member_detail.templ
··· 24 24 // in a dedicated section on the detail page so ops can see who 25 25 // looked at this DID and what they concluded. 26 26 ReviewHistory []ReviewHistoryEntry 27 + WarmupSeeds int 27 28 } 28 29 29 30 // ReviewHistoryEntry is a single operator decision, flattened for the ··· 109 110 </div> 110 111 } 111 112 </article> 113 + @WarmupSection(m) 112 114 @ReviewHistorySection(m.ReviewHistory) 113 115 </div> 116 + } 117 + 118 + templ WarmupSection(m MemberDetail) { 119 + if m.WarmupSeeds > 0 && m.Status == "active" { 120 + <article id="warmup-section"> 121 + <header>IP Warmup</header> 122 + <p>Send a batch of { fmt.Sprintf("%d", m.WarmupSeeds) } warmup emails from <code>{ m.Domain }</code> to seed addresses.</p> 123 + <button 124 + hx-post={ "/ui/member/" + m.DID + "/warmup" } 125 + hx-target="#warmup-section" 126 + hx-swap="innerHTML" 127 + hx-confirm={ fmt.Sprintf("Send %d warmup emails from %s?", m.WarmupSeeds, m.Domain) } 128 + > 129 + Send warmup batch 130 + </button> 131 + </article> 132 + } 133 + } 134 + 135 + templ WarmupResult(sent, failed int, errors []string) { 136 + <header>IP Warmup</header> 137 + <p> 138 + <strong>{ fmt.Sprintf("%d", sent) }</strong> sent, 139 + <strong>{ fmt.Sprintf("%d", failed) }</strong> failed 140 + </p> 141 + if len(errors) > 0 { 142 + <ul> 143 + for _, e := range errors { 144 + <li><small>{ e }</small></li> 145 + } 146 + </ul> 147 + } 114 148 } 115 149 116 150 // ReviewHistorySection renders the per-member audit trail of operator
+256 -84
internal/admin/ui/templates/member_detail_templ.go
··· 32 32 // in a dedicated section on the detail page so ops can see who 33 33 // looked at this DID and what they concluded. 34 34 ReviewHistory []ReviewHistoryEntry 35 + WarmupSeeds int 35 36 } 36 37 37 38 // ReviewHistoryEntry is a single operator decision, flattened for the ··· 83 84 var templ_7745c5c3_Var3 string 84 85 templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(m.Domain) 85 86 if templ_7745c5c3_Err != nil { 86 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 43, Col: 18} 87 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 44, Col: 18} 87 88 } 88 89 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) 89 90 if templ_7745c5c3_Err != nil { ··· 135 136 var templ_7745c5c3_Var5 string 136 137 templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(m.Domain) 137 138 if templ_7745c5c3_Err != nil { 138 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 53, Col: 17} 139 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 54, Col: 17} 139 140 } 140 141 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) 141 142 if templ_7745c5c3_Err != nil { ··· 148 149 var templ_7745c5c3_Var6 string 149 150 templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(m.DID) 150 151 if templ_7745c5c3_Err != nil { 151 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 54, Col: 19} 152 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 55, Col: 19} 152 153 } 153 154 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) 154 155 if templ_7745c5c3_Err != nil { ··· 169 170 var templ_7745c5c3_Var7 string 170 171 templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", m.SendCount)) 171 172 if templ_7745c5c3_Err != nil { 172 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 67, Col: 41} 173 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 68, Col: 41} 173 174 } 174 175 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) 175 176 if templ_7745c5c3_Err != nil { ··· 182 183 var templ_7745c5c3_Var8 string 183 184 templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", m.HourlyLimit)) 184 185 if templ_7745c5c3_Err != nil { 185 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 69, Col: 43} 186 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 70, Col: 43} 186 187 } 187 188 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) 188 189 if templ_7745c5c3_Err != nil { ··· 195 196 var templ_7745c5c3_Var9 string 196 197 templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", m.DailyLimit)) 197 198 if templ_7745c5c3_Err != nil { 198 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 71, Col: 42} 199 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 72, Col: 42} 199 200 } 200 201 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) 201 202 if templ_7745c5c3_Err != nil { ··· 208 209 var templ_7745c5c3_Var10 string 209 210 templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(m.CreatedAt) 210 211 if templ_7745c5c3_Err != nil { 211 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 78, Col: 22} 212 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 79, Col: 22} 212 213 } 213 214 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) 214 215 if templ_7745c5c3_Err != nil { ··· 226 227 var templ_7745c5c3_Var11 string 227 228 templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Domains (%d)", len(m.AllDomains))) 228 229 if templ_7745c5c3_Err != nil { 229 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 84, Col: 60} 230 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 85, Col: 60} 230 231 } 231 232 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) 232 233 if templ_7745c5c3_Err != nil { ··· 244 245 var templ_7745c5c3_Var12 string 245 246 templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(d) 246 247 if templ_7745c5c3_Err != nil { 247 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 88, Col: 16} 248 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 89, Col: 16} 248 249 } 249 250 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) 250 251 if templ_7745c5c3_Err != nil { ··· 262 263 var templ_7745c5c3_Var13 string 263 264 templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(" · ") 264 265 if templ_7745c5c3_Err != nil { 265 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 90, Col: 23} 266 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 91, Col: 23} 266 267 } 267 268 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) 268 269 if templ_7745c5c3_Err != nil { ··· 275 276 var templ_7745c5c3_Var14 string 276 277 templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(m.AllContactEmails[i]) 277 278 if templ_7745c5c3_Err != nil { 278 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 90, Col: 55} 279 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 91, Col: 55} 279 280 } 280 281 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) 281 282 if templ_7745c5c3_Err != nil { ··· 293 294 var templ_7745c5c3_Var15 string 294 295 templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(" · no contact email") 295 296 if templ_7745c5c3_Err != nil { 296 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 92, Col: 67} 297 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 93, Col: 67} 297 298 } 298 299 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) 299 300 if templ_7745c5c3_Err != nil { ··· 340 341 var templ_7745c5c3_Var16 string 341 342 templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(" ") 342 343 if templ_7745c5c3_Err != nil { 343 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 107, Col: 11} 344 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 108, Col: 11} 344 345 } 345 346 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) 346 347 if templ_7745c5c3_Err != nil { ··· 356 357 if templ_7745c5c3_Err != nil { 357 358 return templ_7745c5c3_Err 358 359 } 360 + templ_7745c5c3_Err = WarmupSection(m).Render(ctx, templ_7745c5c3_Buffer) 361 + if templ_7745c5c3_Err != nil { 362 + return templ_7745c5c3_Err 363 + } 359 364 templ_7745c5c3_Err = ReviewHistorySection(m.ReviewHistory).Render(ctx, templ_7745c5c3_Buffer) 360 365 if templ_7745c5c3_Err != nil { 361 366 return templ_7745c5c3_Err ··· 368 373 }) 369 374 } 370 375 376 + func WarmupSection(m MemberDetail) templ.Component { 377 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 378 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 379 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 380 + return templ_7745c5c3_CtxErr 381 + } 382 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 383 + if !templ_7745c5c3_IsBuffer { 384 + defer func() { 385 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 386 + if templ_7745c5c3_Err == nil { 387 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 388 + } 389 + }() 390 + } 391 + ctx = templ.InitializeContext(ctx) 392 + templ_7745c5c3_Var17 := templ.GetChildren(ctx) 393 + if templ_7745c5c3_Var17 == nil { 394 + templ_7745c5c3_Var17 = templ.NopComponent 395 + } 396 + ctx = templ.ClearChildren(ctx) 397 + if m.WarmupSeeds > 0 && m.Status == "active" { 398 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<article id=\"warmup-section\"><header>IP Warmup</header><p>Send a batch of ") 399 + if templ_7745c5c3_Err != nil { 400 + return templ_7745c5c3_Err 401 + } 402 + var templ_7745c5c3_Var18 string 403 + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", m.WarmupSeeds)) 404 + if templ_7745c5c3_Err != nil { 405 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 122, Col: 56} 406 + } 407 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) 408 + if templ_7745c5c3_Err != nil { 409 + return templ_7745c5c3_Err 410 + } 411 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " warmup emails from <code>") 412 + if templ_7745c5c3_Err != nil { 413 + return templ_7745c5c3_Err 414 + } 415 + var templ_7745c5c3_Var19 string 416 + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(m.Domain) 417 + if templ_7745c5c3_Err != nil { 418 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 122, Col: 94} 419 + } 420 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) 421 + if templ_7745c5c3_Err != nil { 422 + return templ_7745c5c3_Err 423 + } 424 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</code> to seed addresses.</p><button hx-post=\"") 425 + if templ_7745c5c3_Err != nil { 426 + return templ_7745c5c3_Err 427 + } 428 + var templ_7745c5c3_Var20 string 429 + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs("/ui/member/" + m.DID + "/warmup") 430 + if templ_7745c5c3_Err != nil { 431 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 124, Col: 47} 432 + } 433 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) 434 + if templ_7745c5c3_Err != nil { 435 + return templ_7745c5c3_Err 436 + } 437 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" hx-target=\"#warmup-section\" hx-swap=\"innerHTML\" hx-confirm=\"") 438 + if templ_7745c5c3_Err != nil { 439 + return templ_7745c5c3_Err 440 + } 441 + var templ_7745c5c3_Var21 string 442 + templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Send %d warmup emails from %s?", m.WarmupSeeds, m.Domain)) 443 + if templ_7745c5c3_Err != nil { 444 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 127, Col: 87} 445 + } 446 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) 447 + if templ_7745c5c3_Err != nil { 448 + return templ_7745c5c3_Err 449 + } 450 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\">Send warmup batch</button></article>") 451 + if templ_7745c5c3_Err != nil { 452 + return templ_7745c5c3_Err 453 + } 454 + } 455 + return nil 456 + }) 457 + } 458 + 459 + func WarmupResult(sent, failed int, errors []string) templ.Component { 460 + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { 461 + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context 462 + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 463 + return templ_7745c5c3_CtxErr 464 + } 465 + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) 466 + if !templ_7745c5c3_IsBuffer { 467 + defer func() { 468 + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) 469 + if templ_7745c5c3_Err == nil { 470 + templ_7745c5c3_Err = templ_7745c5c3_BufErr 471 + } 472 + }() 473 + } 474 + ctx = templ.InitializeContext(ctx) 475 + templ_7745c5c3_Var22 := templ.GetChildren(ctx) 476 + if templ_7745c5c3_Var22 == nil { 477 + templ_7745c5c3_Var22 = templ.NopComponent 478 + } 479 + ctx = templ.ClearChildren(ctx) 480 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<header>IP Warmup</header><p><strong>") 481 + if templ_7745c5c3_Err != nil { 482 + return templ_7745c5c3_Err 483 + } 484 + var templ_7745c5c3_Var23 string 485 + templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", sent)) 486 + if templ_7745c5c3_Err != nil { 487 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 138, Col: 35} 488 + } 489 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) 490 + if templ_7745c5c3_Err != nil { 491 + return templ_7745c5c3_Err 492 + } 493 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</strong> sent, <strong>") 494 + if templ_7745c5c3_Err != nil { 495 + return templ_7745c5c3_Err 496 + } 497 + var templ_7745c5c3_Var24 string 498 + templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", failed)) 499 + if templ_7745c5c3_Err != nil { 500 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 139, Col: 37} 501 + } 502 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) 503 + if templ_7745c5c3_Err != nil { 504 + return templ_7745c5c3_Err 505 + } 506 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</strong> failed</p>") 507 + if templ_7745c5c3_Err != nil { 508 + return templ_7745c5c3_Err 509 + } 510 + if len(errors) > 0 { 511 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<ul>") 512 + if templ_7745c5c3_Err != nil { 513 + return templ_7745c5c3_Err 514 + } 515 + for _, e := range errors { 516 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<li><small>") 517 + if templ_7745c5c3_Err != nil { 518 + return templ_7745c5c3_Err 519 + } 520 + var templ_7745c5c3_Var25 string 521 + templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(e) 522 + if templ_7745c5c3_Err != nil { 523 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 144, Col: 18} 524 + } 525 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) 526 + if templ_7745c5c3_Err != nil { 527 + return templ_7745c5c3_Err 528 + } 529 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</small></li>") 530 + if templ_7745c5c3_Err != nil { 531 + return templ_7745c5c3_Err 532 + } 533 + } 534 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</ul>") 535 + if templ_7745c5c3_Err != nil { 536 + return templ_7745c5c3_Err 537 + } 538 + } 539 + return nil 540 + }) 541 + } 542 + 371 543 // ReviewHistorySection renders the per-member audit trail of operator 372 544 // decisions on suspensions. Shown even when empty so ops know the 373 545 // feature exists on every member. ··· 387 559 }() 388 560 } 389 561 ctx = templ.InitializeContext(ctx) 390 - templ_7745c5c3_Var17 := templ.GetChildren(ctx) 391 - if templ_7745c5c3_Var17 == nil { 392 - templ_7745c5c3_Var17 = templ.NopComponent 562 + templ_7745c5c3_Var26 := templ.GetChildren(ctx) 563 + if templ_7745c5c3_Var26 == nil { 564 + templ_7745c5c3_Var26 = templ.NopComponent 393 565 } 394 566 ctx = templ.ClearChildren(ctx) 395 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<article><header>Review history</header>") 567 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<article><header>Review history</header>") 396 568 if templ_7745c5c3_Err != nil { 397 569 return templ_7745c5c3_Err 398 570 } 399 571 if len(entries) == 0 { 400 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<p><em>No review actions recorded for this member.</em></p>") 572 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<p><em>No review actions recorded for this member.</em></p>") 401 573 if templ_7745c5c3_Err != nil { 402 574 return templ_7745c5c3_Err 403 575 } 404 576 } else { 405 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<ul>") 577 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<ul>") 406 578 if templ_7745c5c3_Err != nil { 407 579 return templ_7745c5c3_Err 408 580 } 409 581 for _, e := range entries { 410 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<li><strong>") 582 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<li><strong>") 411 583 if templ_7745c5c3_Err != nil { 412 584 return templ_7745c5c3_Err 413 585 } 414 - var templ_7745c5c3_Var18 string 415 - templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(e.ReviewedAt) 586 + var templ_7745c5c3_Var27 string 587 + templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(e.ReviewedAt) 416 588 if templ_7745c5c3_Err != nil { 417 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 128, Col: 28} 589 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 162, Col: 28} 418 590 } 419 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) 591 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) 420 592 if templ_7745c5c3_Err != nil { 421 593 return templ_7745c5c3_Err 422 594 } 423 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</strong> ") 595 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</strong> ") 424 596 if templ_7745c5c3_Err != nil { 425 597 return templ_7745c5c3_Err 426 598 } 427 - var templ_7745c5c3_Var19 string 428 - templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(" — ") 599 + var templ_7745c5c3_Var28 string 600 + templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(" — ") 429 601 if templ_7745c5c3_Err != nil { 430 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 129, Col: 15} 602 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 163, Col: 15} 431 603 } 432 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) 604 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) 433 605 if templ_7745c5c3_Err != nil { 434 606 return templ_7745c5c3_Err 435 607 } ··· 438 610 return templ_7745c5c3_Err 439 611 } 440 612 if e.Actor != "" { 441 - var templ_7745c5c3_Var20 string 442 - templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(" by ") 613 + var templ_7745c5c3_Var29 string 614 + templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(" by ") 443 615 if templ_7745c5c3_Err != nil { 444 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 132, Col: 15} 616 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 166, Col: 15} 445 617 } 446 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) 618 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) 447 619 if templ_7745c5c3_Err != nil { 448 620 return templ_7745c5c3_Err 449 621 } 450 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " <code>") 622 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " <code>") 451 623 if templ_7745c5c3_Err != nil { 452 624 return templ_7745c5c3_Err 453 625 } 454 - var templ_7745c5c3_Var21 string 455 - templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(e.Actor) 626 + var templ_7745c5c3_Var30 string 627 + templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(e.Actor) 456 628 if templ_7745c5c3_Err != nil { 457 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 133, Col: 22} 629 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 167, Col: 22} 458 630 } 459 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) 631 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) 460 632 if templ_7745c5c3_Err != nil { 461 633 return templ_7745c5c3_Err 462 634 } 463 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</code> ") 635 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</code> ") 464 636 if templ_7745c5c3_Err != nil { 465 637 return templ_7745c5c3_Err 466 638 } 467 639 } 468 640 if e.Note != "" { 469 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<div><small>") 641 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<div><small>") 470 642 if templ_7745c5c3_Err != nil { 471 643 return templ_7745c5c3_Err 472 644 } 473 - var templ_7745c5c3_Var22 string 474 - templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(e.Note) 645 + var templ_7745c5c3_Var31 string 646 + templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(e.Note) 475 647 if templ_7745c5c3_Err != nil { 476 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 136, Col: 27} 648 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 170, Col: 27} 477 649 } 478 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) 650 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) 479 651 if templ_7745c5c3_Err != nil { 480 652 return templ_7745c5c3_Err 481 653 } 482 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</small></div>") 654 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</small></div>") 483 655 if templ_7745c5c3_Err != nil { 484 656 return templ_7745c5c3_Err 485 657 } 486 658 } 487 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</li>") 659 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</li>") 488 660 if templ_7745c5c3_Err != nil { 489 661 return templ_7745c5c3_Err 490 662 } 491 663 } 492 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</ul>") 664 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</ul>") 493 665 if templ_7745c5c3_Err != nil { 494 666 return templ_7745c5c3_Err 495 667 } 496 668 } 497 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</article>") 669 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</article>") 498 670 if templ_7745c5c3_Err != nil { 499 671 return templ_7745c5c3_Err 500 672 } ··· 518 690 }() 519 691 } 520 692 ctx = templ.InitializeContext(ctx) 521 - templ_7745c5c3_Var23 := templ.GetChildren(ctx) 522 - if templ_7745c5c3_Var23 == nil { 523 - templ_7745c5c3_Var23 = templ.NopComponent 693 + templ_7745c5c3_Var32 := templ.GetChildren(ctx) 694 + if templ_7745c5c3_Var32 == nil { 695 + templ_7745c5c3_Var32 = templ.NopComponent 524 696 } 525 697 ctx = templ.ClearChildren(ctx) 526 698 if action == "reactivated" { 527 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<span class=\"badge badge-active\">reactivated</span>") 699 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<span class=\"badge badge-active\">reactivated</span>") 528 700 if templ_7745c5c3_Err != nil { 529 701 return templ_7745c5c3_Err 530 702 } 531 703 } else if action == "keep_suspended" { 532 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<span class=\"badge badge-suspended\">kept suspended</span>") 704 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<span class=\"badge badge-suspended\">kept suspended</span>") 533 705 if templ_7745c5c3_Err != nil { 534 706 return templ_7745c5c3_Err 535 707 } 536 708 } else { 537 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<span class=\"badge\">") 709 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<span class=\"badge\">") 538 710 if templ_7745c5c3_Err != nil { 539 711 return templ_7745c5c3_Err 540 712 } 541 - var templ_7745c5c3_Var24 string 542 - templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(action) 713 + var templ_7745c5c3_Var33 string 714 + templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(action) 543 715 if templ_7745c5c3_Err != nil { 544 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 151, Col: 30} 716 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 185, Col: 30} 545 717 } 546 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) 718 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) 547 719 if templ_7745c5c3_Err != nil { 548 720 return templ_7745c5c3_Err 549 721 } 550 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</span>") 722 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</span>") 551 723 if templ_7745c5c3_Err != nil { 552 724 return templ_7745c5c3_Err 553 725 } ··· 572 744 }() 573 745 } 574 746 ctx = templ.InitializeContext(ctx) 575 - templ_7745c5c3_Var25 := templ.GetChildren(ctx) 576 - if templ_7745c5c3_Var25 == nil { 577 - templ_7745c5c3_Var25 = templ.NopComponent 747 + templ_7745c5c3_Var34 := templ.GetChildren(ctx) 748 + if templ_7745c5c3_Var34 == nil { 749 + templ_7745c5c3_Var34 = templ.NopComponent 578 750 } 579 751 ctx = templ.ClearChildren(ctx) 580 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<p>") 752 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<p>") 581 753 if templ_7745c5c3_Err != nil { 582 754 return templ_7745c5c3_Err 583 755 } ··· 585 757 if templ_7745c5c3_Err != nil { 586 758 return templ_7745c5c3_Err 587 759 } 588 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</p>") 760 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</p>") 589 761 if templ_7745c5c3_Err != nil { 590 762 return templ_7745c5c3_Err 591 763 } 592 764 if m.SuspendReason != "" { 593 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<p><small>Reason: ") 765 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<p><small>Reason: ") 594 766 if templ_7745c5c3_Err != nil { 595 767 return templ_7745c5c3_Err 596 768 } 597 - var templ_7745c5c3_Var26 string 598 - templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(m.SuspendReason) 769 + var templ_7745c5c3_Var35 string 770 + templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(m.SuspendReason) 599 771 if templ_7745c5c3_Err != nil { 600 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 160, Col: 37} 772 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 194, Col: 37} 601 773 } 602 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) 774 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) 603 775 if templ_7745c5c3_Err != nil { 604 776 return templ_7745c5c3_Err 605 777 } 606 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</small></p>") 778 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</small></p>") 607 779 if templ_7745c5c3_Err != nil { 608 780 return templ_7745c5c3_Err 609 781 } 610 782 } 611 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<div role=\"group\">") 783 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<div role=\"group\">") 612 784 if templ_7745c5c3_Err != nil { 613 785 return templ_7745c5c3_Err 614 786 } 615 787 if m.Status == "active" { 616 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<button class=\"secondary\" hx-post=\"") 788 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<button class=\"secondary\" hx-post=\"") 617 789 if templ_7745c5c3_Err != nil { 618 790 return templ_7745c5c3_Err 619 791 } 620 - var templ_7745c5c3_Var27 string 621 - templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs("/ui/member/" + m.DID + "/suspend") 792 + var templ_7745c5c3_Var36 string 793 + templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs("/ui/member/" + m.DID + "/suspend") 622 794 if templ_7745c5c3_Err != nil { 623 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 166, Col: 48} 795 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 200, Col: 48} 624 796 } 625 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) 797 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) 626 798 if templ_7745c5c3_Err != nil { 627 799 return templ_7745c5c3_Err 628 800 } 629 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\" hx-target=\"#member-status\" hx-swap=\"innerHTML\" hx-confirm=\"Suspend this member?\">Suspend</button>") 801 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" hx-target=\"#member-status\" hx-swap=\"innerHTML\" hx-confirm=\"Suspend this member?\">Suspend</button>") 630 802 if templ_7745c5c3_Err != nil { 631 803 return templ_7745c5c3_Err 632 804 } 633 805 } else { 634 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<button hx-post=\"") 806 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<button hx-post=\"") 635 807 if templ_7745c5c3_Err != nil { 636 808 return templ_7745c5c3_Err 637 809 } 638 - var templ_7745c5c3_Var28 string 639 - templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs("/ui/member/" + m.DID + "/reactivate") 810 + var templ_7745c5c3_Var37 string 811 + templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs("/ui/member/" + m.DID + "/reactivate") 640 812 if templ_7745c5c3_Err != nil { 641 - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 175, Col: 51} 813 + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/member_detail.templ`, Line: 209, Col: 51} 642 814 } 643 - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) 815 + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) 644 816 if templ_7745c5c3_Err != nil { 645 817 return templ_7745c5c3_Err 646 818 } 647 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\" hx-target=\"#member-status\" hx-swap=\"innerHTML\" hx-confirm=\"Reactivate this member?\">Reactivate</button>") 819 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\" hx-target=\"#member-status\" hx-swap=\"innerHTML\" hx-confirm=\"Reactivate this member?\">Reactivate</button>") 648 820 if templ_7745c5c3_Err != nil { 649 821 return templ_7745c5c3_Err 650 822 } 651 823 } 652 - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</div>") 824 + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</div>") 653 825 if templ_7745c5c3_Err != nil { 654 826 return templ_7745c5c3_Err 655 827 }
+246 -9
internal/admin/ui/templates/recover.go
··· 16 16 "io" 17 17 "strings" 18 18 19 + "atmosphere-mail/internal/relaystore" 20 + 19 21 "github.com/a-h/templ" 20 22 ) 21 23 ··· 40 42 MessageErr bool 41 43 } 42 44 45 + // RecoverSelectDomainData drives the post-OAuth domain picker for DIDs 46 + // with more than one enrolled sending domain. 47 + type RecoverSelectDomainData struct { 48 + DID string 49 + Domains []relaystore.MemberDomain 50 + ExpiresAt string 51 + } 52 + 43 53 // RecoverCompleteData is the one-time new-key-reveal view model. 44 54 type RecoverCompleteData struct { 45 55 DID string ··· 47 57 APIKey string 48 58 } 49 59 50 - // RecoverLanding renders the /account entry page: a single-field form 51 - // asking for the sending domain, with an optional inline error banner. 60 + // RecoverLanding renders the /account entry page: a handle/DID form with 61 + // an optional inline error banner. 52 62 func RecoverLanding(errMsg string) templ.Component { 53 63 return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error { 54 64 inner := templ.ComponentFunc(func(_ context.Context, w io.Writer) error { 55 65 var b strings.Builder 56 66 b.WriteString(`<h1 class="masthead masthead-sub">Account</h1>`) 57 - b.WriteString(`<p class="lede" style="margin-bottom: 1.25rem;">Sign in with your atproto PDS to manage an enrolled domain. You can rotate your API key, update your contact email, or re-check your DKIM records.</p>`) 67 + b.WriteString(`<p class="lede" style="margin-bottom: 1.25rem;">Sign in with your handle to manage an enrolled domain. You can rotate your API key, update your contact email, or re-check your DKIM records.</p>`) 58 68 b.WriteString(`<section class="section" style="margin-top: 1.25rem; padding-top: 0.75rem;">`) 59 - b.WriteString(`<h2 style="margin-bottom: 0.75rem;">Which domain?</h2>`) 69 + b.WriteString(`<h2 style="margin-bottom: 0.75rem;">Sign in</h2>`) 60 70 if errMsg != "" { 61 71 fmt.Fprintf(&b, `<div class="error-note" role="alert">%s</div>`, html.EscapeString(errMsg)) 62 72 } 63 - b.WriteString(`<form method="POST" action="/account/start">`) 64 - b.WriteString(`<label for="domain">Sending domain</label>`) 65 - b.WriteString(`<small>The domain you originally enrolled. We'll look up the DID and start an OAuth handshake against your PDS.</small>`) 66 - b.WriteString(`<input type="text" id="domain" name="domain" required placeholder="mail.yourhandle.com" pattern="[a-z0-9][a-z0-9\-.]*\.[a-z]{2,}" autocomplete="off" spellcheck="false" autocapitalize="off">`) 67 - b.WriteString(`<button type="submit">Sign in</button>`) 73 + b.WriteString(`<form id="account-form" method="POST" action="/account/start">`) 74 + b.WriteString(`<label for="identity">Your handle</label>`) 75 + b.WriteString(`<small>The <a href="https://atproto.com/specs/handle">handle</a> of the account you enrolled with. You can also paste a full <code>did:plc:…</code>.</small>`) 76 + b.WriteString(`<input type="text" id="identity" name="identity" placeholder="alice.bsky.social" required autocomplete="off" spellcheck="false" autocapitalize="off">`) 77 + b.WriteString(`<div id="resolver-hint" class="resolver-hint" aria-live="polite"></div>`) 78 + b.WriteString(`<input type="hidden" id="did" name="did" value="">`) 79 + b.WriteString(`<button type="submit" id="account-submit" data-default-text="Sign in">Sign in</button>`) 68 80 b.WriteString(`</form>`) 69 81 b.WriteString(`<p class="section-lede" style="margin-top: 1rem; margin-bottom: 0;">Not enrolled yet? <a href="/enroll">Start here</a>.</p>`) 82 + b.WriteString(`<script> 83 + (function() { 84 + var SEARCH_API = 'https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead'; 85 + var DEBOUNCE_MS = 250; 86 + var MIN_QUERY = 2; 87 + var MAX_RESULTS = 6; 88 + 89 + var form = document.getElementById('account-form'); 90 + var identity = document.getElementById('identity'); 91 + var didField = document.getElementById('did'); 92 + var hint = document.getElementById('resolver-hint'); 93 + var submit = document.getElementById('account-submit'); 94 + 95 + var debounceTimer = null; 96 + var abortCtrl = null; 97 + var activeIndex = -1; 98 + var currentResults = []; 99 + 100 + function esc(s) { 101 + return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); 102 + } 103 + function isDID(s) { 104 + return /^did:(plc|web):[A-Za-z0-9._%\-]+$/.test(s.trim()); 105 + } 106 + function setHint(text, cls) { 107 + hint.textContent = text; 108 + hint.className = 'resolver-hint ' + (cls || ''); 109 + } 110 + 111 + var wrapper = document.createElement('div'); 112 + wrapper.className = 'handle-input-wrapper'; 113 + identity.parentElement.insertBefore(wrapper, identity); 114 + wrapper.appendChild(identity); 115 + wrapper.parentElement.insertBefore(hint, wrapper.nextSibling); 116 + 117 + var dropdown = document.createElement('div'); 118 + dropdown.className = 'handle-suggestions'; 119 + dropdown.setAttribute('role', 'listbox'); 120 + dropdown.style.display = 'none'; 121 + wrapper.appendChild(dropdown); 122 + identity.setAttribute('role', 'combobox'); 123 + identity.setAttribute('aria-autocomplete', 'list'); 124 + identity.setAttribute('aria-expanded', 'false'); 125 + 126 + function renderSuggestions(results) { 127 + if (!results.length) { 128 + dropdown.style.display = 'none'; 129 + identity.setAttribute('aria-expanded', 'false'); 130 + return; 131 + } 132 + dropdown.innerHTML = results.map(function(r, i) { 133 + return '<div class="handle-suggestion" role="option" data-index="' + i + '" data-handle="' + esc(r.handle) + '">' 134 + + (r.avatar 135 + ? '<img src="' + esc(r.avatar) + '" alt="" class="suggestion-avatar"/>' 136 + : '<div class="suggestion-avatar-placeholder"></div>') 137 + + '<div class="suggestion-text">' 138 + + '<span class="suggestion-name">' + esc(r.displayName) + '</span>' 139 + + '<span class="suggestion-handle">@' + esc(r.handle) + '</span>' 140 + + '</div></div>'; 141 + }).join(''); 142 + dropdown.style.display = ''; 143 + identity.setAttribute('aria-expanded', 'true'); 144 + } 145 + 146 + function updateActive() { 147 + var items = dropdown.querySelectorAll('.handle-suggestion'); 148 + for (var i = 0; i < items.length; i++) { 149 + if (i === activeIndex) items[i].classList.add('active'); 150 + else items[i].classList.remove('active'); 151 + } 152 + } 153 + 154 + function selectHandle(handle) { 155 + identity.value = handle; 156 + dropdown.style.display = 'none'; 157 + identity.setAttribute('aria-expanded', 'false'); 158 + currentResults = []; 159 + activeIndex = -1; 160 + resolve(handle); 161 + } 162 + 163 + function searchHandles(query) { 164 + if (abortCtrl) abortCtrl.abort(); 165 + if (query.length < MIN_QUERY) return Promise.resolve([]); 166 + abortCtrl = new AbortController(); 167 + return fetch(SEARCH_API + '?q=' + encodeURIComponent(query) + '&limit=' + MAX_RESULTS, { signal: abortCtrl.signal }) 168 + .then(function(r) { return r.ok ? r.json() : { actors: [] }; }) 169 + .then(function(data) { 170 + return (data.actors || []).map(function(a) { 171 + return { handle: a.handle, displayName: a.displayName || a.handle, avatar: a.avatar || null }; 172 + }); 173 + }) 174 + .catch(function() { return []; }); 175 + } 176 + 177 + function debouncedSearch(query) { 178 + if (debounceTimer) clearTimeout(debounceTimer); 179 + if (query.length < MIN_QUERY) { renderSuggestions([]); return; } 180 + debounceTimer = setTimeout(function() { 181 + searchHandles(query).then(function(results) { 182 + currentResults = results; 183 + activeIndex = -1; 184 + renderSuggestions(results); 185 + }); 186 + }, DEBOUNCE_MS); 187 + } 188 + 189 + async function resolve(raw) { 190 + var v = (raw || '').replace(/^@/, '').trim(); 191 + if (!v) { setHint('', ''); didField.value = ''; return; } 192 + if (isDID(v)) { setHint(v, 'is-ok'); didField.value = v; return; } 193 + setHint('Resolving ' + v + '…', 'is-loading'); 194 + didField.value = ''; 195 + try { 196 + var r = await fetch('/enroll/resolve?handle=' + encodeURIComponent(v), { 197 + headers: { Accept: 'application/json' }, 198 + }); 199 + if (!r.ok) { 200 + var body = await r.json().catch(function() { return {error:'resolution failed'}; }); 201 + setHint(body.error || 'resolution failed', 'is-err'); 202 + return; 203 + } 204 + var data = await r.json(); 205 + if (data.did) { 206 + setHint(data.did, 'is-ok'); 207 + didField.value = data.did; 208 + } 209 + } catch (e) { 210 + setHint('Network error — try again or paste a DID directly', 'is-err'); 211 + } 212 + } 213 + 214 + identity.addEventListener('input', function() { 215 + var q = identity.value.trim().replace(/^@/, ''); 216 + if (didField.value) { didField.value = ''; setHint('', ''); } 217 + if (isDID(q)) { 218 + renderSuggestions([]); 219 + setHint(q, 'is-ok'); 220 + didField.value = q; 221 + return; 222 + } 223 + debouncedSearch(q); 224 + }); 225 + 226 + identity.addEventListener('keydown', function(e) { 227 + if (!currentResults.length) return; 228 + if (e.key === 'ArrowDown') { 229 + e.preventDefault(); 230 + activeIndex = Math.min(activeIndex + 1, currentResults.length - 1); 231 + updateActive(); 232 + } else if (e.key === 'ArrowUp') { 233 + e.preventDefault(); 234 + activeIndex = Math.max(activeIndex - 1, 0); 235 + updateActive(); 236 + } else if (e.key === 'Enter' && activeIndex >= 0) { 237 + e.preventDefault(); 238 + e.stopPropagation(); 239 + selectHandle(currentResults[activeIndex].handle); 240 + } else if (e.key === 'Escape') { 241 + dropdown.style.display = 'none'; 242 + identity.setAttribute('aria-expanded', 'false'); 243 + activeIndex = -1; 244 + } 245 + }); 246 + 247 + dropdown.addEventListener('mousedown', function(e) { 248 + e.preventDefault(); 249 + var target = e.target.closest('.handle-suggestion'); 250 + if (target) selectHandle(target.dataset.handle); 251 + }); 252 + 253 + identity.addEventListener('blur', function() { 254 + setTimeout(function() { 255 + dropdown.style.display = 'none'; 256 + identity.setAttribute('aria-expanded', 'false'); 257 + }, 150); 258 + if (!didField.value) resolve(identity.value); 259 + }); 260 + 261 + form.addEventListener('submit', async function(ev) { 262 + if (didField.value) return; 263 + ev.preventDefault(); 264 + submit.disabled = true; 265 + submit.textContent = 'Resolving identity…'; 266 + await resolve(identity.value); 267 + submit.disabled = false; 268 + submit.textContent = submit.getAttribute('data-default-text') || 'Sign in'; 269 + if (didField.value) form.submit(); 270 + }); 271 + })(); 272 + </script>`) 70 273 b.WriteString(`</section>`) 71 274 _, err := io.WriteString(w, b.String()) 72 275 return err ··· 75 278 }) 76 279 } 77 280 281 + // RecoverSelectDomain renders after OAuth when the verified DID has more 282 + // than one enrolled domain. The ticket remains in the HttpOnly cookie; each 283 + // button posts the chosen domain for server-side DID/domain validation. 284 + func RecoverSelectDomain(d RecoverSelectDomainData) templ.Component { 285 + return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error { 286 + inner := templ.ComponentFunc(func(_ context.Context, w io.Writer) error { 287 + var b strings.Builder 288 + b.WriteString(`<h1 class="masthead masthead-sub">Account</h1>`) 289 + fmt.Fprintf(&b, `<p class="lede" style="margin-bottom: 1.25rem;">Signed in as <code>%s</code>. Choose which enrolled domain to manage.</p>`, html.EscapeString(d.DID)) 290 + b.WriteString(`<section class="section">`) 291 + b.WriteString(`<h2>Sending domain</h2>`) 292 + b.WriteString(`<p class="section-lede">API key rotation, contact email, and DKIM records are scoped to one enrolled sending domain.</p>`) 293 + for _, domain := range d.Domains { 294 + b.WriteString(`<form method="POST" action="/account/select-domain" style="margin-top: 0.75rem;">`) 295 + fmt.Fprintf(&b, `<input type="hidden" name="domain" value="%s">`, html.EscapeString(domain.Domain)) 296 + fmt.Fprintf(&b, `<button type="submit">%s</button>`, html.EscapeString(domain.Domain)) 297 + b.WriteString(`</form>`) 298 + } 299 + fmt.Fprintf(&b, `<p class="section-lede" style="margin-top: 1.5rem;"><small>Session expires at %s.</small></p>`, html.EscapeString(d.ExpiresAt)) 300 + b.WriteString(`</section>`) 301 + _, err := io.WriteString(w, b.String()) 302 + return err 303 + }) 304 + return publicLayout("Choose account domain", false).Render(templ.WithChildren(ctx, inner), w) 305 + }) 306 + } 307 + 78 308 // RecoverManage renders the signed-in member's account dashboard. 79 309 func RecoverManage(d RecoverManageData) templ.Component { 80 310 return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error { ··· 129 359 fmt.Fprintf(&b, `<form method="POST" action="/account/regenerate" onsubmit="return confirm('Rotate the API key for %s? The current key will stop working.');">`, html.EscapeString(d.Domain)) 130 360 b.WriteString(`<button type="submit">Regenerate API key</button>`) 131 361 b.WriteString(`</form>`) 362 + b.WriteString(`</section>`) 363 + 364 + // Add another domain 365 + b.WriteString(`<section class="section">`) 366 + b.WriteString(`<h2>Add another domain</h2>`) 367 + b.WriteString(`<p class="section-lede">Enroll an additional sending domain under this account (up to 2 during the alpha).</p>`) 368 + b.WriteString(`<a href="/enroll" class="btn">Add domain →</a>`) 132 369 b.WriteString(`</section>`) 133 370 134 371 // Sign out — ends the session and redirects to /.
+2 -3
internal/atpoauth/client.go
··· 93 93 ClientID string 94 94 // CallbackURL is the single, fully-qualified redirect URI. 95 95 CallbackURL string 96 - // Scopes requested on every flow. Must include "atproto". For record 97 - // writes we also pass "transition:generic". 96 + // Scopes requested on every flow. Must include "atproto". 98 97 Scopes []string 99 98 // SigningKeyPath is where the confidential-client ES256 private key is 100 99 // persisted (PEM-wrapped multibase). If the file is missing, a new key ··· 128 127 return nil, fmt.Errorf("atpoauth: ClientID and CallbackURL are required") 129 128 } 130 129 if len(cfg.Scopes) == 0 { 131 - cfg.Scopes = []string{"atproto", "transition:generic"} 130 + cfg.Scopes = []string{"atproto", "repo:email.atmos.attestation"} 132 131 } 133 132 if cfg.KeyID == "" { 134 133 cfg.KeyID = "atmosphere-mail-1"
+6
internal/notify/webhook.go
··· 57 57 // KindMemberReactivated fires when a suspended member is returned 58 58 // to active status by operator action. 59 59 KindMemberReactivated EventKind = "member_reactivated" 60 + 61 + // KindMemberDomainAdded fires when an existing member enrolls an 62 + // additional sending domain. Informational — the operator may want 63 + // visibility but no action is required when the member is already 64 + // approved. 65 + KindMemberDomainAdded EventKind = "member_domain_added" 60 66 ) 61 67 62 68 // Event is the payload shape every webhook call carries. Fields are
+142 -1
internal/relay/metrics.go
··· 1 1 package relay 2 2 3 - import "github.com/prometheus/client_golang/prometheus" 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "strings" 7 + "time" 8 + 9 + "github.com/prometheus/client_golang/prometheus" 10 + ) 4 11 5 12 // Metrics holds all Prometheus metrics for the relay. 6 13 type Metrics struct { ··· 13 20 AuthAttempts *prometheus.CounterVec // result: success, failure 14 21 RateLimitHits *prometheus.CounterVec // limit_type: hourly, daily, global 15 22 23 + // HTTP request tracking 24 + HTTPRequestsTotal *prometheus.CounterVec // host, method, path, status 25 + HTTPRequestDuration *prometheus.HistogramVec // host, method, path 26 + 27 + // Enrollment funnel 28 + EnrollFunnel *prometheus.CounterVec // step: marketing, landing, auth_start, enroll_start, enroll_verify, enroll_success, attest_start, attest_callback 29 + 16 30 // Gauges 17 31 DeliveryQueueDepth prometheus.Gauge 18 32 MembersTotal *prometheus.GaugeVec // status: active, suspended ··· 26 40 OspreyEventsEmitted *prometheus.CounterVec // event_type 27 41 OspreyEventsFailed *prometheus.CounterVec // event_type 28 42 43 + // FBL/ARF complaint tracking 44 + ComplaintsTotal *prometheus.CounterVec // feedback_type, provider 45 + 46 + // OAuth callback results 47 + OAuthCallbacks *prometheus.CounterVec // type: enroll_auth, recovery, attestation, error 48 + 29 49 // Inbound mail classification + forwarding (Phase 1b) 30 50 InboundMessages *prometheus.CounterVec // classification: verp_bounce, srs_bounce, reply, postmaster 31 51 RepliesForwarded *prometheus.CounterVec // status: sent, failed ··· 62 82 Name: "atmosphere_relay_ratelimit_hits_total", 63 83 Help: "Total rate limit hits, by limit type.", 64 84 }, []string{"limit_type"}), 85 + HTTPRequestsTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ 86 + Name: "atmos_http_requests_total", 87 + Help: "Total HTTP requests by host, method, path, and status code.", 88 + }, []string{"host", "method", "path", "status"}), 89 + HTTPRequestDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ 90 + Name: "atmos_http_request_duration_seconds", 91 + Help: "HTTP request duration in seconds by host, method, and path.", 92 + Buckets: []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5}, 93 + }, []string{"host", "method", "path"}), 94 + EnrollFunnel: prometheus.NewCounterVec(prometheus.CounterOpts{ 95 + Name: "atmos_enroll_funnel_total", 96 + Help: "Enrollment funnel step visits.", 97 + }, []string{"step"}), 65 98 DeliveryQueueDepth: prometheus.NewGauge(prometheus.GaugeOpts{ 66 99 Name: "atmosphere_relay_delivery_queue_depth", 67 100 Help: "Current number of messages in the delivery queue.", ··· 90 123 Name: "atmosphere_relay_osprey_events_failed_total", 91 124 Help: "Osprey events that failed to write to Kafka (marshal, sync buffer-full, or async broker error), by event type.", 92 125 }, []string{"event_type"}), 126 + ComplaintsTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ 127 + Name: "atmosphere_relay_complaints_total", 128 + Help: "Total FBL/ARF complaints received.", 129 + }, []string{"feedback_type", "provider"}), 93 130 InboundMessages: prometheus.NewCounterVec(prometheus.CounterOpts{ 94 131 Name: "atmosphere_relay_inbound_messages_total", 95 132 Help: "Inbound messages received on :25, by classification.", ··· 98 135 Name: "atmosphere_relay_replies_forwarded_total", 99 136 Help: "Outcome of reply-forwarding attempts, by status.", 100 137 }, []string{"status"}), 138 + OAuthCallbacks: prometheus.NewCounterVec(prometheus.CounterOpts{ 139 + Name: "atmos_enroll_oauth_callbacks_total", 140 + Help: "OAuth callback completions, by result type.", 141 + }, []string{"type"}), 101 142 } 102 143 103 144 reg.MustRegister( ··· 115 156 m.OspreyChecksTotal, 116 157 m.OspreyEventsEmitted, 117 158 m.OspreyEventsFailed, 159 + m.ComplaintsTotal, 118 160 m.InboundMessages, 119 161 m.RepliesForwarded, 162 + m.HTTPRequestsTotal, 163 + m.HTTPRequestDuration, 164 + m.EnrollFunnel, 165 + m.OAuthCallbacks, 120 166 ) 121 167 122 168 // Initialize label values so they appear in /metrics output even at zero ··· 147 193 m.OspreyEventsEmitted.WithLabelValues("delivery_result") 148 194 m.OspreyEventsEmitted.WithLabelValues("bounce_received") 149 195 m.OspreyEventsEmitted.WithLabelValues("member_suspended") 196 + m.OspreyEventsEmitted.WithLabelValues("complaint_received") 150 197 m.OspreyEventsFailed.WithLabelValues("relay_attempt") 151 198 m.OspreyEventsFailed.WithLabelValues("relay_rejected") 152 199 m.OspreyEventsFailed.WithLabelValues("delivery_result") 153 200 m.OspreyEventsFailed.WithLabelValues("bounce_received") 154 201 m.OspreyEventsFailed.WithLabelValues("member_suspended") 202 + m.OspreyEventsFailed.WithLabelValues("complaint_received") 203 + for _, ft := range []string{"abuse", "fraud", "not-spam", "other"} { 204 + for _, p := range []string{"gmail", "microsoft", "yahoo", "other"} { 205 + m.ComplaintsTotal.WithLabelValues(ft, p) 206 + } 207 + } 155 208 m.InboundMessages.WithLabelValues("verp_bounce") 156 209 m.InboundMessages.WithLabelValues("srs_bounce") 157 210 m.InboundMessages.WithLabelValues("reply") 158 211 m.InboundMessages.WithLabelValues("postmaster") 159 212 m.RepliesForwarded.WithLabelValues("sent") 160 213 m.RepliesForwarded.WithLabelValues("failed") 214 + for _, step := range []string{"marketing", "landing", "auth_start", "enroll_start", "enroll_verify", "enroll_success", "attest_start", "attest_callback"} { 215 + m.EnrollFunnel.WithLabelValues(step) 216 + } 217 + for _, t := range []string{"enroll_auth", "recovery", "attestation", "error"} { 218 + m.OAuthCallbacks.WithLabelValues(t) 219 + } 161 220 162 221 return m 163 222 } 164 223 224 + // RecordEnrollStep increments the enrollment funnel counter for the given step. 225 + func (m *Metrics) RecordEnrollStep(step string) { 226 + m.EnrollFunnel.WithLabelValues(step).Inc() 227 + } 228 + 229 + // RecordOAuthCallback increments the OAuth callback counter for the given type. 230 + func (m *Metrics) RecordOAuthCallback(callbackType string) { 231 + m.OAuthCallbacks.WithLabelValues(callbackType).Inc() 232 + } 233 + 165 234 // RecordInbound implements relay.InboundMetrics. 166 235 func (m *Metrics) RecordInbound(classification string) { 167 236 m.InboundMessages.WithLabelValues(classification).Inc() ··· 185 254 func (a *EmitterMetricsAdapter) IncFailed(eventType string) { 186 255 a.Failed.WithLabelValues(eventType).Inc() 187 256 } 257 + 258 + // HTTPMiddleware wraps an http.Handler to record request count and duration. 259 + func (m *Metrics) HTTPMiddleware(next http.Handler) http.Handler { 260 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 261 + start := time.Now() 262 + rw := &statusWriter{ResponseWriter: w, status: 200} 263 + next.ServeHTTP(rw, r) 264 + 265 + host := normalizeMetricsHost(r.Host) 266 + path := collapseRoute(r.URL.Path) 267 + status := fmt.Sprintf("%d", rw.status) 268 + 269 + m.HTTPRequestsTotal.WithLabelValues(host, r.Method, path, status).Inc() 270 + m.HTTPRequestDuration.WithLabelValues(host, r.Method, path).Observe(time.Since(start).Seconds()) 271 + }) 272 + } 273 + 274 + type statusWriter struct { 275 + http.ResponseWriter 276 + status int 277 + wroteHeader bool 278 + } 279 + 280 + func (w *statusWriter) WriteHeader(code int) { 281 + if !w.wroteHeader { 282 + w.status = code 283 + w.wroteHeader = true 284 + } 285 + w.ResponseWriter.WriteHeader(code) 286 + } 287 + 288 + func (w *statusWriter) Write(b []byte) (int, error) { 289 + if !w.wroteHeader { 290 + w.wroteHeader = true 291 + } 292 + return w.ResponseWriter.Write(b) 293 + } 294 + 295 + func normalizeMetricsHost(h string) string { 296 + h = strings.ToLower(h) 297 + if i := strings.LastIndex(h, ":"); i != -1 { 298 + return h[:i] 299 + } 300 + return h 301 + } 302 + 303 + // collapseRoute maps request paths to a small set of route patterns 304 + // to avoid unbounded cardinality in metrics labels. 305 + func collapseRoute(path string) string { 306 + switch { 307 + case path == "/" || path == "": 308 + return "/" 309 + case path == "/enroll" || path == "/enroll/": 310 + return "/enroll" 311 + case strings.HasPrefix(path, "/enroll/"): 312 + return "/enroll/*" 313 + case strings.HasPrefix(path, "/u/"): 314 + return "/u/*" 315 + case path == "/healthz": 316 + return "/healthz" 317 + case path == "/verify-email": 318 + return "/verify-email" 319 + case path == "/.well-known/atproto-oauth-client-metadata.json": 320 + return "/.well-known/atproto-oauth-client-metadata.json" 321 + case strings.HasPrefix(path, "/.well-known/"): 322 + return "/.well-known/*" 323 + case path == "/metrics": 324 + return "/metrics" 325 + default: 326 + return "/other" 327 + } 328 + }
+103
internal/relay/metrics_test.go
··· 32 32 "atmosphere_relay_labeler_reachable": false, 33 33 "atmosphere_relay_auth_attempts_total": false, 34 34 "atmosphere_relay_ratelimit_hits_total": false, 35 + "atmosphere_relay_complaints_total": false, 35 36 } 36 37 37 38 for _, f := range families { ··· 102 103 } 103 104 if v := testutil.ToFloat64(m.LabelerReachable); v != 1 { 104 105 t.Errorf("LabelerReachable = %v, want 1", v) 106 + } 107 + } 108 + 109 + func TestMetricsComplaintsTotalPreInitialized(t *testing.T) { 110 + reg := prometheus.NewRegistry() 111 + NewMetrics(reg) 112 + 113 + families, err := reg.Gather() 114 + if err != nil { 115 + t.Fatalf("Gather: %v", err) 116 + } 117 + 118 + type combo struct{ ft, p string } 119 + want := map[combo]bool{ 120 + {"abuse", "gmail"}: false, 121 + {"abuse", "microsoft"}: false, 122 + {"abuse", "yahoo"}: false, 123 + {"abuse", "other"}: false, 124 + {"fraud", "gmail"}: false, 125 + {"fraud", "other"}: false, 126 + {"not-spam", "gmail"}: false, 127 + {"other", "other"}: false, 128 + } 129 + 130 + for _, f := range families { 131 + if f.GetName() != "atmosphere_relay_complaints_total" { 132 + continue 133 + } 134 + for _, m := range f.Metric { 135 + var ft, p string 136 + for _, l := range m.Label { 137 + switch l.GetName() { 138 + case "feedback_type": 139 + ft = l.GetValue() 140 + case "provider": 141 + p = l.GetValue() 142 + } 143 + } 144 + c := combo{ft, p} 145 + if _, ok := want[c]; ok { 146 + want[c] = true 147 + } 148 + } 149 + } 150 + 151 + for c, found := range want { 152 + if !found { 153 + t.Errorf("ComplaintsTotal{feedback_type=%q, provider=%q} not pre-initialized", c.ft, c.p) 154 + } 155 + } 156 + } 157 + 158 + func TestMetricsComplaintReceiredOspreyPreInitialized(t *testing.T) { 159 + reg := prometheus.NewRegistry() 160 + NewMetrics(reg) 161 + 162 + families, err := reg.Gather() 163 + if err != nil { 164 + t.Fatalf("Gather: %v", err) 165 + } 166 + 167 + for _, label := range []string{"complaint_received"} { 168 + for _, metricName := range []string{ 169 + "atmosphere_relay_osprey_events_emitted_total", 170 + "atmosphere_relay_osprey_events_failed_total", 171 + } { 172 + found := false 173 + for _, f := range families { 174 + if f.GetName() != metricName { 175 + continue 176 + } 177 + for _, m := range f.Metric { 178 + for _, l := range m.Label { 179 + if l.GetName() == "event_type" && l.GetValue() == label { 180 + found = true 181 + } 182 + } 183 + } 184 + } 185 + if !found { 186 + t.Errorf("event_type=%q must be pre-initialized in %s", label, metricName) 187 + } 188 + } 189 + } 190 + } 191 + 192 + func TestMetricsComplaintsTotalIncrement(t *testing.T) { 193 + reg := prometheus.NewRegistry() 194 + m := NewMetrics(reg) 195 + 196 + m.ComplaintsTotal.WithLabelValues("abuse", "gmail").Inc() 197 + m.ComplaintsTotal.WithLabelValues("abuse", "gmail").Inc() 198 + m.ComplaintsTotal.WithLabelValues("fraud", "microsoft").Inc() 199 + 200 + if v := testutil.ToFloat64(m.ComplaintsTotal.WithLabelValues("abuse", "gmail")); v != 2 { 201 + t.Errorf("ComplaintsTotal(abuse, gmail) = %v, want 2", v) 202 + } 203 + if v := testutil.ToFloat64(m.ComplaintsTotal.WithLabelValues("fraud", "microsoft")); v != 1 { 204 + t.Errorf("ComplaintsTotal(fraud, microsoft) = %v, want 1", v) 205 + } 206 + if v := testutil.ToFloat64(m.ComplaintsTotal.WithLabelValues("abuse", "yahoo")); v != 0 { 207 + t.Errorf("ComplaintsTotal(abuse, yahoo) = %v, want 0", v) 105 208 } 106 209 } 107 210
+71
internal/relay/opmail.go
··· 57 57 // OpMailKeyRegenerated tells a member their API key was rotated. 58 58 // Recipient is the member's contact_email. 59 59 OpMailKeyRegenerated OpMailKind = "key_regenerated" 60 + // OpMailFBLComplaint notifies the operator that a FBL/ARF complaint 61 + // was received for a member's sending domain. 62 + OpMailFBLComplaint OpMailKind = "fbl_complaint" 63 + // OpMailEmailVerification asks a member to verify their contact_email 64 + // by clicking a link. Recipient is the member's contact_email. 65 + OpMailEmailVerification OpMailKind = "email_verification" 60 66 ) 61 67 62 68 // OpMailSendFunc is the swappable transport for system mail. Production ··· 102 108 Domain string 103 109 } 104 110 111 + // FBLComplaintData drives the fbl_complaint template. 112 + type FBLComplaintData struct { 113 + MemberDID string 114 + SenderDomain string 115 + RecipientDomain string 116 + FeedbackType string 117 + Provider string 118 + } 119 + 120 + // EmailVerificationData drives the email_verification template. 121 + type EmailVerificationData struct { 122 + Domain string 123 + VerifyURL string 124 + } 125 + 105 126 // OpMailer renders and dispatches system mail. Built once at startup and 106 127 // shared across call sites (admin API enroll path, UI approve action, 107 128 // regenerate-key endpoint). Safe for concurrent use — the signer and ··· 160 181 return m.sendTemplated(ctx, OpMailKeyRegenerated, "welcome", to, subject, body) 161 182 } 162 183 184 + // SendFBLComplaint notifies the operator that a FBL/ARF complaint was 185 + // received. Recipient is the operator's forwarding address. Unlike member 186 + // mail, complaints always have a recipient (the operator), so no 187 + // ErrNoContactEmail path. 188 + func (m *OpMailer) SendFBLComplaint(ctx OpMailContext, to string, data FBLComplaintData) (string, error) { 189 + if to == "" { 190 + return "", fmt.Errorf("fbl_complaint: recipient required") 191 + } 192 + subject := fmt.Sprintf("atmos.email — FBL complaint: %s (%s)", data.SenderDomain, data.Provider) 193 + body := renderFBLComplaint(data) 194 + return m.sendTemplated(ctx, OpMailFBLComplaint, "ops", to, subject, body) 195 + } 196 + 197 + // SendEmailVerification asks a member to verify their contact_email by 198 + // clicking a link. Returns ErrNoContactEmail if `to` is empty. 199 + func (m *OpMailer) SendEmailVerification(ctx OpMailContext, to string, data EmailVerificationData) (string, error) { 200 + if to == "" { 201 + return "", ErrNoContactEmail 202 + } 203 + subject := "atmos.email — verify your contact email" 204 + body := renderEmailVerification(data) 205 + return m.sendTemplated(ctx, OpMailEmailVerification, "welcome", to, subject, body) 206 + } 207 + 163 208 // ErrNoContactEmail is returned when a send was requested for a member 164 209 // who never supplied a contact mailbox. Callers should log a warning and 165 210 // continue — the user-visible operation (enrollment, approve, rotate) ··· 280 325 281 326 Docs: https://atmospheremail.com/docs 282 327 `, d.Domain) 328 + } 329 + 330 + func renderFBLComplaint(d FBLComplaintData) string { 331 + return fmt.Sprintf(`A feedback loop complaint was received for a member's sending domain. 332 + 333 + Member: %s 334 + Domain: %s 335 + Provider: %s 336 + Type: %s 337 + Recipient: %s 338 + 339 + This means a recipient at %s marked a message from %s as spam. Isolated complaints are normal — a pattern of complaints from the same member may indicate a deliverability or content problem worth investigating. 340 + 341 + This notification was sent by the atmos.email relay to its configured operator forwarding address. 342 + `, d.MemberDID, d.SenderDomain, d.Provider, d.FeedbackType, d.RecipientDomain, d.Provider, d.SenderDomain) 343 + } 344 + 345 + func renderEmailVerification(d EmailVerificationData) string { 346 + return fmt.Sprintf(`Please verify the contact email address for your Atmosphere Mail domain %s. 347 + 348 + Click the link below to confirm this is your email: 349 + 350 + %s 351 + 352 + This link expires in 72 hours. If you did not enroll this domain with Atmosphere Mail, you can safely ignore this message. 353 + `, d.Domain, d.VerifyURL) 283 354 } 284 355 285 356 // DefaultOpMailSender returns a production-ready OpMailSendFunc that
+195
internal/relay/warmup.go
··· 1 + package relay 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log" 7 + "strings" 8 + "time" 9 + ) 10 + 11 + // WarmupSender builds and queues warmup emails on behalf of a member. 12 + // Bypasses rate limiting and suppression since these are operator-initiated 13 + // sends to known seed addresses. 14 + type WarmupSender struct { 15 + seedAddresses []string 16 + memberLookup func(ctx context.Context, did string) (*MemberWithDomains, error) 17 + queue *Queue 18 + operatorKeys *DKIMKeys 19 + operatorDKIMDomain string 20 + relayDomain string 21 + 22 + insertMessage func(ctx context.Context, did, from, to, msgID string) (int64, error) 23 + incrSendCount func(ctx context.Context, did string) 24 + } 25 + 26 + // WarmupConfig configures the warmup sender. 27 + type WarmupConfig struct { 28 + SeedAddresses []string 29 + MemberLookup func(ctx context.Context, did string) (*MemberWithDomains, error) 30 + Queue *Queue 31 + OperatorKeys *DKIMKeys 32 + OperatorDKIMDomain string 33 + RelayDomain string 34 + InsertMessage func(ctx context.Context, did, from, to, msgID string) (int64, error) 35 + IncrSendCount func(ctx context.Context, did string) 36 + } 37 + 38 + func NewWarmupSender(cfg WarmupConfig) *WarmupSender { 39 + return &WarmupSender{ 40 + seedAddresses: cfg.SeedAddresses, 41 + memberLookup: cfg.MemberLookup, 42 + queue: cfg.Queue, 43 + operatorKeys: cfg.OperatorKeys, 44 + operatorDKIMDomain: cfg.OperatorDKIMDomain, 45 + relayDomain: cfg.RelayDomain, 46 + insertMessage: cfg.InsertMessage, 47 + incrSendCount: cfg.IncrSendCount, 48 + } 49 + } 50 + 51 + func (w *WarmupSender) SeedCount() int { return len(w.seedAddresses) } 52 + 53 + // WarmupResult reports what happened for each seed address. 54 + type WarmupResult struct { 55 + Sent int `json:"sent"` 56 + Failed int `json:"failed"` 57 + Errors []string `json:"errors,omitempty"` 58 + } 59 + 60 + // SendBatch sends one warmup email to each seed address on behalf of the 61 + // given member DID. Returns the number sent and any per-recipient errors. 62 + func (w *WarmupSender) SendBatch(ctx context.Context, did string) (*WarmupResult, error) { 63 + if len(w.seedAddresses) == 0 { 64 + return nil, fmt.Errorf("no warmup seed addresses configured") 65 + } 66 + 67 + member, err := w.memberLookup(ctx, did) 68 + if err != nil { 69 + return nil, fmt.Errorf("member lookup: %w", err) 70 + } 71 + if member == nil || len(member.Domains) == 0 { 72 + return nil, fmt.Errorf("member %s not found or has no domains", did) 73 + } 74 + 75 + domain := member.Domains[0] 76 + from := "postmaster@" + domain.Domain 77 + 78 + result := &WarmupResult{} 79 + for _, to := range w.seedAddresses { 80 + msgID := fmt.Sprintf("<%d.warmup@%s>", time.Now().UnixNano(), w.relayDomain) 81 + msg := buildWarmupMessage(from, to, msgID, domain.Domain) 82 + 83 + verpFrom := VERPReturnPath(did, to, w.relayDomain) 84 + 85 + raw := []byte(msg) 86 + stamped := append([]byte("X-Atmos-Member-Did: "+did+"\r\n"), raw...) 87 + stamped = PrependFeedbackID(stamped, "transactional", did, domain.Domain) 88 + 89 + signer := NewDualDomainSigner(domain.DKIMKeys, w.operatorKeys, domain.Domain, w.operatorDKIMDomain) 90 + signed, err := signer.Sign(strings.NewReader(string(stamped))) 91 + if err != nil { 92 + result.Failed++ 93 + result.Errors = append(result.Errors, fmt.Sprintf("%s: DKIM sign: %v", to, err)) 94 + continue 95 + } 96 + 97 + entryID := int64(0) 98 + if w.insertMessage != nil { 99 + id, err := w.insertMessage(ctx, did, from, to, msgID) 100 + if err != nil { 101 + log.Printf("warmup.insert_message: did=%s to=%s error=%v", did, to, err) 102 + } else { 103 + entryID = id 104 + } 105 + } 106 + if w.incrSendCount != nil { 107 + w.incrSendCount(ctx, did) 108 + } 109 + 110 + if err := w.queue.Enqueue(&QueueEntry{ 111 + ID: entryID, 112 + From: verpFrom, 113 + To: to, 114 + Data: signed, 115 + MemberDID: did, 116 + }); err != nil { 117 + result.Failed++ 118 + result.Errors = append(result.Errors, fmt.Sprintf("%s: enqueue: %v", to, err)) 119 + continue 120 + } 121 + 122 + result.Sent++ 123 + log.Printf("warmup.queued: did=%s to=%s msg_id=%s", did, to, msgID) 124 + } 125 + 126 + return result, nil 127 + } 128 + 129 + type warmupTemplate struct { 130 + subject string 131 + body string 132 + } 133 + 134 + func warmupTemplates(domain string) []warmupTemplate { 135 + return []warmupTemplate{ 136 + { 137 + subject: "Re: setting up email for " + domain, 138 + body: "Hi,\r\n\r\n" + 139 + "Just following up — the email configuration for " + domain + " is all set. DKIM signatures are being applied correctly and everything looks good on our end.\r\n\r\n" + 140 + "Let me know if you run into any issues or have questions about the setup.\r\n\r\n" + 141 + "Best,\r\n" + 142 + "Scott", 143 + }, 144 + { 145 + subject: "Quick note about " + domain, 146 + body: "Hey,\r\n\r\n" + 147 + "Wanted to let you know that " + domain + " is fully configured and sending through the relay. The DKIM and SPF records are aligned, so messages should be landing in inboxes without any trouble.\r\n\r\n" + 148 + "The cooperative relay model means your domain benefits from shared reputation across all members, which is especially helpful for newer domains that haven't built up their own sending history yet.\r\n\r\n" + 149 + "Thanks,\r\n" + 150 + "Scott", 151 + }, 152 + { 153 + subject: domain + " is looking good", 154 + body: "Hi,\r\n\r\n" + 155 + "Everything is running well for " + domain + ". Wanted to drop a quick note to confirm that outbound messages are being signed and delivered as expected.\r\n\r\n" + 156 + "One thing worth mentioning — each message gets two DKIM signatures: one for your domain and one for the relay pool. This gives receiving mail servers two independent ways to verify authenticity, which generally helps with inbox placement.\r\n\r\n" + 157 + "Cheers,\r\n" + 158 + "Scott", 159 + }, 160 + { 161 + subject: "Checking in — " + domain, 162 + body: "Hey,\r\n\r\n" + 163 + "Just checking in on " + domain + ". The mail pipeline is healthy and I don't see any issues on our side.\r\n\r\n" + 164 + "If you've been seeing good deliverability, that's great — the shared IP reputation pool is working as intended. If anything looks off, just let me know and I can take a closer look at the logs.\r\n\r\n" + 165 + "Best,\r\n" + 166 + "Scott", 167 + }, 168 + { 169 + subject: "All good with " + domain, 170 + body: "Hi,\r\n\r\n" + 171 + "Touching base to confirm " + domain + " is in good shape. The relay is processing your outbound mail normally, and authentication records are passing validation.\r\n\r\n" + 172 + "For context, Atmosphere Mail is a cooperative relay built for the AT Protocol ecosystem. The idea is that smaller self-hosted services can share IP reputation instead of each one starting from scratch with a cold IP address. Happy to answer any questions about how it works.\r\n\r\n" + 173 + "Thanks,\r\n" + 174 + "Scott", 175 + }, 176 + } 177 + } 178 + 179 + func buildWarmupMessage(from, to, msgID, domain string) string { 180 + templates := warmupTemplates(domain) 181 + idx := int(time.Now().Unix()/60) % len(templates) 182 + t := templates[idx] 183 + 184 + return strings.Join([]string{ 185 + "From: " + from, 186 + "To: " + to, 187 + "Subject: " + t.subject, 188 + "Message-ID: " + msgID, 189 + "Date: " + time.Now().UTC().Format(time.RFC1123Z), 190 + "MIME-Version: 1.0", 191 + "Content-Type: text/plain; charset=utf-8", 192 + "", 193 + t.body, 194 + }, "\r\n") 195 + }
+2
internal/relaystore/pending_notifications.go
··· 15 15 const ( 16 16 NotificationKindWelcome = "welcome" 17 17 NotificationKindKeyRegenerated = "key_regenerated" 18 + NotificationKindFBLComplaint = "fbl_complaint" 19 + NotificationKindEmailVerification = "email_verification" 18 20 ) 19 21 20 22 // MaxNotificationAttempts is the cap at which a pending notification is
+217 -38
internal/relaystore/store.go
··· 23 23 StatusPending = "pending" 24 24 ) 25 25 26 + // CurrentTermsVersion is the date-based version of the Terms of Service 27 + // that enrolling members agree to. Bump this when the terms change 28 + // materially; the old value is preserved in member records so we know 29 + // which version each member accepted. 30 + const CurrentTermsVersion = "2026-04-23" 31 + 26 32 // Message status constants. 27 33 const ( 28 34 MsgQueued = "queued" ··· 51 57 // enrollments (operator vouches) and for members who later complete 52 58 // atproto OAuth (Phase 2). Downstream consumers (labeler, trust scoring) 53 59 // read this to distinguish weak vs strong identity proof. 54 - DIDVerified bool 55 - CreatedAt time.Time 56 - UpdatedAt time.Time 60 + DIDVerified bool 61 + TermsAcceptedAt time.Time 62 + TermsVersion string 63 + CreatedAt time.Time 64 + UpdatedAt time.Time 57 65 } 58 66 59 67 // AgeDays returns whole days since the member was created, floored at zero. ··· 83 91 // one pending enrollment at a time; creating a second replaces the first 84 92 // so re-running the wizard with a fresh token works cleanly. 85 93 type PendingEnrollment struct { 86 - Token string 87 - DID string 88 - Domain string 89 - ContactEmail string 90 - CreatedAt time.Time 91 - ExpiresAt time.Time 94 + Token string 95 + DID string 96 + Domain string 97 + ContactEmail string 98 + TermsAccepted bool 99 + CreatedAt time.Time 100 + ExpiresAt time.Time 92 101 } 93 102 94 103 // MemberDomain represents a sending domain registered under a member DID. ··· 109 118 // are skipped with a logged warning (operator-ping still fires, since 110 119 // that uses the relay-configured operator-forward address). 111 120 ContactEmail string 112 - CreatedAt time.Time 121 + // EmailVerified indicates whether the member has proven ownership of 122 + // ContactEmail by clicking a verification link. False until verified. 123 + EmailVerified bool 124 + CreatedAt time.Time 113 125 } 114 126 115 127 type Message struct { ··· 402 414 } 403 415 } 404 416 417 + // Terms-of-service agreement tracking on members. Recorded at enrollment 418 + // time so we have an audit trail of which version each member agreed to. 419 + var hasTermsAcceptedAt int 420 + _ = s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('members') WHERE name = 'terms_accepted_at'`).Scan(&hasTermsAcceptedAt) 421 + if hasTermsAcceptedAt == 0 { 422 + if _, err := s.db.Exec(`ALTER TABLE members ADD COLUMN terms_accepted_at TEXT NOT NULL DEFAULT ''`); err != nil { 423 + return fmt.Errorf("add terms_accepted_at column: %v", err) 424 + } 425 + } 426 + var hasTermsVersion int 427 + _ = s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('members') WHERE name = 'terms_version'`).Scan(&hasTermsVersion) 428 + if hasTermsVersion == 0 { 429 + if _, err := s.db.Exec(`ALTER TABLE members ADD COLUMN terms_version TEXT NOT NULL DEFAULT ''`); err != nil { 430 + return fmt.Errorf("add terms_version column: %v", err) 431 + } 432 + } 433 + 434 + // Terms acceptance flag on pending_enrollments, carried from enroll-start 435 + // through to enroll-complete where it stamps the member record. 436 + var hasPendingTerms int 437 + _ = s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('pending_enrollments') WHERE name = 'terms_accepted'`).Scan(&hasPendingTerms) 438 + if hasPendingTerms == 0 { 439 + if _, err := s.db.Exec(`ALTER TABLE pending_enrollments ADD COLUMN terms_accepted INTEGER NOT NULL DEFAULT 0`); err != nil { 440 + return fmt.Errorf("add terms_accepted to pending_enrollments: %v", err) 441 + } 442 + } 443 + 444 + // Email verification columns on member_domains. Tracks whether the 445 + // member has proven ownership of their contact_email by clicking a 446 + // link in a verification email. email_verified is 0/1 (SQLite bool), 447 + // email_verify_token is the hex-encoded random token, and 448 + // email_verify_expires is an RFC3339 expiry timestamp. 449 + var hasEmailVerified int 450 + _ = s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('member_domains') WHERE name = 'email_verified'`).Scan(&hasEmailVerified) 451 + if hasEmailVerified == 0 { 452 + if _, err := s.db.Exec(`ALTER TABLE member_domains ADD COLUMN email_verified INTEGER DEFAULT 0`); err != nil { 453 + return fmt.Errorf("add email_verified column: %v", err) 454 + } 455 + } 456 + var hasEmailVerifyToken int 457 + _ = s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('member_domains') WHERE name = 'email_verify_token'`).Scan(&hasEmailVerifyToken) 458 + if hasEmailVerifyToken == 0 { 459 + if _, err := s.db.Exec(`ALTER TABLE member_domains ADD COLUMN email_verify_token TEXT DEFAULT ''`); err != nil { 460 + return fmt.Errorf("add email_verify_token column: %v", err) 461 + } 462 + } 463 + var hasEmailVerifyExpires int 464 + _ = s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('member_domains') WHERE name = 'email_verify_expires'`).Scan(&hasEmailVerifyExpires) 465 + if hasEmailVerifyExpires == 0 { 466 + if _, err := s.db.Exec(`ALTER TABLE member_domains ADD COLUMN email_verify_expires TEXT DEFAULT ''`); err != nil { 467 + return fmt.Errorf("add email_verify_expires column: %v", err) 468 + } 469 + } 470 + 405 471 // Relay-local events store (replaces Druid for Osprey rule-evaluation 406 472 // events). Split into its own method so the migration lives next to 407 473 // the RelayEvent model. ··· 489 555 490 556 func (s *Store) InsertMember(ctx context.Context, m *Member) error { 491 557 _, err := s.db.ExecContext(ctx, 492 - `INSERT INTO members (did, status, suspend_reason, send_count, hourly_limit, daily_limit, did_verified, created_at, updated_at) 493 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, 558 + `INSERT INTO members (did, status, suspend_reason, send_count, hourly_limit, daily_limit, did_verified, terms_accepted_at, terms_version, created_at, updated_at) 559 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 494 560 m.DID, m.Status, m.SuspendReason, m.SendCount, m.HourlyLimit, m.DailyLimit, 495 561 boolToInt(m.DIDVerified), 562 + formatTime(m.TermsAcceptedAt), m.TermsVersion, 496 563 formatTime(m.CreatedAt), formatTime(m.UpdatedAt), 497 564 ) 498 565 if err != nil { ··· 513 580 514 581 if member != nil { 515 582 _, err := tx.ExecContext(ctx, 516 - `INSERT INTO members (did, status, suspend_reason, send_count, hourly_limit, daily_limit, did_verified, created_at, updated_at) 517 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, 583 + `INSERT INTO members (did, status, suspend_reason, send_count, hourly_limit, daily_limit, did_verified, terms_accepted_at, terms_version, created_at, updated_at) 584 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 518 585 member.DID, member.Status, member.SuspendReason, member.SendCount, 519 586 member.HourlyLimit, member.DailyLimit, 520 587 boolToInt(member.DIDVerified), 588 + formatTime(member.TermsAcceptedAt), member.TermsVersion, 521 589 formatTime(member.CreatedAt), formatTime(member.UpdatedAt), 522 590 ) 523 591 if err != nil { ··· 547 615 // ListMembersWithDomains returns all members with their domain names in a single query. 548 616 func (s *Store) ListMembersWithDomains(ctx context.Context) ([]MemberWithDomainSummary, error) { 549 617 rows, err := s.db.QueryContext(ctx, 550 - `SELECT m.did, m.status, m.suspend_reason, m.send_count, m.hourly_limit, m.daily_limit, m.did_verified, m.created_at, m.updated_at, 618 + `SELECT m.did, m.status, m.suspend_reason, m.send_count, m.hourly_limit, m.daily_limit, m.did_verified, m.terms_accepted_at, m.terms_version, m.created_at, m.updated_at, 551 619 COALESCE(GROUP_CONCAT(md.domain, ','), '') 552 620 FROM members m 553 621 LEFT JOIN member_domains md ON md.did = m.did ··· 562 630 var result []MemberWithDomainSummary 563 631 for rows.Next() { 564 632 var mwd MemberWithDomainSummary 565 - var createdAt, updatedAt, domainCSV string 633 + var createdAt, updatedAt, termsAcceptedAt, domainCSV string 566 634 var didVerified int 567 635 if err := rows.Scan( 568 636 &mwd.DID, &mwd.Status, &mwd.SuspendReason, &mwd.SendCount, 569 - &mwd.HourlyLimit, &mwd.DailyLimit, &didVerified, &createdAt, &updatedAt, &domainCSV, 637 + &mwd.HourlyLimit, &mwd.DailyLimit, &didVerified, 638 + &termsAcceptedAt, &mwd.TermsVersion, 639 + &createdAt, &updatedAt, &domainCSV, 570 640 ); err != nil { 571 641 return nil, fmt.Errorf("scan member with domains: %v", err) 572 642 } 573 643 mwd.DIDVerified = didVerified != 0 644 + mwd.TermsAcceptedAt = parseTime(termsAcceptedAt) 574 645 mwd.CreatedAt = parseTime(createdAt) 575 646 mwd.UpdatedAt = parseTime(updatedAt) 576 647 if domainCSV != "" { ··· 583 654 584 655 func (s *Store) GetMember(ctx context.Context, did string) (*Member, error) { 585 656 row := s.db.QueryRowContext(ctx, 586 - `SELECT did, status, suspend_reason, send_count, hourly_limit, daily_limit, did_verified, created_at, updated_at 657 + `SELECT did, status, suspend_reason, send_count, hourly_limit, daily_limit, did_verified, terms_accepted_at, terms_version, created_at, updated_at 587 658 FROM members WHERE did = ?`, did, 588 659 ) 589 660 return scanMember(row) ··· 591 662 592 663 func (s *Store) ListMembers(ctx context.Context) ([]Member, error) { 593 664 rows, err := s.db.QueryContext(ctx, 594 - `SELECT did, status, suspend_reason, send_count, hourly_limit, daily_limit, did_verified, created_at, updated_at 665 + `SELECT did, status, suspend_reason, send_count, hourly_limit, daily_limit, did_verified, terms_accepted_at, terms_version, created_at, updated_at 595 666 FROM members ORDER BY created_at ASC`, 596 667 ) 597 668 if err != nil { ··· 663 734 664 735 func scanMember(sc scanner) (*Member, error) { 665 736 var m Member 666 - var createdAt, updatedAt string 737 + var createdAt, updatedAt, termsAcceptedAt string 667 738 var didVerified int 668 739 669 740 err := sc.Scan( 670 741 &m.DID, &m.Status, &m.SuspendReason, &m.SendCount, 671 - &m.HourlyLimit, &m.DailyLimit, &didVerified, &createdAt, &updatedAt, 742 + &m.HourlyLimit, &m.DailyLimit, &didVerified, 743 + &termsAcceptedAt, &m.TermsVersion, 744 + &createdAt, &updatedAt, 672 745 ) 673 746 if err == sql.ErrNoRows { 674 747 return nil, nil ··· 678 751 } 679 752 680 753 m.DIDVerified = didVerified != 0 754 + m.TermsAcceptedAt = parseTime(termsAcceptedAt) 681 755 m.CreatedAt = parseTime(createdAt) 682 756 m.UpdatedAt = parseTime(updatedAt) 683 757 return &m, nil ··· 701 775 func (s *Store) GetMemberDomain(ctx context.Context, domain string) (*MemberDomain, error) { 702 776 var d MemberDomain 703 777 var createdAt string 778 + var emailVerified int 704 779 err := s.db.QueryRowContext(ctx, 705 - `SELECT domain, did, api_key_hash, dkim_rsa_privkey, dkim_ed_privkey, dkim_selector, forward_to, contact_email, created_at 780 + `SELECT domain, did, api_key_hash, dkim_rsa_privkey, dkim_ed_privkey, dkim_selector, forward_to, contact_email, email_verified, created_at 706 781 FROM member_domains WHERE domain = ?`, domain, 707 - ).Scan(&d.Domain, &d.DID, &d.APIKeyHash, &d.DKIMRSAPriv, &d.DKIMEdPriv, &d.DKIMSelector, &d.ForwardTo, &d.ContactEmail, &createdAt) 782 + ).Scan(&d.Domain, &d.DID, &d.APIKeyHash, &d.DKIMRSAPriv, &d.DKIMEdPriv, &d.DKIMSelector, &d.ForwardTo, &d.ContactEmail, &emailVerified, &createdAt) 708 783 if err == sql.ErrNoRows { 709 784 return nil, nil 710 785 } 711 786 if err != nil { 712 787 return nil, fmt.Errorf("get member domain: %v", err) 713 788 } 789 + d.EmailVerified = emailVerified != 0 714 790 d.CreatedAt = parseTime(createdAt) 715 791 return &d, nil 716 792 } 717 793 718 794 func (s *Store) ListMemberDomains(ctx context.Context, did string) ([]MemberDomain, error) { 719 795 rows, err := s.db.QueryContext(ctx, 720 - `SELECT domain, did, api_key_hash, dkim_rsa_privkey, dkim_ed_privkey, dkim_selector, forward_to, contact_email, created_at 796 + `SELECT domain, did, api_key_hash, dkim_rsa_privkey, dkim_ed_privkey, dkim_selector, forward_to, contact_email, email_verified, created_at 721 797 FROM member_domains WHERE did = ? ORDER BY created_at ASC`, did, 722 798 ) 723 799 if err != nil { ··· 729 805 for rows.Next() { 730 806 var d MemberDomain 731 807 var createdAt string 732 - if err := rows.Scan(&d.Domain, &d.DID, &d.APIKeyHash, &d.DKIMRSAPriv, &d.DKIMEdPriv, &d.DKIMSelector, &d.ForwardTo, &d.ContactEmail, &createdAt); err != nil { 808 + var emailVerified int 809 + if err := rows.Scan(&d.Domain, &d.DID, &d.APIKeyHash, &d.DKIMRSAPriv, &d.DKIMEdPriv, &d.DKIMSelector, &d.ForwardTo, &d.ContactEmail, &emailVerified, &createdAt); err != nil { 733 810 return nil, fmt.Errorf("scan member domain: %v", err) 734 811 } 812 + d.EmailVerified = emailVerified != 0 735 813 d.CreatedAt = parseTime(createdAt) 736 814 domains = append(domains, d) 737 815 } ··· 761 839 } 762 840 763 841 // UpdateDomainContactEmail atomically replaces the contact_email for a 764 - // registered domain. Called from the /account self-service flow and 842 + // registered domain and resets email verification state (email_verified=0, 843 + // token/expiry cleared). Called from the /account self-service flow and 765 844 // from back-fill tooling. Returns an error if the domain isn't 766 845 // registered. Empty contactEmail clears the field. 767 846 func (s *Store) UpdateDomainContactEmail(ctx context.Context, domain, contactEmail string) error { 768 847 res, err := s.db.ExecContext(ctx, 769 - `UPDATE member_domains SET contact_email = ? WHERE domain = ?`, 848 + `UPDATE member_domains SET contact_email = ?, email_verified = 0, email_verify_token = '', email_verify_expires = '' WHERE domain = ?`, 770 849 contactEmail, domain, 771 850 ) 772 851 if err != nil { ··· 782 861 return nil 783 862 } 784 863 864 + // --- Email verification --- 865 + 866 + // SetEmailVerifyToken stores a verification token and its expiry for the 867 + // given domain, and resets email_verified to 0. Called when a verification 868 + // email is about to be sent (new enrollment or contact_email change). 869 + func (s *Store) SetEmailVerifyToken(ctx context.Context, domain, token string, expiresAt time.Time) error { 870 + res, err := s.db.ExecContext(ctx, 871 + `UPDATE member_domains SET email_verified = 0, email_verify_token = ?, email_verify_expires = ? WHERE domain = ?`, 872 + token, formatTime(expiresAt), domain, 873 + ) 874 + if err != nil { 875 + return fmt.Errorf("set email verify token: %v", err) 876 + } 877 + n, err := res.RowsAffected() 878 + if err != nil { 879 + return fmt.Errorf("set email verify token rows: %v", err) 880 + } 881 + if n == 0 { 882 + return fmt.Errorf("domain %q not registered", domain) 883 + } 884 + return nil 885 + } 886 + 887 + // VerifyEmailByToken looks up a verification token across all domains, 888 + // checks that it hasn't expired, marks the domain as email_verified=1, 889 + // and clears the token. Returns the domain name on success so callers 890 + // can render a confirmation page. Returns an error if the token is not 891 + // found or has expired. 892 + func (s *Store) VerifyEmailByToken(ctx context.Context, token string) (string, error) { 893 + if token == "" { 894 + return "", fmt.Errorf("empty verification token") 895 + } 896 + var domain, expiresAtStr string 897 + err := s.db.QueryRowContext(ctx, 898 + `SELECT domain, email_verify_expires FROM member_domains WHERE email_verify_token = ?`, 899 + token, 900 + ).Scan(&domain, &expiresAtStr) 901 + if err == sql.ErrNoRows { 902 + return "", fmt.Errorf("verification token not found") 903 + } 904 + if err != nil { 905 + return "", fmt.Errorf("verify email lookup: %v", err) 906 + } 907 + expiresAt := parseTime(expiresAtStr) 908 + if !expiresAt.IsZero() && time.Now().UTC().After(expiresAt) { 909 + // Clear the expired token so it can't be retried. 910 + s.db.ExecContext(ctx, 911 + `UPDATE member_domains SET email_verify_token = '', email_verify_expires = '' WHERE domain = ?`, 912 + domain, 913 + ) 914 + return "", fmt.Errorf("verification token expired") 915 + } 916 + _, err = s.db.ExecContext(ctx, 917 + `UPDATE member_domains SET email_verified = 1, email_verify_token = '', email_verify_expires = '' WHERE domain = ?`, 918 + domain, 919 + ) 920 + if err != nil { 921 + return "", fmt.Errorf("mark email verified: %v", err) 922 + } 923 + return domain, nil 924 + } 925 + 926 + // IsEmailVerified returns whether the contact_email for the given domain 927 + // has been verified. Returns false for unknown domains. 928 + func (s *Store) IsEmailVerified(ctx context.Context, domain string) (bool, error) { 929 + var verified int 930 + err := s.db.QueryRowContext(ctx, 931 + `SELECT email_verified FROM member_domains WHERE domain = ?`, 932 + domain, 933 + ).Scan(&verified) 934 + if err == sql.ErrNoRows { 935 + return false, nil 936 + } 937 + if err != nil { 938 + return false, fmt.Errorf("is email verified: %v", err) 939 + } 940 + return verified != 0, nil 941 + } 942 + 943 + // ResetEmailVerification sets email_verified=0 and clears the token 944 + // for a domain. Called when contact_email changes and the member needs 945 + // to re-verify. 946 + func (s *Store) ResetEmailVerification(ctx context.Context, domain string) error { 947 + _, err := s.db.ExecContext(ctx, 948 + `UPDATE member_domains SET email_verified = 0, email_verify_token = '', email_verify_expires = '' WHERE domain = ?`, 949 + domain, 950 + ) 951 + if err != nil { 952 + return fmt.Errorf("reset email verification: %v", err) 953 + } 954 + return nil 955 + } 956 + 785 957 // GetMemberByDomain returns the member and domain record for a given domain name. 786 958 // Returns (nil, nil, nil) if the domain is not found. 787 959 func (s *Store) GetMemberByDomain(ctx context.Context, domain string) (*Member, *MemberDomain, error) { 788 960 var m Member 789 961 var d MemberDomain 790 - var mCreatedAt, mUpdatedAt, dCreatedAt string 791 - var didVerified int 962 + var mCreatedAt, mUpdatedAt, mTermsAcceptedAt, dCreatedAt string 963 + var didVerified, emailVerified int 792 964 793 965 err := s.db.QueryRowContext(ctx, 794 - `SELECT m.did, m.status, m.suspend_reason, m.send_count, m.hourly_limit, m.daily_limit, m.did_verified, m.created_at, m.updated_at, 795 - d.domain, d.did, d.api_key_hash, d.dkim_rsa_privkey, d.dkim_ed_privkey, d.dkim_selector, d.forward_to, d.contact_email, d.created_at 966 + `SELECT m.did, m.status, m.suspend_reason, m.send_count, m.hourly_limit, m.daily_limit, m.did_verified, m.terms_accepted_at, m.terms_version, m.created_at, m.updated_at, 967 + d.domain, d.did, d.api_key_hash, d.dkim_rsa_privkey, d.dkim_ed_privkey, d.dkim_selector, d.forward_to, d.contact_email, d.email_verified, d.created_at 796 968 FROM member_domains d JOIN members m ON d.did = m.did 797 969 WHERE d.domain = ?`, domain, 798 970 ).Scan( 799 - &m.DID, &m.Status, &m.SuspendReason, &m.SendCount, &m.HourlyLimit, &m.DailyLimit, &didVerified, &mCreatedAt, &mUpdatedAt, 800 - &d.Domain, &d.DID, &d.APIKeyHash, &d.DKIMRSAPriv, &d.DKIMEdPriv, &d.DKIMSelector, &d.ForwardTo, &d.ContactEmail, &dCreatedAt, 971 + &m.DID, &m.Status, &m.SuspendReason, &m.SendCount, &m.HourlyLimit, &m.DailyLimit, &didVerified, 972 + &mTermsAcceptedAt, &m.TermsVersion, 973 + &mCreatedAt, &mUpdatedAt, 974 + &d.Domain, &d.DID, &d.APIKeyHash, &d.DKIMRSAPriv, &d.DKIMEdPriv, &d.DKIMSelector, &d.ForwardTo, &d.ContactEmail, &emailVerified, &dCreatedAt, 801 975 ) 802 976 if err == sql.ErrNoRows { 803 977 return nil, nil, nil ··· 807 981 } 808 982 809 983 m.DIDVerified = didVerified != 0 984 + m.TermsAcceptedAt = parseTime(mTermsAcceptedAt) 810 985 m.CreatedAt = parseTime(mCreatedAt) 811 986 m.UpdatedAt = parseTime(mUpdatedAt) 987 + d.EmailVerified = emailVerified != 0 812 988 d.CreatedAt = parseTime(dCreatedAt) 813 989 return &m, &d, nil 814 990 } ··· 1739 1915 // constraint), then inserts. Token is the PK so we don't need a 1740 1916 // separate conflict target for it — a collision there is astronomical. 1741 1917 _, err := s.db.ExecContext(ctx, 1742 - `INSERT OR REPLACE INTO pending_enrollments (token, did, domain, contact_email, created_at, expires_at) 1743 - VALUES (?, ?, ?, ?, ?, ?)`, 1744 - p.Token, p.DID, p.Domain, p.ContactEmail, formatTime(p.CreatedAt), formatTime(p.ExpiresAt), 1918 + `INSERT OR REPLACE INTO pending_enrollments (token, did, domain, contact_email, terms_accepted, created_at, expires_at) 1919 + VALUES (?, ?, ?, ?, ?, ?, ?)`, 1920 + p.Token, p.DID, p.Domain, p.ContactEmail, boolToInt(p.TermsAccepted), 1921 + formatTime(p.CreatedAt), formatTime(p.ExpiresAt), 1745 1922 ) 1746 1923 if err != nil { 1747 1924 return fmt.Errorf("create pending enrollment: %v", err) ··· 1757 1934 func (s *Store) GetPendingEnrollment(ctx context.Context, token string) (*PendingEnrollment, error) { 1758 1935 var p PendingEnrollment 1759 1936 var createdAt, expiresAt string 1937 + var termsAccepted int 1760 1938 err := s.db.QueryRowContext(ctx, 1761 - `SELECT token, did, domain, contact_email, created_at, expires_at 1939 + `SELECT token, did, domain, contact_email, terms_accepted, created_at, expires_at 1762 1940 FROM pending_enrollments WHERE token = ?`, token, 1763 - ).Scan(&p.Token, &p.DID, &p.Domain, &p.ContactEmail, &createdAt, &expiresAt) 1941 + ).Scan(&p.Token, &p.DID, &p.Domain, &p.ContactEmail, &termsAccepted, &createdAt, &expiresAt) 1764 1942 if err == sql.ErrNoRows { 1765 1943 return nil, nil 1766 1944 } 1767 1945 if err != nil { 1768 1946 return nil, fmt.Errorf("get pending enrollment: %v", err) 1769 1947 } 1948 + p.TermsAccepted = termsAccepted != 0 1770 1949 p.CreatedAt = parseTime(createdAt) 1771 1950 p.ExpiresAt = parseTime(expiresAt) 1772 1951 return &p, nil