···116116 }
117117}
118118119119+// TestFireFBLComplaint_EnqueuesWithPayload verifies the FBL complaint
120120+// enqueue path stores all complaint fields in the payload.
121121+func TestFireFBLComplaint_EnqueuesWithPayload(t *testing.T) {
122122+ _, store := testAdminAPI(t)
123123+ api := New(store, "test-admin-token", "atmos.email")
124124+ api.SetOpMailer(nil, "operator@example.com", "")
125125+126126+ api.FireFBLComplaint(context.Background(), "did:plc:fbltest1111111111111aaab", "member.example.com", "gmail.com", "abuse", "gmail")
127127+128128+ now := time.Now().UTC().Unix()
129129+ claimed, err := store.ClaimPendingNotifications(context.Background(), now, 10)
130130+ if err != nil {
131131+ t.Fatalf("ClaimPendingNotifications: %v", err)
132132+ }
133133+ if len(claimed) != 1 {
134134+ t.Fatalf("expected 1 enqueued, got %d", len(claimed))
135135+ }
136136+ got := claimed[0]
137137+ if got.Kind != relaystore.NotificationKindFBLComplaint {
138138+ t.Errorf("Kind: want %q got %q", relaystore.NotificationKindFBLComplaint, got.Kind)
139139+ }
140140+ if got.Recipient != "operator@example.com" {
141141+ t.Errorf("Recipient: want operator@example.com got %q", got.Recipient)
142142+ }
143143+ if v, _ := got.Payload["memberDID"].(string); v != "did:plc:fbltest1111111111111aaab" {
144144+ t.Errorf("Payload[memberDID]: want did:plc:fbltest1111111111111aaab got %v", got.Payload["memberDID"])
145145+ }
146146+ if v, _ := got.Payload["senderDomain"].(string); v != "member.example.com" {
147147+ t.Errorf("Payload[senderDomain]: want member.example.com got %v", got.Payload["senderDomain"])
148148+ }
149149+ if v, _ := got.Payload["feedbackType"].(string); v != "abuse" {
150150+ t.Errorf("Payload[feedbackType]: want abuse got %v", got.Payload["feedbackType"])
151151+ }
152152+ if v, _ := got.Payload["provider"].(string); v != "gmail" {
153153+ t.Errorf("Payload[provider]: want gmail got %v", got.Payload["provider"])
154154+ }
155155+}
156156+157157+// TestFireFBLComplaint_NoForwardIsNoop confirms that complaints are
158158+// silently skipped when no operator forwarding address is configured.
159159+func TestFireFBLComplaint_NoForwardIsNoop(t *testing.T) {
160160+ _, store := testAdminAPI(t)
161161+ api := New(store, "test-admin-token", "atmos.email")
162162+163163+ api.FireFBLComplaint(context.Background(), "did:plc:fbltest1111111111111aaab", "member.example.com", "gmail.com", "abuse", "gmail")
164164+165165+ now := time.Now().UTC().Unix()
166166+ claimed, _ := store.ClaimPendingNotifications(context.Background(), now, 10)
167167+ if len(claimed) != 0 {
168168+ t.Errorf("expected 0 enqueued when no operator forward, got %d", len(claimed))
169169+ }
170170+}
171171+119172// TestDeliverNotificationRoutesByKind exercises the kind-dispatch
120173// inside DeliverNotification. This is the bridge between the queue
121174// worker and the existing OpMailer — a regression here would silently
···150203 }
151204 if !bytes.Contains(cap.lastData, []byte("k.example.com")) {
152205 t.Errorf("key-regenerated body missing domain: %s", cap.lastData)
206206+ }
207207+208208+ // FBL complaint delivery.
209209+ if err := api.DeliverNotification(context.Background(), relaystore.Notification{
210210+ Kind: relaystore.NotificationKindFBLComplaint,
211211+ Recipient: "ops@example.com",
212212+ Payload: map[string]any{
213213+ "memberDID": "did:plc:test123",
214214+ "senderDomain": "complaint.example.com",
215215+ "recipientDomain": "gmail.com",
216216+ "feedbackType": "abuse",
217217+ "provider": "gmail",
218218+ },
219219+ }); err != nil {
220220+ t.Fatalf("fbl_complaint delivery: %v", err)
221221+ }
222222+ if !bytes.Contains(cap.lastData, []byte("complaint.example.com")) {
223223+ t.Errorf("fbl_complaint body missing domain: %s", cap.lastData)
224224+ }
225225+ if !bytes.Contains(cap.lastData, []byte("did:plc:test123")) {
226226+ t.Errorf("fbl_complaint body missing DID: %s", cap.lastData)
153227 }
154228155229 // Unknown kind must surface an error so the row dead-letters
+57
internal/admin/opmail.go
···126126 }
127127}
128128129129+// FireEmailVerification enqueues a verification email for the given
130130+// domain's contact_email. Skips silently if contactEmail is empty.
131131+// The verifyURL is the full HTTPS link the member clicks to confirm.
132132+func (a *API) FireEmailVerification(ctx context.Context, domain, contactEmail, verifyURL string) {
133133+ if contactEmail == "" {
134134+ log.Printf("opmail.verify.skipped: domain=%s reason=no_contact_email", domain)
135135+ return
136136+ }
137137+ if _, err := a.store.EnqueueNotification(ctx, relaystore.NotificationKindEmailVerification, contactEmail, map[string]any{
138138+ "domain": domain,
139139+ "verifyURL": verifyURL,
140140+ }); err != nil {
141141+ log.Printf("opmail.verify.enqueue_error: domain=%s error=%v", domain, err)
142142+ }
143143+}
144144+145145+// FireFBLComplaint enqueues an operator notification when a FBL/ARF
146146+// complaint is received for a member. Recipient is the operator's
147147+// forwarding address — complaints are always operator-facing, never
148148+// member-facing (the member doesn't need to know a recipient hit "spam").
149149+func (a *API) FireFBLComplaint(ctx context.Context, memberDID, senderDomain, recipientDomain, feedbackType, provider string) {
150150+ if a.operatorForwardTo == "" {
151151+ log.Printf("opmail.fbl_complaint.skipped: did=%s reason=no_operator_forward", memberDID)
152152+ return
153153+ }
154154+ if _, err := a.store.EnqueueNotification(ctx, relaystore.NotificationKindFBLComplaint, a.operatorForwardTo, map[string]any{
155155+ "memberDID": memberDID,
156156+ "senderDomain": senderDomain,
157157+ "recipientDomain": recipientDomain,
158158+ "feedbackType": feedbackType,
159159+ "provider": provider,
160160+ }); err != nil {
161161+ log.Printf("opmail.fbl_complaint.enqueue_error: did=%s error=%v", memberDID, err)
162162+ }
163163+}
164164+129165// DeadLetterNotification is the JSON shape returned by
130166// /admin/notifications/dead-letter. Serialized timestamps are RFC3339
131167// UTC so operator tooling has a stable parse target.
···216252 return err
217253 case relaystore.NotificationKindKeyRegenerated:
218254 _, err := a.opMailer.SendKeyRegenerated(opmailCtx, n.Recipient, relay.KeyRegeneratedData{Domain: domain})
255255+ return err
256256+ case relaystore.NotificationKindFBLComplaint:
257257+ memberDID, _ := n.Payload["memberDID"].(string)
258258+ senderDomain, _ := n.Payload["senderDomain"].(string)
259259+ recipientDomain, _ := n.Payload["recipientDomain"].(string)
260260+ feedbackType, _ := n.Payload["feedbackType"].(string)
261261+ provider, _ := n.Payload["provider"].(string)
262262+ _, err := a.opMailer.SendFBLComplaint(opmailCtx, n.Recipient, relay.FBLComplaintData{
263263+ MemberDID: memberDID,
264264+ SenderDomain: senderDomain,
265265+ RecipientDomain: recipientDomain,
266266+ FeedbackType: feedbackType,
267267+ Provider: provider,
268268+ })
269269+ return err
270270+ case relaystore.NotificationKindEmailVerification:
271271+ verifyURL, _ := n.Payload["verifyURL"].(string)
272272+ _, err := a.opMailer.SendEmailVerification(opmailCtx, n.Recipient, relay.EmailVerificationData{
273273+ Domain: domain,
274274+ VerifyURL: verifyURL,
275275+ })
219276 return err
220277 default:
221278 return fmt.Errorf("unknown notification kind: %s", n.Kind)
+63-3
internal/admin/ui/attest.go
···5858 // has no Attestation payload — the member went through OAuth via the
5959 // /recover flow rather than /enroll. May be nil to disable recovery.
6060 recoveryIssuer RecoveryIssuer
6161+ // enrollAuthIssuer, when set, is invoked on callbacks where the session
6262+ // carries the enroll-auth sentinel — the user is verifying DID ownership
6363+ // before filling out the enrollment form. May be nil to disable.
6464+ enrollAuthIssuer EnrollAuthIssuer
6565+ funnel FunnelRecorder
6166}
62676368// NewAttestHandler constructs the handler. pub and store must both be non-nil.
···7075// can share indigo's single configured redirect URI.
7176func (h *AttestHandler) SetRecoveryIssuer(issuer RecoveryIssuer) {
7277 h.recoveryIssuer = issuer
7878+}
7979+8080+// SetEnrollAuthIssuer wires the enrollment identity-verification flow
8181+// into the shared OAuth callback. The enroll-auth sentinel in the
8282+// attestation payload signals this dispatch path.
8383+func (h *AttestHandler) SetEnrollAuthIssuer(issuer EnrollAuthIssuer) {
8484+ h.enrollAuthIssuer = issuer
8585+}
8686+8787+// SetFunnelRecorder wires enrollment funnel metrics.
8888+func (h *AttestHandler) SetFunnelRecorder(fr FunnelRecorder) {
8989+ h.funnel = fr
7390}
74917592// RegisterRoutes attaches handlers to the given mux.
···89106 h.renderError(w, r, "invalid form submission")
90107 return
91108 }
109109+ if h.funnel != nil {
110110+ h.funnel.RecordEnrollStep("attest_start")
111111+ }
92112 did := strings.TrimSpace(r.FormValue("did"))
93113 domain := strings.TrimSpace(r.FormValue("domain"))
94114 dkimSelector := strings.TrimSpace(r.FormValue("dkim_selector"))
···144164 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
145165 return
146166 }
167167+ if h.funnel != nil {
168168+ h.funnel.RecordEnrollStep("attest_callback")
169169+ }
147170 ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
148171 defer cancel()
149172···157180 sess, err := h.pub.CompleteCallback(ctx, params)
158181 if err != nil {
159182 log.Printf("attest.callback: callback_error=%v", err)
160160- // Don't surface error-description text to the user raw — it may
161161- // contain strings from the AS that we don't want to reflect. A
162162- // generic message + log detail is enough.
183183+ if h.funnel != nil {
184184+ h.funnel.RecordOAuthCallback("error")
185185+ }
163186 msg := "OAuth callback failed — please start over."
164187 if err == atpoauth.ErrPendingNotFound {
165188 msg = "This attestation flow has expired or was already used. Please start over."
···180203 h.renderError(w, r, "recovery flow is not enabled on this deployment")
181204 return
182205 }
206206+ if h.funnel != nil {
207207+ h.funnel.RecordOAuthCallback("recovery")
208208+ }
183209 target := h.recoveryIssuer.IssueRecoveryTicket(sess.AccountDID(), sess.Domain())
184210 log.Printf("attest.callback: did=%s domain=%s handoff=recovery target=%s",
185211 sess.AccountDID(), sess.Domain(), target)
···187213 return
188214 }
189215216216+ // Enrollment auth dispatch. The sentinel {"flow":"enroll-auth"}
217217+ // means the user is verifying DID ownership before filling out the
218218+ // enrollment form. No record is published — just mint a ticket.
219219+ if isEnrollAuthSentinel(sess.Attestation()) {
220220+ if h.enrollAuthIssuer == nil {
221221+ log.Printf("attest.callback: did=%s enroll_auth=true but no issuer wired", sess.AccountDID())
222222+ h.renderError(w, r, "enrollment identity verification is not enabled on this deployment")
223223+ return
224224+ }
225225+ if h.funnel != nil {
226226+ h.funnel.RecordOAuthCallback("enroll_auth")
227227+ }
228228+ target := h.enrollAuthIssuer.IssueEnrollAuthTicket(sess.AccountDID(), sess.Domain(), r.UserAgent())
229229+ if target == "" {
230230+ h.renderError(w, r, "enrollment service is temporarily overloaded — try again in a moment")
231231+ return
232232+ }
233233+ log.Printf("attest.callback: did=%s handle=%s handoff=enroll-auth",
234234+ sess.AccountDID(), sess.Domain())
235235+ http.Redirect(w, r, target, http.StatusFound)
236236+ return
237237+ }
238238+190239 // Decode the pending JSON back into a map so the APIClient Post can
191240 // re-encode it. Using a map keeps the record's field order stable
192241 // across the serialize→deserialize→publish path.
···212261 sess.AccountDID(), sess.Domain(), err)
213262 }
214263264264+ if h.funnel != nil {
265265+ h.funnel.RecordOAuthCallback("attestation")
266266+ }
215267 log.Printf("attest.callback: did=%s domain=%s rkey=%s published=true",
216268 sess.AccountDID(), sess.Domain(), rkey)
217269 w.Header().Set("Content-Type", "text/html; charset=utf-8")
···254306 return a.s.PutRecord(ctx, collection, rkey, record)
255307}
256308func (a *sessionAdapter) Close(ctx context.Context) { a.s.Close(ctx) }
309309+310310+func isEnrollAuthSentinel(b []byte) bool {
311311+ var m map[string]string
312312+ if err := json.Unmarshal(b, &m); err != nil {
313313+ return false
314314+ }
315315+ return m["flow"] == "enroll-auth"
316316+}
+353-44
internal/admin/ui/enroll.go
···33import (
44 "bytes"
55 "context"
66+ "crypto/rand"
77+ "crypto/sha256"
88+ "encoding/hex"
69 "encoding/json"
710 "fmt"
811 "io"
···1013 "net/http"
1114 "net/http/httptest"
1215 "strings"
1616+ "sync"
1317 "time"
14181519 "atmosphere-mail/internal/admin/ui/templates"
2020+ "atmosphere-mail/internal/atpoauth"
1621 "atmosphere-mail/internal/relay"
1722)
1823···4146 QueryLabels(ctx context.Context, did string) ([]string, error)
4247}
43484949+// DomainLister returns the sending domains registered under a DID.
5050+// Used by the enrollment landing to show existing enrollment state so
5151+// returning members see their domains and remaining quota. Nil is
5252+// allowed; the landing page silently degrades to the new-user form.
5353+type DomainLister interface {
5454+ ListMemberDomains(ctx context.Context, did string) ([]string, error)
5555+}
5656+5757+const (
5858+ enrollAuthTicketTTL = 15 * time.Minute
5959+ enrollAuthCookieName = "atmos_enroll_auth"
6060+ enrollAuthCookieMax = int(enrollAuthTicketTTL / time.Second)
6161+)
6262+6363+type enrollAuthTicket struct {
6464+ did string
6565+ handle string
6666+ expiry time.Time
6767+ uaHash [32]byte
6868+}
6969+7070+// EnrollAuthIssuer mints an enrollment-auth ticket after the OAuth
7171+// callback sees the enroll-auth sentinel. Implemented by EnrollHandler.
7272+type EnrollAuthIssuer interface {
7373+ IssueEnrollAuthTicket(did, handle, userAgent string) string
7474+}
7575+7676+// AccountTicketIssuer mints an account (recovery) session ticket so a
7777+// verified enrollment user can reach /account/manage without re-authenticating.
7878+type AccountTicketIssuer interface {
7979+ IssueRecoveryTicketWithUA(did, domain, ua string) string
8080+}
8181+8282+// FunnelRecorder records enrollment funnel step visits and OAuth callback outcomes.
8383+type FunnelRecorder interface {
8484+ RecordEnrollStep(step string)
8585+ RecordOAuthCallback(callbackType string)
8686+}
8787+4488type EnrollHandler struct {
4545- // adminAPI is the admin API whose /admin/enroll-start + /admin/enroll
4646- // handlers we invoke in-process. We construct sub-requests with
4747- // httptest.NewRecorder so no TCP round-trip happens and we don't
4848- // require a loopback HTTP client.
4989 adminAPI http.Handler
5050- // resolver optionally powers /enroll/resolve (handle→DID lookup). If
5151- // nil, the wizard still works — users just have to paste their DID
5252- // directly.
5390 resolver HandleResolver
5454- // labels optionally powers /enroll/label-status (labeler poll). If nil,
5555- // the endpoint returns 503 and the success page hides the polling UI.
5656- labels LabelStatusQuerier
5757- mux *http.ServeMux
9191+ labels LabelStatusQuerier
9292+ domains DomainLister
9393+ pub Publisher
9494+ account AccountTicketIssuer
9595+ funnel FunnelRecorder
9696+ mux *http.ServeMux
9797+9898+ mu sync.Mutex
9999+ tickets map[string]enrollAuthTicket
58100}
5910160102// NewEnrollHandler constructs a public enrollment UI that delegates the
61103// start/verify business logic to adminAPI (typically *admin.API). Pass
62104// resolver to enable handle→DID resolution at /enroll/resolve.
63105func NewEnrollHandler(adminAPI http.Handler, resolver HandleResolver) *EnrollHandler {
6464- h := &EnrollHandler{adminAPI: adminAPI, resolver: resolver, mux: http.NewServeMux()}
106106+ h := &EnrollHandler{adminAPI: adminAPI, resolver: resolver, mux: http.NewServeMux(), tickets: make(map[string]enrollAuthTicket)}
65107 h.mux.HandleFunc("/", h.handleMarketing)
66108 h.mux.HandleFunc("/enroll", h.handleLanding)
109109+ h.mux.HandleFunc("/enroll/auth", h.handleAuth)
110110+ h.mux.HandleFunc("/enroll/reset", h.handleReset)
111111+ h.mux.HandleFunc("/enroll/manage", h.handleManageBridge)
67112 h.mux.HandleFunc("/enroll/start", h.handleStart)
68113 h.mux.HandleFunc("/enroll/verify", h.handleVerify)
69114 h.mux.HandleFunc("/enroll/resolve", h.handleResolve)
···85130 h.labels = q
86131}
87132133133+// SetPublisher wires the OAuth client used for identity verification
134134+// during enrollment. When set, /enroll requires OAuth proof of DID
135135+// ownership before the domain enrollment form is shown.
136136+func (h *EnrollHandler) SetPublisher(pub Publisher) {
137137+ h.pub = pub
138138+}
139139+140140+// SetAccountTicketIssuer wires the recovery handler so that verified
141141+// enrollment users can navigate to /account/manage without re-authenticating.
142142+func (h *EnrollHandler) SetAccountTicketIssuer(ati AccountTicketIssuer) {
143143+ h.account = ati
144144+}
145145+146146+// SetDomainLister wires the domain lookup used by the enrollment
147147+// landing page to show existing enrollment state. When set, verified
148148+// users see their current domains and remaining quota instead of a
149149+// blank form. Nil = feature-off (new-user form always shown).
150150+func (h *EnrollHandler) SetDomainLister(dl DomainLister) {
151151+ h.domains = dl
152152+}
153153+154154+// SetFunnelRecorder wires enrollment funnel metrics.
155155+func (h *EnrollHandler) SetFunnelRecorder(fr FunnelRecorder) {
156156+ h.funnel = fr
157157+}
158158+159159+func (h *EnrollHandler) recordStep(step string) {
160160+ if h.funnel != nil {
161161+ h.funnel.RecordEnrollStep(step)
162162+ }
163163+}
164164+88165// handleLabelStatus answers GET /enroll/label-status?did=... with the
89166// current label set for a DID as reported by the labeler. Called by a
90167// small client-side poller on the enroll success page so members can
···92169// server-side so the labeler can remain tailnet-only.
93170//
94171// Response shape:
9595-// {"did":"did:plc:...","labels":["verified-mail-operator"],
9696-// "hasVerifiedMailOperator":true,"hasRelayMember":false}
172172+//
173173+// {"did":"did:plc:...","labels":["verified-mail-operator"],
174174+// "hasVerifiedMailOperator":true,"hasRelayMember":false}
97175//
98176// 503 when no labeler is configured. 400 when did is missing or too
99177// long (rudimentary sanity check; we don't strictly validate DID shape
···246324 if r.Method == http.MethodHead {
247325 return
248326 }
327327+ h.recordStep("marketing")
249328 signedOut := r.URL.Query().Get("signed_out") == "1"
250329 _ = templates.MarketingLanding(signedOut).Render(r.Context(), w)
251330}
252331253332func (h *EnrollHandler) handleLanding(w http.ResponseWriter, r *http.Request) {
254254- // Only render the landing page for exact path matches — avoid serving
255255- // it under arbitrary prefixes like /enroll/whatever that we don't
256256- // control.
257333 if r.URL.Path != "/enroll" && r.URL.Path != "/enroll/" {
258334 http.NotFound(w, r)
259335 return
···266342 if r.Method == http.MethodHead {
267343 return
268344 }
269269- _ = templates.EnrollLanding().Render(r.Context(), w)
345345+346346+ // Ticket in URL → set cookie, redirect to clean URL (same pattern
347347+ // as recovery — URLs leak via Referer, cookies don't).
348348+ if qt := strings.TrimSpace(r.URL.Query().Get("ticket")); qt != "" {
349349+ setEnrollAuthCookie(w, qt)
350350+ http.Redirect(w, r, "/enroll", http.StatusFound)
351351+ return
352352+ }
353353+354354+ // Check for auth cookie → Phase 2 (identity verified, show domain form).
355355+ var authDID, authHandle string
356356+ if id, ok := enrollAuthTicketFromCookie(r); ok {
357357+ if ticket, ok := h.lookupEnrollAuthTicket(id, r.UserAgent()); ok {
358358+ authDID = ticket.did
359359+ authHandle = ticket.handle
360360+ }
361361+ }
362362+363363+ // When the user is verified and we have a domain lister, fetch their
364364+ // existing domains so the template can show enrollment state.
365365+ var existingDomains []string
366366+ if authDID != "" && h.domains != nil {
367367+ if ds, err := h.domains.ListMemberDomains(r.Context(), authDID); err == nil {
368368+ existingDomains = ds
369369+ }
370370+ }
371371+372372+ h.recordStep("landing")
373373+ _ = templates.EnrollLanding(authDID, authHandle, h.pub != nil, existingDomains).Render(r.Context(), w)
270374}
271375272376// handleStart is step 1 of the wizard: the user POSTs (DID, domain) and we
···281385 h.renderError(w, r, "invalid form submission")
282386 return
283387 }
284284- did := strings.TrimSpace(r.FormValue("did"))
285285- identity := strings.TrimSpace(r.FormValue("identity"))
388388+ h.recordStep("enroll_start")
286389 domain := strings.TrimSpace(r.FormValue("domain"))
287390 contactEmail := strings.TrimSpace(r.FormValue("contact_email"))
288391289289- // Server-side fallback: if the landing JS didn't populate the hidden
290290- // `did` field (JS off, XHR blocked, resolver raced the submit), try
291291- // to resolve `identity` ourselves. Accepts either a full DID or a
292292- // handle. Keeps enrollments from failing ugly for users who don't
293293- // run Javascript or whose browser blocked the /enroll/resolve XHR.
294294- if did == "" && identity != "" {
295295- identity = strings.TrimPrefix(identity, "@")
296296- if strings.HasPrefix(identity, "did:") {
297297- did = identity
298298- } else if h.resolver != nil && relay.LooksLikeHandle(identity) {
299299- ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
300300- resolved, err := h.resolver.ResolveHandle(ctx, identity)
301301- cancel()
302302- if err != nil {
303303- log.Printf("enroll.start.server_resolve_failed: identity=%s error=%v", identity, err)
304304- h.renderError(w, r, fmt.Sprintf("handle %q did not resolve — check your DID document or paste your DID directly", identity))
305305- return
392392+ // When OAuth is configured, DID must come from the auth ticket — the
393393+ // user proved ownership via their PDS. This is the primary path.
394394+ var did string
395395+ if h.pub != nil {
396396+ if id, ok := enrollAuthTicketFromCookie(r); ok {
397397+ if ticket, ok := h.lookupEnrollAuthTicket(id, r.UserAgent()); ok {
398398+ did = ticket.did
399399+ }
400400+ }
401401+ if did == "" {
402402+ h.renderError(w, r, "Identity verification required — please verify your handle first")
403403+ return
404404+ }
405405+ } else {
406406+ // Legacy fallback: no OAuth configured, read DID from form.
407407+ did = strings.TrimSpace(r.FormValue("did"))
408408+ identity := strings.TrimSpace(r.FormValue("identity"))
409409+ if did == "" && identity != "" {
410410+ identity = strings.TrimPrefix(identity, "@")
411411+ if strings.HasPrefix(identity, "did:") {
412412+ did = identity
413413+ } else if h.resolver != nil && relay.LooksLikeHandle(identity) {
414414+ ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
415415+ resolved, err := h.resolver.ResolveHandle(ctx, identity)
416416+ cancel()
417417+ if err != nil {
418418+ log.Printf("enroll.start.server_resolve_failed: identity=%s error=%v", identity, err)
419419+ h.renderError(w, r, fmt.Sprintf("handle %q did not resolve — check your DID document or paste your DID directly", identity))
420420+ return
421421+ }
422422+ did = resolved
306423 }
307307- did = resolved
308424 }
309425 }
310426311427 if did == "" || domain == "" {
312312- h.renderError(w, r, "A handle or DID and a sending domain are both required")
428428+ h.renderError(w, r, "A verified identity and a sending domain are both required")
313429 return
314430 }
315431432432+ termsAccepted := r.FormValue("terms_accepted") == "on"
433433+316434 // Forward to /admin/enroll-start. No admin bearer — the endpoint is
317435 // public because token issuance on its own conveys no privilege; the
318436 // token is only useful once the corresponding TXT record exists.
319437 // contact_email is optional for backward-compat with existing form
320438 // submissions; when present it propagates into pending_enrollments so
321439 // the operator-ping and welcome emails have a recipient on file.
322322- body, _ := json.Marshal(map[string]string{
323323- "did": did,
324324- "domain": domain,
325325- "contactEmail": contactEmail,
440440+ body, _ := json.Marshal(map[string]any{
441441+ "did": did,
442442+ "domain": domain,
443443+ "contactEmail": contactEmail,
444444+ "termsAccepted": termsAccepted,
326445 })
327446 resp := h.proxyAdminInner(http.MethodPost, "/admin/enroll-start", bytes.NewReader(body))
328447 if resp.Code != http.StatusOK {
329329- h.renderError(w, r, fmt.Sprintf("enrollment start failed (%d): %s", resp.Code, strings.TrimSpace(resp.Body.String())))
448448+ msg := strings.TrimSpace(resp.Body.String())
449449+ if msg == "" {
450450+ msg = fmt.Sprintf("the relay returned an unexpected status (%d)", resp.Code)
451451+ }
452452+ h.renderError(w, r, msg)
330453 return
331454 }
332455 var sr struct {
···363486 h.renderError(w, r, "invalid form submission")
364487 return
365488 }
489489+ h.recordStep("enroll_verify")
366490 did := strings.TrimSpace(r.FormValue("did"))
367491 domain := strings.TrimSpace(r.FormValue("domain"))
368492 token := strings.TrimSpace(r.FormValue("token"))
···430554 },
431555 }
432556557557+ h.recordStep("enroll_success")
433558 log.Printf("enroll.public_success: did=%s domain=%s", er.DID, domain)
434559 w.Header().Set("Content-Type", "text/html; charset=utf-8")
435560 _ = templates.EnrollSuccess(result).Render(r.Context(), w)
561561+}
562562+563563+// handleAuth kicks off the OAuth flow to verify DID ownership before
564564+// enrollment. The sentinel attestation {"flow":"enroll-auth"} tells the
565565+// shared callback to dispatch here instead of publishing a record.
566566+func (h *EnrollHandler) handleAuth(w http.ResponseWriter, r *http.Request) {
567567+ if r.Method != http.MethodPost {
568568+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
569569+ return
570570+ }
571571+ if h.pub == nil {
572572+ h.renderError(w, r, "identity verification is not configured on this relay")
573573+ return
574574+ }
575575+ if err := r.ParseForm(); err != nil {
576576+ h.renderError(w, r, "invalid form submission")
577577+ return
578578+ }
579579+580580+ h.recordStep("auth_start")
581581+ did := strings.TrimSpace(r.FormValue("did"))
582582+ identity := strings.TrimSpace(r.FormValue("identity"))
583583+ handle := strings.TrimPrefix(strings.TrimSpace(identity), "@")
584584+585585+ if did == "" && identity != "" {
586586+ if strings.HasPrefix(strings.TrimSpace(identity), "did:") {
587587+ did = strings.TrimSpace(identity)
588588+ handle = did
589589+ } else if h.resolver != nil && relay.LooksLikeHandle(handle) {
590590+ ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
591591+ resolved, err := h.resolver.ResolveHandle(ctx, handle)
592592+ cancel()
593593+ if err != nil {
594594+ log.Printf("enroll.auth: handle=%s resolve_error=%v", handle, err)
595595+ h.renderError(w, r, fmt.Sprintf("handle %q did not resolve — check the spelling or paste a DID directly", handle))
596596+ return
597597+ }
598598+ did = resolved
599599+ }
600600+ }
601601+ if did == "" {
602602+ h.renderError(w, r, "A handle or DID is required")
603603+ return
604604+ }
605605+606606+ sentinel := []byte(`{"flow":"enroll-auth"}`)
607607+ ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
608608+ defer cancel()
609609+ authorizeURL, state, err := h.pub.StartAuthFlow(ctx, did, atpoauth.StartOptions{
610610+ ExpectedDID: did,
611611+ Domain: handle,
612612+ Attestation: sentinel,
613613+ })
614614+ if err != nil {
615615+ log.Printf("enroll.auth: did=%s handle=%s error=%v", did, handle, err)
616616+ h.renderError(w, r, fmt.Sprintf("couldn't start identity verification: %v", err))
617617+ return
618618+ }
619619+620620+ log.Printf("enroll.auth: did=%s handle=%s state=%s", did, handle, state)
621621+ http.Redirect(w, r, authorizeURL, http.StatusFound)
622622+}
623623+624624+// handleManageBridge lets a verified enrollment user reach /account/manage
625625+// without re-authenticating. Validates the enroll auth ticket, mints a
626626+// recovery ticket via the account issuer, and redirects.
627627+func (h *EnrollHandler) handleManageBridge(w http.ResponseWriter, r *http.Request) {
628628+ if h.account == nil {
629629+ http.Redirect(w, r, "/account", http.StatusFound)
630630+ return
631631+ }
632632+ id, ok := enrollAuthTicketFromCookie(r)
633633+ if !ok {
634634+ http.Redirect(w, r, "/account", http.StatusFound)
635635+ return
636636+ }
637637+ ticket, ok := h.lookupEnrollAuthTicket(id, r.UserAgent())
638638+ if !ok {
639639+ http.Redirect(w, r, "/account", http.StatusFound)
640640+ return
641641+ }
642642+ target := h.account.IssueRecoveryTicketWithUA(ticket.did, "", r.UserAgent())
643643+ if target == "" {
644644+ http.Redirect(w, r, "/account", http.StatusFound)
645645+ return
646646+ }
647647+ http.Redirect(w, r, target, http.StatusFound)
648648+}
649649+650650+// handleReset clears the enrollment auth cookie so the user can start
651651+// over with a different identity.
652652+func (h *EnrollHandler) handleReset(w http.ResponseWriter, r *http.Request) {
653653+ clearEnrollAuthCookie(w)
654654+ http.Redirect(w, r, "/enroll", http.StatusFound)
655655+}
656656+657657+// IssueEnrollAuthTicket mints a ticket proving DID ownership and returns
658658+// a redirect URL. Called by the shared OAuth callback when it sees the
659659+// enroll-auth sentinel.
660660+func (h *EnrollHandler) IssueEnrollAuthTicket(did, handle, userAgent string) string {
661661+ var raw [16]byte
662662+ _, _ = rand.Read(raw[:])
663663+ id := hex.EncodeToString(raw[:])
664664+665665+ t := enrollAuthTicket{
666666+ did: did,
667667+ handle: handle,
668668+ expiry: time.Now().Add(enrollAuthTicketTTL),
669669+ }
670670+ if userAgent != "" {
671671+ t.uaHash = sha256.Sum256([]byte(userAgent))
672672+ }
673673+674674+ h.mu.Lock()
675675+ now := time.Now()
676676+ for k, v := range h.tickets {
677677+ if now.After(v.expiry) {
678678+ delete(h.tickets, k)
679679+ }
680680+ }
681681+ h.tickets[id] = t
682682+ h.mu.Unlock()
683683+684684+ return "/enroll?ticket=" + id
685685+}
686686+687687+func (h *EnrollHandler) lookupEnrollAuthTicket(id, userAgent string) (enrollAuthTicket, bool) {
688688+ if id == "" {
689689+ return enrollAuthTicket{}, false
690690+ }
691691+ h.mu.Lock()
692692+ defer h.mu.Unlock()
693693+ t, ok := h.tickets[id]
694694+ if !ok {
695695+ return enrollAuthTicket{}, false
696696+ }
697697+ if time.Now().After(t.expiry) {
698698+ delete(h.tickets, id)
699699+ return enrollAuthTicket{}, false
700700+ }
701701+ var zero [32]byte
702702+ if t.uaHash != zero {
703703+ got := sha256.Sum256([]byte(userAgent))
704704+ if got != t.uaHash {
705705+ return enrollAuthTicket{}, false
706706+ }
707707+ }
708708+ return t, true
709709+}
710710+711711+func setEnrollAuthCookie(w http.ResponseWriter, ticket string) {
712712+ http.SetCookie(w, &http.Cookie{
713713+ Name: enrollAuthCookieName,
714714+ Value: ticket,
715715+ Path: "/enroll",
716716+ HttpOnly: true,
717717+ Secure: true,
718718+ SameSite: http.SameSiteLaxMode,
719719+ MaxAge: enrollAuthCookieMax,
720720+ })
721721+}
722722+723723+func clearEnrollAuthCookie(w http.ResponseWriter) {
724724+ http.SetCookie(w, &http.Cookie{
725725+ Name: enrollAuthCookieName,
726726+ Value: "",
727727+ Path: "/enroll",
728728+ HttpOnly: true,
729729+ Secure: true,
730730+ SameSite: http.SameSiteLaxMode,
731731+ MaxAge: -1,
732732+ })
733733+}
734734+735735+func enrollAuthTicketFromCookie(r *http.Request) (string, bool) {
736736+ c, err := r.Cookie(enrollAuthCookieName)
737737+ if err != nil || c == nil {
738738+ return "", false
739739+ }
740740+ id := strings.TrimSpace(c.Value)
741741+ if id == "" {
742742+ return "", false
743743+ }
744744+ return id, true
436745}
437746438747func (h *EnrollHandler) renderError(w http.ResponseWriter, r *http.Request, message string) {
+203-7
internal/admin/ui/enroll_test.go
···8383 }
8484 body := w.Body.String()
8585 if !strings.Contains(body, "/enroll/start") {
8686- t.Error("enroll landing should POST to /enroll/start")
8686+ t.Error("enroll landing without OAuth should POST to /enroll/start")
8787+ }
8888+ if !strings.Contains(body, `name="domain"`) {
8989+ t.Error("legacy enroll landing should include sending domain field")
9090+ }
9191+}
9292+9393+func TestEnrollLanding_OAuthEnabledStartsWithIdentityVerification(t *testing.T) {
9494+ h := NewEnrollHandler(&fakeAdminAPI{}, nil)
9595+ h.SetPublisher(&fakePublisher{})
9696+ req := httptest.NewRequest(http.MethodGet, "/enroll", nil)
9797+ w := httptest.NewRecorder()
9898+ h.ServeHTTP(w, req)
9999+ if w.Code != http.StatusOK {
100100+ t.Fatalf("status = %d, want 200", w.Code)
101101+ }
102102+ body := w.Body.String()
103103+ if !strings.Contains(body, "/enroll/auth") {
104104+ t.Error("enroll landing with OAuth should POST to /enroll/auth")
105105+ }
106106+ if strings.Contains(body, `name="domain"`) {
107107+ t.Error("OAuth phase 1 should not expose domain enrollment fields before identity verification")
108108+ }
109109+ if !strings.Contains(body, ">Your handle<") {
110110+ t.Error("identity label should say 'Your handle'")
111111+ }
112112+ if strings.Contains(body, "Bluesky") {
113113+ t.Error("identity copy should stay protocol-neutral")
114114+ }
115115+ if !strings.Contains(body, `href="https://atproto.com/specs/handle"`) {
116116+ t.Error("identity helper text should link to the handle spec")
87117 }
88118}
89119···138168 }
139169}
140170171171+func TestEnrollStart_OAuthRequiresVerifiedTicket(t *testing.T) {
172172+ fake := &fakeAdminAPI{}
173173+ h := NewEnrollHandler(fake, nil)
174174+ h.SetPublisher(&fakePublisher{})
175175+176176+ req := httptest.NewRequest(http.MethodPost, "/enroll/start", strings.NewReader("did=did:plc:client&domain=example.com"))
177177+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
178178+ w := httptest.NewRecorder()
179179+ h.ServeHTTP(w, req)
180180+181181+ if w.Code != http.StatusBadRequest {
182182+ t.Fatalf("status = %d, want 400", w.Code)
183183+ }
184184+ if fake.gotEnrollStart {
185185+ t.Error("admin API must not be called without a verified OAuth ticket")
186186+ }
187187+ if !strings.Contains(w.Body.String(), "Identity verification required") {
188188+ t.Errorf("error should explain identity verification requirement; body=%s", w.Body.String())
189189+ }
190190+}
191191+192192+func TestEnrollStart_OAuthUsesTicketDID(t *testing.T) {
193193+ fake := &fakeAdminAPI{
194194+ enrollStartStatus: http.StatusOK,
195195+ enrollStartBody: `{"token":"tok","dnsName":"_atmos-enroll.example.com","dnsValue":"atmos-verify=tok","expiresAt":"2026-04-17T12:00:00Z"}`,
196196+ }
197197+ h := NewEnrollHandler(fake, nil)
198198+ h.SetPublisher(&fakePublisher{})
199199+ target := h.IssueEnrollAuthTicket("did:plc:verified", "verified.example", "ua")
200200+ ticketID := strings.TrimPrefix(target, "/enroll?ticket=")
201201+202202+ req := httptest.NewRequest(http.MethodPost, "/enroll/start",
203203+ strings.NewReader("did=did:plc:client&domain=example.com&contact_email=user%40example.com"))
204204+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
205205+ req.Header.Set("User-Agent", "ua")
206206+ req.AddCookie(&http.Cookie{Name: enrollAuthCookieName, Value: ticketID})
207207+ w := httptest.NewRecorder()
208208+ h.ServeHTTP(w, req)
209209+210210+ if w.Code != http.StatusOK {
211211+ t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
212212+ }
213213+ if !fake.gotEnrollStart {
214214+ t.Fatal("admin API should have been called with verified DID")
215215+ }
216216+ if !strings.Contains(fake.lastBody, "did:plc:verified") {
217217+ t.Errorf("verified DID not forwarded to admin API; got %s", fake.lastBody)
218218+ }
219219+ if strings.Contains(fake.lastBody, "did:plc:client") {
220220+ t.Errorf("client-supplied DID should be ignored when OAuth is enabled; got %s", fake.lastBody)
221221+ }
222222+}
223223+141224func TestEnrollStart_AdminAPIFailureRendersError(t *testing.T) {
142225 fake := &fakeAdminAPI{
143226 enrollStartStatus: http.StatusConflict,
144144- enrollStartBody: "domain already registered",
227227+ enrollStartBody: "This domain is registered to another account.",
145228 }
146229 h := NewEnrollHandler(fake, nil)
147230···155238 t.Errorf("status = %d, want 400 (error template)", w.Code)
156239 }
157240 body := w.Body.String()
158158- if !strings.Contains(body, "failed") {
159159- t.Errorf("error template should mention failure; body: %s", body[:min(200, len(body))])
241241+ // The API message should appear directly in the rendered page —
242242+ // the UI passes it through without wrapping in a status-code prefix.
243243+ if !strings.Contains(body, "another account") {
244244+ t.Errorf("error template should contain the API error message; body: %s", body[:min(200, len(body))])
245245+ }
246246+ // Must render the error page, not the enrollment form.
247247+ if !strings.Contains(body, "enroll") {
248248+ t.Errorf("error template should contain a link back to /enroll; body: %s", body[:min(200, len(body))])
160249 }
161250}
162251···271360 h := NewEnrollHandler(&fakeAdminAPI{}, nil)
272361273362 cases := []string{
274274- "domain=example.com", // missing token
275275- "token=&domain=example.com", // empty token
363363+ "domain=example.com", // missing token
364364+ "token=&domain=example.com", // empty token
276365 }
277366 for _, form := range cases {
278367 req := httptest.NewRequest(http.MethodPost, "/enroll/verify", strings.NewReader(form))
···358447359448func TestEnrollResolve_InvalidHandleSyntax(t *testing.T) {
360449 h := NewEnrollHandler(&fakeAdminAPI{}, &fakeResolver{})
361361- // "no-dots" doesn't match the atproto handle regex (needs a dot).
450450+ // "no-dots" doesn't match the handle regex (needs a dot).
362451 req := httptest.NewRequest(http.MethodGet, "/enroll/resolve?handle=nodots", nil)
363452 w := httptest.NewRecorder()
364453 h.ServeHTTP(w, req)
···740829 }
741830 if resp.DID != "did:plc:abcd1234" {
742831 t.Errorf("did = %q, want did:plc:abcd1234", resp.DID)
832832+ }
833833+}
834834+835835+// --- Enrollment-aware landing (domain lister) ---
836836+837837+type fakeDomainLister struct {
838838+ domains map[string][]string // DID → domain names
839839+}
840840+841841+func (f *fakeDomainLister) ListMemberDomains(_ context.Context, did string) ([]string, error) {
842842+ return f.domains[did], nil
843843+}
844844+845845+func TestEnrollLanding_VerifiedNewUser_ShowsDomainForm(t *testing.T) {
846846+ // A verified user with no existing domains should see the standard
847847+ // domain enrollment form with "Identity verified" messaging.
848848+ h := NewEnrollHandler(&fakeAdminAPI{}, nil)
849849+ h.SetPublisher(&fakePublisher{})
850850+ h.SetDomainLister(&fakeDomainLister{domains: map[string][]string{}})
851851+852852+ did := "did:plc:newusertest1234567890ab"
853853+ ticket := h.IssueEnrollAuthTicket(did, "newuser.bsky.social", "test-ua")
854854+ req := httptest.NewRequest(http.MethodGet, "/enroll", nil)
855855+ req.Header.Set("User-Agent", "test-ua")
856856+ req.AddCookie(&http.Cookie{Name: enrollAuthCookieName, Value: ticket[len("/enroll?ticket="):]})
857857+ w := httptest.NewRecorder()
858858+ h.ServeHTTP(w, req)
859859+860860+ body := w.Body.String()
861861+ if !strings.Contains(body, "Identity verified") {
862862+ t.Error("new verified user should see 'Identity verified' messaging")
863863+ }
864864+ if !strings.Contains(body, `name="domain"`) {
865865+ t.Error("new verified user should see domain enrollment form")
866866+ }
867867+ if !strings.Contains(body, "Start enrollment") {
868868+ t.Error("new user should see 'Start enrollment' button text")
869869+ }
870870+}
871871+872872+func TestEnrollLanding_VerifiedOneDomain_ShowsAddForm(t *testing.T) {
873873+ // A verified user with 1 domain should see their existing domain,
874874+ // a message about adding one more, and the domain form.
875875+ h := NewEnrollHandler(&fakeAdminAPI{}, nil)
876876+ h.SetPublisher(&fakePublisher{})
877877+ did := "did:plc:onedomain1234567890abcd"
878878+ h.SetDomainLister(&fakeDomainLister{domains: map[string][]string{
879879+ did: {"existing.example.com"},
880880+ }})
881881+882882+ ticket := h.IssueEnrollAuthTicket(did, "user.bsky.social", "test-ua")
883883+ req := httptest.NewRequest(http.MethodGet, "/enroll", nil)
884884+ req.Header.Set("User-Agent", "test-ua")
885885+ req.AddCookie(&http.Cookie{Name: enrollAuthCookieName, Value: ticket[len("/enroll?ticket="):]})
886886+ w := httptest.NewRecorder()
887887+ h.ServeHTTP(w, req)
888888+889889+ body := w.Body.String()
890890+ if !strings.Contains(body, "existing.example.com") {
891891+ t.Error("should show existing domain name")
892892+ }
893893+ if !strings.Contains(body, "one more") {
894894+ t.Error("should mention they can add one more domain")
895895+ }
896896+ if !strings.Contains(body, `name="domain"`) {
897897+ t.Error("should still show domain enrollment form")
898898+ }
899899+ if !strings.Contains(body, "Add domain") {
900900+ t.Error("button text should say 'Add domain' not 'Start enrollment'")
901901+ }
902902+ if !strings.Contains(body, `href="/enroll/manage"`) {
903903+ t.Error("should link to /enroll/manage for management")
904904+ }
905905+}
906906+907907+func TestEnrollLanding_VerifiedAtLimit_ShowsNoForm(t *testing.T) {
908908+ // A verified user at the 2-domain limit should see their domains
909909+ // and a message about the limit — no domain form.
910910+ h := NewEnrollHandler(&fakeAdminAPI{}, nil)
911911+ h.SetPublisher(&fakePublisher{})
912912+ did := "did:plc:twodomain1234567890abcd"
913913+ h.SetDomainLister(&fakeDomainLister{domains: map[string][]string{
914914+ did: {"first.example.com", "second.example.com"},
915915+ }})
916916+917917+ ticket := h.IssueEnrollAuthTicket(did, "user.bsky.social", "test-ua")
918918+ req := httptest.NewRequest(http.MethodGet, "/enroll", nil)
919919+ req.Header.Set("User-Agent", "test-ua")
920920+ req.AddCookie(&http.Cookie{Name: enrollAuthCookieName, Value: ticket[len("/enroll?ticket="):]})
921921+ w := httptest.NewRecorder()
922922+ h.ServeHTTP(w, req)
923923+924924+ body := w.Body.String()
925925+ if !strings.Contains(body, "first.example.com") {
926926+ t.Error("should show first domain name")
927927+ }
928928+ if !strings.Contains(body, "second.example.com") {
929929+ t.Error("should show second domain name")
930930+ }
931931+ if !strings.Contains(body, "maximum") {
932932+ t.Error("should mention the maximum domain limit")
933933+ }
934934+ if strings.Contains(body, `name="domain"`) {
935935+ t.Error("should NOT show domain enrollment form when at limit")
936936+ }
937937+ if !strings.Contains(body, `href="/enroll/manage"`) {
938938+ t.Error("should link to /enroll/manage for management")
743939 }
744940}
745941
+34
internal/admin/ui/handlers.go
···3737// operator webhook stream as the JSON API actions. Nil = skip.
3838type NotifyStateChangeHook func(kind, did, reason string)
39394040+// WarmupHook sends a batch of warmup emails for a member DID. Returns
4141+// sent/failed counts and per-recipient errors. Nil = feature disabled.
4242+type WarmupHook func(ctx context.Context, did string) (sent, failed int, errors []string, err error)
4343+4044// Handler serves the operator dashboard UI.
4145type Handler struct {
4246 store *relaystore.Store
4347 labelQuerier LabelQuerier
4448 queueDepth QueueDepthFunc // may be nil
4949+ warmupSeeds int // number of configured seed addresses (0 = hidden)
4550 mux *http.ServeMux
4651 onApprove ApproveHook
4752 onRegenerate RegenerateKeyHook
4853 onStateChange NotifyStateChangeHook
5454+ onWarmup WarmupHook
49555056 // allowedOrigins is the CSRF allowlist for state-changing admin
5157 // POSTs. Populated via NewWithOrigins/AllowOrigins; when empty,
···8389 h.onStateChange = hook
8490}
85919292+// SetWarmupHook wires the warmup button on member detail pages.
9393+// seedCount controls whether the button renders (0 = hidden).
9494+func (h *Handler) SetWarmupHook(hook WarmupHook, seedCount int) {
9595+ h.onWarmup = hook
9696+ h.warmupSeeds = seedCount
9797+}
9898+8699// New creates a UI handler that serves the operator dashboard.
87100// labelQuerier may be nil if the labeler is not configured.
88101func New(store *relaystore.Store, labelQuerier LabelQuerier) *Handler {
···236249 h.handleMemberRejectAction(w, r, did)
237250 case action == "regenerate-key" && r.Method == http.MethodPost:
238251 h.handleMemberRegenerateKeyAction(w, r, did)
252252+ case action == "warmup" && r.Method == http.MethodPost:
253253+ h.handleMemberWarmupAction(w, r, did)
239254 default:
240255 http.NotFound(w, r)
241256 }
···254269 }
255270256271 detail := memberToDetail(member, domains)
272272+ detail.WarmupSeeds = h.warmupSeeds
257273258274 // Fetch labels from the labeler (best-effort — don't fail if unavailable)
259275 if h.labelQuerier != nil {
···453469 APIKey: apiKey,
454470 }).Render(r.Context(), w)
455471}
472472+473473+func (h *Handler) handleMemberWarmupAction(w http.ResponseWriter, r *http.Request, did string) {
474474+ if h.onWarmup == nil {
475475+ http.Error(w, "warmup not configured", http.StatusServiceUnavailable)
476476+ return
477477+ }
478478+479479+ sent, failed, errors, err := h.onWarmup(r.Context(), did)
480480+ if err != nil {
481481+ log.Printf("ui.warmup: did=%s error=%v", did, err)
482482+ http.Error(w, "warmup failed: "+err.Error(), http.StatusInternalServerError)
483483+ return
484484+ }
485485+486486+ log.Printf("ui.warmup: did=%s sent=%d failed=%d", did, sent, failed)
487487+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
488488+ _ = templates.WarmupResult(sent, failed, errors).Render(r.Context(), w)
489489+}
+161-33
internal/admin/ui/recover.go
···33333434 "atmosphere-mail/internal/admin/ui/templates"
3535 "atmosphere-mail/internal/atpoauth"
3636+ "atmosphere-mail/internal/relay"
3637 "atmosphere-mail/internal/relaystore"
3738)
3839···7980type RecoverHandler struct {
8081 pub Publisher
8182 store *relaystore.Store
8383+ resolver HandleResolver
8284 siteBaseURL string
8385 // allowedOrigins are the CSRF-acceptable origins for /account/*
8486 // POSTs. Defaults to [siteBaseURL] when unset.
···8789 // main.go to the admin API's regenerate-key logic so we don't reach
8890 // across packages to duplicate the SQL.
8991 regenFn RecoverRegenerateFunc
9292+ // onContactEmailChanged is called after a successful contact_email
9393+ // update so the admin API can trigger email re-verification. Nil =
9494+ // no-op (verification feature not wired).
9595+ onContactEmailChanged func(ctx context.Context, domain, contactEmail string)
90969197 mu sync.Mutex
9298 tickets map[string]recoveryTicket
···101107 closeOnce sync.Once
102108}
103109110110+// SetHandleResolver wires handle→DID resolution for the /account sign-in
111111+// form. Nil leaves server-side handle fallback disabled, but users may
112112+// still submit a DID directly.
113113+func (h *RecoverHandler) SetHandleResolver(r HandleResolver) {
114114+ h.resolver = r
115115+}
116116+117117+// SetContactEmailChangedHook registers a callback invoked after a
118118+// successful contact_email update. The callback receives the domain and
119119+// the new contact email address so the caller (main.go) can trigger
120120+// email re-verification without the UI package importing admin.
121121+func (h *RecoverHandler) SetContactEmailChangedHook(fn func(ctx context.Context, domain, contactEmail string)) {
122122+ h.onContactEmailChanged = fn
123123+}
124124+104125// RecoverRegenerateFunc rotates the API key for (did, domain) and
105126// returns the new plaintext key. Called with the context of the HTTP
106127// request so it can time out with the user.
···201222 mux.Handle("/account", wrap(h.handleLanding))
202223 mux.Handle("/account/start", wrap(h.handleStart))
203224 mux.Handle("/account/manage", wrap(h.handleManage))
225225+ mux.Handle("/account/select-domain", wrap(h.handleSelectDomain))
204226 mux.Handle("/account/regenerate", wrap(h.handleRegenerate))
205227 mux.Handle("/account/contact-email", wrap(h.handleContactEmail))
206228 mux.Handle("/account/sign-out", wrap(h.handleSignOut))
···309331}
310332311333// handleLanding renders the entry form where the member enters the
312312-// sending domain they originally enrolled.
334334+// handle or DID they originally enrolled.
313335func (h *RecoverHandler) handleLanding(w http.ResponseWriter, r *http.Request) {
314336 if r.Method != http.MethodGet {
315337 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
···319341 _ = templates.RecoverLanding("").Render(r.Context(), w)
320342}
321343322322-// handleStart reads the domain from the submitted form, looks up the
323323-// member DID, and kicks off OAuth. Omitting the Attestation field in
324324-// StartOptions is the signal to the callback handler that this is a
325325-// recovery flow, not an enrollment.
344344+// handleStart reads the submitted handle/DID and kicks off OAuth. Domain
345345+// selection happens after OAuth, when we know the browser controls the DID.
346346+// Omitting the Attestation field in StartOptions is the signal to the
347347+// callback handler that this is a recovery flow, not an enrollment.
326348func (h *RecoverHandler) handleStart(w http.ResponseWriter, r *http.Request) {
327349 if r.Method != http.MethodPost {
328350 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
···332354 h.renderLandingErr(w, r, "invalid form submission")
333355 return
334356 }
335335- domain := strings.TrimSpace(strings.ToLower(r.FormValue("domain")))
336336- if domain == "" {
337337- h.renderLandingErr(w, r, "A domain is required")
338338- return
339339- }
340340- // Audit #156: validate domain server-side. The HTML pattern=
341341- // attribute is bypassable, and a domain with CRLF will corrupt
342342- // any downstream log line if we let it through.
343343- if !isValidRecoveryDomain(domain) {
344344- log.Printf("recover.start: invalid_domain domain=%s", sanitizeForLog(domain))
345345- h.renderLandingErr(w, r, "That doesn't look like a valid domain. Check the spelling and try again.")
346346- return
357357+ did := strings.TrimSpace(r.FormValue("did"))
358358+ identity := strings.TrimSpace(r.FormValue("identity"))
359359+ if did == "" && identity != "" {
360360+ identity = strings.TrimPrefix(identity, "@")
361361+ if strings.HasPrefix(identity, "did:") {
362362+ did = identity
363363+ } else if h.resolver != nil && relay.LooksLikeHandle(identity) {
364364+ ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
365365+ resolved, err := h.resolver.ResolveHandle(ctx, identity)
366366+ cancel()
367367+ if err != nil {
368368+ log.Printf("recover.start: identity_resolve_failed identity=%s err=%v", sanitizeForLog(identity), err)
369369+ h.renderLandingErr(w, r, "That handle didn't resolve. Check the spelling or paste your DID directly.")
370370+ return
371371+ }
372372+ did = resolved
373373+ }
347374 }
348348-349349- // Look up the DID from the domain. If the domain isn't enrolled,
350350- // return a generic "check your domain" message — deliberately NOT
351351- // distinguishing "not found" from "wrong entry" to avoid a domain
352352- // enumeration oracle.
353353- memberDomain, err := h.store.GetMemberDomain(r.Context(), domain)
354354- if err != nil || memberDomain == nil {
355355- log.Printf("recover.start: not_found domain=%s err=%v", sanitizeForLog(domain), err)
356356- h.renderLandingErr(w, r, "we couldn't find an enrollment for that domain. Check the spelling, or enroll if this is your first time.")
375375+ if did == "" || !strings.HasPrefix(did, "did:") {
376376+ h.renderLandingErr(w, r, "A handle or DID is required")
357377 return
358378 }
359379360380 // Kick off OAuth. Empty Attestation payload — the callback handler
361381 // will read that as "recovery, not enrollment" and dispatch here.
362362- authorizeURL, state, err := h.pub.StartAuthFlow(r.Context(), memberDomain.DID, atpoauth.StartOptions{
363363- ExpectedDID: memberDomain.DID,
364364- Domain: domain,
382382+ authorizeURL, state, err := h.pub.StartAuthFlow(r.Context(), did, atpoauth.StartOptions{
383383+ ExpectedDID: did,
384384+ // Domain deliberately empty. /account/manage will select the
385385+ // member domain after OAuth proves DID ownership.
365386 // Attestation deliberately nil.
366387 })
367388 if err != nil {
···369390 // message to the user. Upstream error strings can carry PDS
370391 // hostnames, network internals, and indigo-specific tokens
371392 // that don't belong in a browser.
372372- log.Printf("recover.start: start_error did_hash=%s domain=%s err=%v",
373373- HashForLog(memberDomain.DID), sanitizeForLog(domain), err)
393393+ log.Printf("recover.start: start_error did_hash=%s err=%v",
394394+ HashForLog(did), err)
374395 h.renderLandingErr(w, r, "Couldn't start sign-in. Try again in a moment.")
375396 return
376397 }
377398378378- log.Printf("recover.start: did_hash=%s domain=%s state_hash=%s",
379379- HashForLog(memberDomain.DID), sanitizeForLog(domain), HashForLog(state))
399399+ log.Printf("recover.start: did_hash=%s state_hash=%s",
400400+ HashForLog(did), HashForLog(state))
380401 http.Redirect(w, r, authorizeURL, http.StatusFound)
381402}
382403···415436 return
416437 }
417438439439+ if ticket.domain == "" {
440440+ domains, err := h.store.ListMemberDomains(r.Context(), ticket.did)
441441+ if err != nil {
442442+ log.Printf("recover.manage: did_hash=%s domain_list_error=%v", HashForLog(ticket.did), err)
443443+ http.Error(w, "internal error", http.StatusInternalServerError)
444444+ return
445445+ }
446446+ if len(domains) == 0 {
447447+ log.Printf("recover.manage: did_hash=%s no_domains", HashForLog(ticket.did))
448448+ h.renderLandingErr(w, r, "No enrolled domains found for that account. Enroll first, then come back to Account.")
449449+ return
450450+ }
451451+ if len(domains) == 1 {
452452+ if updated, ok := h.setTicketDomain(id, r.UserAgent(), domains[0].Domain); ok {
453453+ ticket = updated
454454+ } else {
455455+ h.renderLandingErr(w, r, "Recovery session expired or not found. Start recovery again.")
456456+ return
457457+ }
458458+ } else {
459459+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
460460+ _ = templates.RecoverSelectDomain(templates.RecoverSelectDomainData{
461461+ DID: ticket.did,
462462+ Domains: domains,
463463+ ExpiresAt: ticket.expiry.Format(time.RFC3339),
464464+ }).Render(r.Context(), w)
465465+ return
466466+ }
467467+ }
468468+418469 memberDomain, err := h.store.GetMemberDomain(r.Context(), ticket.domain)
419470 if err != nil || memberDomain == nil {
420471 log.Printf("recover.manage: domain=%s lookup_error=%v", sanitizeForLog(ticket.domain), err)
···432483 }).Render(r.Context(), w)
433484}
434485486486+func (h *RecoverHandler) handleSelectDomain(w http.ResponseWriter, r *http.Request) {
487487+ if r.Method != http.MethodPost {
488488+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
489489+ return
490490+ }
491491+ if err := r.ParseForm(); err != nil {
492492+ http.Error(w, "invalid form", http.StatusBadRequest)
493493+ return
494494+ }
495495+ id, ok := recoveryTicketFromCookie(r)
496496+ if !ok {
497497+ h.renderLandingErr(w, r, "Session expired or not found. Start over by signing in.")
498498+ return
499499+ }
500500+ ticket, ok := h.lookupTicket(id, r.UserAgent())
501501+ if !ok {
502502+ h.renderLandingErr(w, r, "Session expired or not found. Start over by signing in.")
503503+ return
504504+ }
505505+ if ticket.domain != "" {
506506+ http.Redirect(w, r, "/account/manage", http.StatusFound)
507507+ return
508508+ }
509509+ domain := strings.TrimSpace(strings.ToLower(r.FormValue("domain")))
510510+ if !isValidRecoveryDomain(domain) {
511511+ log.Printf("account.select_domain: invalid_domain did_hash=%s domain=%s", HashForLog(ticket.did), sanitizeForLog(domain))
512512+ h.renderLandingErr(w, r, "That doesn't look like a valid domain. Sign in again and choose a listed domain.")
513513+ return
514514+ }
515515+ memberDomain, err := h.store.GetMemberDomain(r.Context(), domain)
516516+ if err != nil || memberDomain == nil || memberDomain.DID != ticket.did {
517517+ log.Printf("account.select_domain: did_hash=%s domain=%s allowed=false err=%v",
518518+ HashForLog(ticket.did), sanitizeForLog(domain), err)
519519+ h.renderLandingErr(w, r, "That domain is not enrolled for this account. Sign in again and choose a listed domain.")
520520+ return
521521+ }
522522+ if _, ok := h.setTicketDomain(id, r.UserAgent(), domain); !ok {
523523+ h.renderLandingErr(w, r, "Session expired or not found. Start over by signing in.")
524524+ return
525525+ }
526526+ log.Printf("account.select_domain: did_hash=%s domain=%s", HashForLog(ticket.did), sanitizeForLog(domain))
527527+ http.Redirect(w, r, "/account/manage", http.StatusFound)
528528+}
529529+435530// handleRegenerate consumes the ticket, rotates the API key via the
436531// injected regenFn, and renders the one-time view of the new plaintext
437532// key. POST-only so a GET from a browser referer doesn't accidentally
···459554 h.renderLandingErr(w, r, "Recovery session expired or already used. Start recovery again.")
460555 return
461556 }
557557+ if ticket.domain == "" {
558558+ h.renderLandingErr(w, r, "Choose a domain before rotating your API key.")
559559+ return
560560+ }
462561463562 apiKey, err := h.regenFn(ticket.did, ticket.domain)
464563 if err != nil {
···509608 h.renderLandingErr(w, r, "Session expired or not found. Start over by signing in.")
510609 return
511610 }
611611+ if ticket.domain == "" {
612612+ http.Redirect(w, r, "/account/manage", http.StatusFound)
613613+ return
614614+ }
512615 email := strings.TrimSpace(r.FormValue("contact_email"))
513616 // Audit #156: empty is OK (unset); non-empty must parse as a
514617 // valid RFC 5322 address. net/mail.ParseAddress is stricter than
···528631 }
529632 log.Printf("account.contact_email: did_hash=%s domain=%s updated",
530633 HashForLog(ticket.did), sanitizeForLog(ticket.domain))
634634+ // Trigger re-verification for the new address. Goroutined so a
635635+ // slow enqueue doesn't delay the page render. No-op if hook is nil
636636+ // (verification feature not wired) or if email was cleared.
637637+ if h.onContactEmailChanged != nil && email != "" {
638638+ go h.onContactEmailChanged(context.Background(), ticket.domain, email)
639639+ }
531640 h.renderManageWithMessage(w, r, ticket, "Contact email updated.", false)
532641}
533642···612721 if !uaMatches(t.uaHash, userAgent) {
613722 return recoveryTicket{}, false
614723 }
724724+ return t, true
725725+}
726726+727727+func (h *RecoverHandler) setTicketDomain(id, userAgent, domain string) (recoveryTicket, bool) {
728728+ if id == "" || domain == "" {
729729+ return recoveryTicket{}, false
730730+ }
731731+ h.mu.Lock()
732732+ defer h.mu.Unlock()
733733+ t, ok := h.tickets[id]
734734+ if !ok || t.consumed || time.Now().After(t.expiry) {
735735+ delete(h.tickets, id)
736736+ return recoveryTicket{}, false
737737+ }
738738+ if !uaMatches(t.uaHash, userAgent) {
739739+ return recoveryTicket{}, false
740740+ }
741741+ t.domain = domain
742742+ h.tickets[id] = t
615743 return t, true
616744}
617745
+219-23
internal/admin/ui/recover_test.go
···7373 }
7474}
75757676+func addRecoverDomain(t *testing.T, s *relaystore.Store, did, domain string) {
7777+ t.Helper()
7878+ now := time.Now().UTC()
7979+ if err := s.InsertMemberDomain(context.Background(), &relaystore.MemberDomain{
8080+ DID: did,
8181+ Domain: domain,
8282+ APIKeyHash: []byte("old-hash"),
8383+ DKIMRSAPriv: []byte("rsa"),
8484+ DKIMEdPriv: []byte("ed"),
8585+ DKIMSelector: "atmos20260420",
8686+ CreatedAt: now,
8787+ }); err != nil {
8888+ t.Fatalf("add domain: %v", err)
8989+ }
9090+}
9191+7692func TestRecover_LandingRendersForm(t *testing.T) {
7793 h := NewRecoverHandler(&fakePublisher{}, newRecoverTestStore(t), "https://example.com", nil)
7894 req := httptest.NewRequest(http.MethodGet, "/account", nil)
···85101 t.Fatalf("status = %d, want 200", rec.Code)
86102 }
87103 body := rec.Body.String()
8888- if !strings.Contains(body, `name="domain"`) {
8989- t.Error("landing page missing domain input")
104104+ if !strings.Contains(body, `name="identity"`) {
105105+ t.Error("landing page missing identity input")
106106+ }
107107+ if !strings.Contains(body, `>Your handle<`) {
108108+ t.Error("landing page should use protocol-neutral handle label")
109109+ }
110110+ if !strings.Contains(body, `href="https://atproto.com/specs/handle"`) {
111111+ t.Error("landing page should link to the handle spec")
112112+ }
113113+ if strings.Contains(body, `name="domain"`) {
114114+ t.Error("landing page should not ask for a domain before OAuth")
90115 }
91116 if !strings.Contains(body, `action="/account/start"`) {
92117 t.Error("landing page missing form action")
···101126 pub := &fakePublisher{returnURL: "https://pds.example/oauth/authorize?foo=bar"}
102127 h := NewRecoverHandler(pub, store, "https://example.com", nil)
103128104104- form := url.Values{"domain": {"recover.example.com"}}
129129+ form := url.Values{"did": {did}, "identity": {did}}
105130 req := httptest.NewRequest(http.MethodPost, "/account/start", strings.NewReader(form.Encode()))
106131 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
107132 req.Header.Set("Origin", "https://example.com")
···122147 if pub.lastOpts.ExpectedDID != did {
123148 t.Errorf("ExpectedDID = %q, want %q", pub.lastOpts.ExpectedDID, did)
124149 }
150150+ if pub.lastOpts.Domain != "" {
151151+ t.Errorf("Domain = %q, want empty before post-OAuth domain selection", pub.lastOpts.Domain)
152152+ }
125153 if len(pub.lastOpts.Attestation) != 0 {
126154 t.Errorf("Attestation should be empty for recovery flow, got %d bytes", len(pub.lastOpts.Attestation))
127155 }
128156}
129157130130-func TestRecover_StartRejectsUnknownDomain(t *testing.T) {
131131- h := NewRecoverHandler(&fakePublisher{}, newRecoverTestStore(t), "https://example.com", nil)
132132- form := url.Values{"domain": {"ghost.example"}}
158158+func TestRecover_StartDoesNotRequireKnownDomainBeforeOAuth(t *testing.T) {
159159+ pub := &fakePublisher{returnURL: "https://pds.example/oauth/authorize?foo=bar"}
160160+ h := NewRecoverHandler(pub, newRecoverTestStore(t), "https://example.com", nil)
161161+ form := url.Values{"did": {"did:plc:recover1111111111111aa"}}
133162 req := httptest.NewRequest(http.MethodPost, "/account/start", strings.NewReader(form.Encode()))
134163 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
135164 req.Header.Set("Origin", "https://example.com")
···138167 h.RegisterRoutes(mux)
139168 mux.ServeHTTP(rec, req)
140169170170+ if rec.Code != http.StatusFound {
171171+ t.Fatalf("status = %d, want 302; body=%q", rec.Code, rec.Body.String())
172172+ }
173173+ if got := rec.Header().Get("Location"); got != pub.returnURL {
174174+ t.Errorf("redirect to %q, want %q", got, pub.returnURL)
175175+ }
176176+ if pub.lastOpts.Domain != "" {
177177+ t.Errorf("Domain = %q, want empty before post-OAuth domain selection", pub.lastOpts.Domain)
178178+ }
179179+}
180180+181181+func TestRecover_SelectDomainRejectsDIDDomainMismatch(t *testing.T) {
182182+ store := newRecoverTestStore(t)
183183+ seedRecoverMember(t, store, "did:plc:recover1111111111111aa", "recover.example.com")
184184+185185+ h := NewRecoverHandler(&fakePublisher{}, store, "https://example.com", nil)
186186+ target := h.IssueRecoveryTicket("did:plc:other222222222222222", "")
187187+ ticket := strings.TrimPrefix(target, "/account/manage?ticket=")
188188+ form := url.Values{"domain": {"recover.example.com"}}
189189+ req := httptest.NewRequest(http.MethodPost, "/account/select-domain", strings.NewReader(form.Encode()))
190190+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
191191+ req.Header.Set("Origin", "https://example.com")
192192+ req.AddCookie(&http.Cookie{Name: RecoveryCookieName, Value: ticket})
193193+ rec := httptest.NewRecorder()
194194+ mux := http.NewServeMux()
195195+ h.RegisterRoutes(mux)
196196+ mux.ServeHTTP(rec, req)
197197+141198 if rec.Code != http.StatusBadRequest {
142199 t.Fatalf("status = %d, want 400", rec.Code)
143200 }
144144- // Don't leak a "domain not found" oracle; assertion: the message is
145145- // generic and the form re-renders for retry.
146146- body := rec.Body.String()
147147- if !strings.Contains(body, `name="domain"`) {
148148- t.Error("expected landing form re-rendered on error")
201201+}
202202+203203+func TestRecover_StartServerResolvesHandleWhenDIDMissing(t *testing.T) {
204204+ store := newRecoverTestStore(t)
205205+ did := "did:plc:recover1111111111111aa"
206206+ seedRecoverMember(t, store, did, "recover.example.com")
207207+208208+ pub := &fakePublisher{returnURL: "https://pds.example/oauth/authorize?foo=bar"}
209209+ h := NewRecoverHandler(pub, store, "https://example.com", nil)
210210+ h.SetHandleResolver(&fakeResolver{did: did})
211211+ form := url.Values{"identity": {"recover.example.com"}}
212212+ req := httptest.NewRequest(http.MethodPost, "/account/start", strings.NewReader(form.Encode()))
213213+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
214214+ req.Header.Set("Origin", "https://example.com")
215215+ rec := httptest.NewRecorder()
216216+ mux := http.NewServeMux()
217217+ h.RegisterRoutes(mux)
218218+ mux.ServeHTTP(rec, req)
219219+220220+ if rec.Code != http.StatusFound {
221221+ t.Fatalf("status = %d, want 302; body=%q", rec.Code, rec.Body.String())
222222+ }
223223+ if pub.lastIdentifier != did {
224224+ t.Errorf("published identifier = %q, want %q", pub.lastIdentifier, did)
149225 }
150226}
151227···192268 }
193269}
194270271271+func TestRecover_ManageAutoSelectsOnlyDomain(t *testing.T) {
272272+ store := newRecoverTestStore(t)
273273+ did := "did:plc:single11111111111111"
274274+ domain := "single.example.com"
275275+ seedRecoverMember(t, store, did, domain)
276276+277277+ h := NewRecoverHandler(&fakePublisher{}, store, "https://example.com", nil)
278278+ target := h.IssueRecoveryTicket(did, "")
279279+ ticket := strings.TrimPrefix(target, "/account/manage?ticket=")
280280+281281+ req := httptest.NewRequest(http.MethodGet, "/account/manage", nil)
282282+ req.AddCookie(&http.Cookie{Name: RecoveryCookieName, Value: ticket})
283283+ rec := httptest.NewRecorder()
284284+ mux := http.NewServeMux()
285285+ h.RegisterRoutes(mux)
286286+ mux.ServeHTTP(rec, req)
287287+288288+ if rec.Code != http.StatusOK {
289289+ t.Fatalf("status = %d, want 200", rec.Code)
290290+ }
291291+ body := rec.Body.String()
292292+ if !strings.Contains(body, domain) {
293293+ t.Error("manage page should auto-select the only enrolled domain")
294294+ }
295295+ if strings.Contains(body, `action="/account/select-domain"`) {
296296+ t.Error("single-domain account should not show the domain picker")
297297+ }
298298+}
299299+300300+func TestRecover_ManagePromptsWhenMultipleDomains(t *testing.T) {
301301+ store := newRecoverTestStore(t)
302302+ did := "did:plc:multi111111111111111"
303303+ seedRecoverMember(t, store, did, "one.example.com")
304304+ addRecoverDomain(t, store, did, "two.example.com")
305305+306306+ h := NewRecoverHandler(&fakePublisher{}, store, "https://example.com", nil)
307307+ target := h.IssueRecoveryTicket(did, "")
308308+ ticket := strings.TrimPrefix(target, "/account/manage?ticket=")
309309+310310+ req := httptest.NewRequest(http.MethodGet, "/account/manage", nil)
311311+ req.AddCookie(&http.Cookie{Name: RecoveryCookieName, Value: ticket})
312312+ rec := httptest.NewRecorder()
313313+ mux := http.NewServeMux()
314314+ h.RegisterRoutes(mux)
315315+ mux.ServeHTTP(rec, req)
316316+317317+ if rec.Code != http.StatusOK {
318318+ t.Fatalf("status = %d, want 200", rec.Code)
319319+ }
320320+ body := rec.Body.String()
321321+ if !strings.Contains(body, `action="/account/select-domain"`) {
322322+ t.Error("multi-domain account should render the domain picker")
323323+ }
324324+ for _, domain := range []string{"one.example.com", "two.example.com"} {
325325+ if !strings.Contains(body, domain) {
326326+ t.Errorf("domain picker missing %s", domain)
327327+ }
328328+ }
329329+ if strings.Contains(body, `action="/account/regenerate"`) {
330330+ t.Error("domain picker should not expose domain-scoped actions yet")
331331+ }
332332+}
333333+334334+func TestRecover_ManageRejectsWhenNoDomains(t *testing.T) {
335335+ h := NewRecoverHandler(&fakePublisher{}, newRecoverTestStore(t), "https://example.com", nil)
336336+ target := h.IssueRecoveryTicket("did:plc:none111111111111111", "")
337337+ ticket := strings.TrimPrefix(target, "/account/manage?ticket=")
338338+339339+ req := httptest.NewRequest(http.MethodGet, "/account/manage", nil)
340340+ req.AddCookie(&http.Cookie{Name: RecoveryCookieName, Value: ticket})
341341+ rec := httptest.NewRecorder()
342342+ mux := http.NewServeMux()
343343+ h.RegisterRoutes(mux)
344344+ mux.ServeHTTP(rec, req)
345345+346346+ if rec.Code != http.StatusBadRequest {
347347+ t.Fatalf("status = %d, want 400", rec.Code)
348348+ }
349349+ body := rec.Body.String()
350350+ if !strings.Contains(body, `name="identity"`) {
351351+ t.Error("expected landing form re-rendered on no-domain error")
352352+ }
353353+}
354354+355355+func TestRecover_SelectDomainSetsDomainAndRedirects(t *testing.T) {
356356+ store := newRecoverTestStore(t)
357357+ did := "did:plc:select1111111111111"
358358+ seedRecoverMember(t, store, did, "one.example.com")
359359+ addRecoverDomain(t, store, did, "two.example.com")
360360+361361+ h := NewRecoverHandler(&fakePublisher{}, store, "https://example.com", nil)
362362+ target := h.IssueRecoveryTicket(did, "")
363363+ ticket := strings.TrimPrefix(target, "/account/manage?ticket=")
364364+ mux := http.NewServeMux()
365365+ h.RegisterRoutes(mux)
366366+367367+ form := url.Values{"domain": {"two.example.com"}}
368368+ req := httptest.NewRequest(http.MethodPost, "/account/select-domain", strings.NewReader(form.Encode()))
369369+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
370370+ req.Header.Set("Origin", "https://example.com")
371371+ req.AddCookie(&http.Cookie{Name: RecoveryCookieName, Value: ticket})
372372+ rec := httptest.NewRecorder()
373373+ mux.ServeHTTP(rec, req)
374374+375375+ if rec.Code != http.StatusFound {
376376+ t.Fatalf("select status = %d, want 302; body=%q", rec.Code, rec.Body.String())
377377+ }
378378+379379+ req = httptest.NewRequest(http.MethodGet, "/account/manage", nil)
380380+ req.AddCookie(&http.Cookie{Name: RecoveryCookieName, Value: ticket})
381381+ rec = httptest.NewRecorder()
382382+ mux.ServeHTTP(rec, req)
383383+ if rec.Code != http.StatusOK {
384384+ t.Fatalf("manage status = %d, want 200", rec.Code)
385385+ }
386386+ if !strings.Contains(rec.Body.String(), "two.example.com") {
387387+ t.Error("manage page should render selected domain")
388388+ }
389389+}
390390+195391func TestRecover_ManageRejectsInvalidTicket(t *testing.T) {
196392 h := NewRecoverHandler(&fakePublisher{}, newRecoverTestStore(t), "https://example.com", nil)
197393 // Post-CRIT-#152: ticket lives in a cookie. A bogus cookie value
···304500305501// --- Audit #156: validate domain + contact_email before log/store ---
306502307307-// TestRecoverStart_RejectsInvalidDomain ensures the /account/start
308308-// handler rejects syntactically-invalid domains (including CRLF
309309-// injection attempts) with a 400 and never reaches the store.
310310-func TestRecoverStart_RejectsInvalidDomain(t *testing.T) {
503503+// TestRecoverSelectDomain_RejectsInvalidDomain ensures the post-OAuth
504504+// domain picker rejects syntactically-invalid domains (including CRLF
505505+// injection attempts) with a 400.
506506+func TestRecoverSelectDomain_RejectsInvalidDomain(t *testing.T) {
311507 cases := []string{
312508 "not a domain",
313509 "foo.com\r\nFAKE: injected=1",
···318514 for _, in := range cases {
319515 t.Run(in, func(t *testing.T) {
320516 store := newRecoverTestStore(t)
321321- pub := &fakePublisher{returnURL: "https://should.not/redirect"}
322322- h := NewRecoverHandler(pub, store, "https://example.com", nil)
517517+ did := "did:plc:recover1111111111111aa"
518518+ seedRecoverMember(t, store, did, "valid.example.com")
519519+ h := NewRecoverHandler(&fakePublisher{}, store, "https://example.com", nil)
520520+ target := h.IssueRecoveryTicket(did, "")
521521+ ticket := strings.TrimPrefix(target, "/account/manage?ticket=")
323522 mux := http.NewServeMux()
324523 h.RegisterRoutes(mux)
325524326525 form := url.Values{"domain": {in}}
327327- req := httptest.NewRequest(http.MethodPost, "/account/start", strings.NewReader(form.Encode()))
526526+ req := httptest.NewRequest(http.MethodPost, "/account/select-domain", strings.NewReader(form.Encode()))
328527 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
329528 req.Header.Set("Origin", "https://example.com")
529529+ req.AddCookie(&http.Cookie{Name: RecoveryCookieName, Value: ticket})
330530 rec := httptest.NewRecorder()
331531 mux.ServeHTTP(rec, req)
332532333533 if rec.Code != http.StatusBadRequest {
334534 t.Errorf("status = %d, want 400 for invalid domain %q", rec.Code, in)
335335- }
336336- if pub.lastIdentifier != "" {
337337- t.Errorf("publisher was called (identifier=%q) for invalid domain %q",
338338- pub.lastIdentifier, in)
339535 }
340536 })
341537 }
···401597 mux := http.NewServeMux()
402598 h.RegisterRoutes(mux)
403599404404- form := url.Values{"domain": {domain}}
600600+ form := url.Values{"did": {did}, "domain": {domain}}
405601 req := httptest.NewRequest(http.MethodPost, "/account/start", strings.NewReader(form.Encode()))
406602 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
407603 req.Header.Set("Origin", "https://example.com")
+454-103
internal/admin/ui/templates/enroll.templ
···286286 border-color: var(--ink);
287287 box-shadow: inset 0 -2px 0 0 var(--accent);
288288 }
289289+ .handle-input-wrapper {
290290+ position: relative;
291291+ }
292292+ .handle-input-wrapper input[type=text] {
293293+ padding-left: 1.75rem;
294294+ }
295295+ .handle-input-wrapper::before {
296296+ content: "@";
297297+ position: absolute;
298298+ left: 0.75rem;
299299+ top: 0.6rem;
300300+ font-family: 'JetBrains Mono', 'Menlo', monospace;
301301+ font-size: var(--t-m);
302302+ color: var(--muted);
303303+ pointer-events: none;
304304+ z-index: 1;
305305+ }
306306+ .handle-suggestions {
307307+ position: absolute;
308308+ left: 0;
309309+ right: 0;
310310+ bottom: 100%;
311311+ background: var(--ink);
312312+ color: var(--bg);
313313+ border-radius: 2px 2px 0 0;
314314+ z-index: 10;
315315+ max-height: 260px;
316316+ overflow-y: auto;
317317+ box-shadow: 0 -4px 16px rgba(0,0,0,0.15);
318318+ }
319319+ .handle-suggestion {
320320+ display: flex;
321321+ align-items: center;
322322+ gap: 0.6rem;
323323+ padding: 0.5rem 0.75rem;
324324+ cursor: pointer;
325325+ transition: background 0.1s;
326326+ }
327327+ .handle-suggestion:hover,
328328+ .handle-suggestion.active {
329329+ background: oklch(0.30 0.02 70);
330330+ }
331331+ .suggestion-avatar {
332332+ width: 32px;
333333+ height: 32px;
334334+ border-radius: 50%;
335335+ flex-shrink: 0;
336336+ object-fit: cover;
337337+ }
338338+ .suggestion-avatar-placeholder {
339339+ width: 32px;
340340+ height: 32px;
341341+ border-radius: 50%;
342342+ flex-shrink: 0;
343343+ background: oklch(0.40 0.01 70);
344344+ }
345345+ .suggestion-text {
346346+ display: flex;
347347+ flex-direction: column;
348348+ min-width: 0;
349349+ }
350350+ .suggestion-name {
351351+ font-weight: 700;
352352+ font-size: var(--t-s);
353353+ overflow: hidden;
354354+ text-overflow: ellipsis;
355355+ white-space: nowrap;
356356+ }
357357+ .suggestion-handle {
358358+ font-size: var(--t-xs);
359359+ color: oklch(0.65 0.01 70);
360360+ overflow: hidden;
361361+ text-overflow: ellipsis;
362362+ white-space: nowrap;
363363+ }
289364 textarea {
290365 resize: vertical;
291366 line-height: 1.4;
···474549// to take (DID, domain, contact email) and start the DNS-verification
475550// handshake. Uses masthead-sub (no drop-cap) because the drop-cap is
476551// reserved for the root page's brand mark.
477477-templ EnrollLanding() {
552552+templ EnrollLanding(authDID, authHandle string, requireAuth bool, existingDomains []string) {
478553 @publicLayout("Enroll", false) {
479554 <h1 class="masthead masthead-sub">Enroll</h1>
480480- <p class="lede" style="margin-bottom: 1.25rem;">
481481- Three fields, one DNS record, and you're in. We'll issue credentials to start sending mail through the relay.
482482- </p>
555555+556556+ if authDID == "" {
557557+ if requireAuth {
558558+ <p class="lede" style="margin-bottom: 1.25rem;">
559559+ First, verify your handle. We'll redirect you to your PDS to confirm you own the account.
560560+ </p>
561561+ } else {
562562+ <p class="lede" style="margin-bottom: 1.25rem;">
563563+ Three fields, one DNS record, and you're in. We'll issue credentials to start sending mail through the relay.
564564+ </p>
565565+ }
483566484484- <section class="section" style="margin-top: 1.25rem; padding-top: 0.75rem;">
485485- <form id="enroll-form" action="/enroll/start" method="POST">
486486- <label for="identity">Your Bluesky handle</label>
487487- <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>
488488- <input
489489- type="text"
490490- id="identity"
491491- name="identity"
492492- placeholder="alice.bsky.social"
493493- required
494494- autocomplete="off"
495495- spellcheck="false"
496496- autocapitalize="off"
497497- />
498498- <div id="resolver-hint" class="resolver-hint" aria-live="polite"></div>
499499- <!-- Hidden field submitted to /enroll/start. Populated by JS
500500- from the identity input (or directly if the user pasted a DID). -->
501501- <input type="hidden" id="did" name="did" value=""/>
567567+ <section class="section" style="margin-top: 1.25rem; padding-top: 0.75rem;">
568568+ <form id="enroll-form" action={ enrollLandingAction(requireAuth) } method="POST">
569569+ <label for="identity">Your handle</label>
570570+ <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>
571571+ <input
572572+ type="text"
573573+ id="identity"
574574+ name="identity"
575575+ placeholder="alice.bsky.social"
576576+ required
577577+ autocomplete="off"
578578+ spellcheck="false"
579579+ autocapitalize="off"
580580+ />
581581+ <div id="resolver-hint" class="resolver-hint" aria-live="polite"></div>
582582+ <input type="hidden" id="did" name="did" value=""/>
502583503503- <label for="domain">Sending domain</label>
504504- <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>
505505- <input
506506- type="text"
507507- id="domain"
508508- name="domain"
509509- placeholder="example.com"
510510- required
511511- pattern="[a-z0-9][a-z0-9\-.]*\.[a-z]{2,}"
512512- autocomplete="off"
513513- spellcheck="false"
514514- autocapitalize="off"
515515- />
584584+ if !requireAuth {
585585+ <label for="domain">Sending domain</label>
586586+ <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>
587587+ <input
588588+ type="text"
589589+ id="domain"
590590+ name="domain"
591591+ placeholder="example.com"
592592+ required
593593+ pattern="[a-z0-9][a-z0-9\-.]*\.[a-z]{2,}"
594594+ autocomplete="off"
595595+ spellcheck="false"
596596+ autocapitalize="off"
597597+ />
516598517517- <label for="contact_email">Contact email</label>
518518- <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>
519519- <input
520520- type="email"
521521- id="contact_email"
522522- name="contact_email"
523523- placeholder="you@example.com"
524524- required
525525- autocomplete="email"
526526- spellcheck="false"
527527- autocapitalize="off"
528528- />
599599+ <label for="contact_email">Contact email</label>
600600+ <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>
601601+ <input
602602+ type="email"
603603+ id="contact_email"
604604+ name="contact_email"
605605+ placeholder="you@example.com"
606606+ required
607607+ autocomplete="email"
608608+ spellcheck="false"
609609+ autocapitalize="off"
610610+ />
529611530530- <button type="submit" id="enroll-submit">Start enrollment</button>
531531- </form>
532532- <p class="section-lede" style="margin-top: 1rem; margin-bottom: 0;">
533533- Already enrolled? Sign in at <a href="/account">Account</a> to see DKIM records, rotate your API key, or update your contact email.
534534- </p>
535535- <p class="section-lede" style="margin-top: 0.5rem; margin-bottom: 0; font-size: var(--t-xs);">
536536- New here? The <a href="/">landing page</a> covers how this works.
537537- </p>
538538- <script>
539539- // Handle↔DID resolver. Typing a DID (starts with "did:") bypasses
540540- // the network round-trip. Typing anything else is treated as a
612612+ <label style="display: flex; align-items: flex-start; gap: 0.5rem; margin-top: 1.25rem; font-weight: 400; cursor: pointer;">
613613+ <input
614614+ type="checkbox"
615615+ name="terms_accepted"
616616+ id="terms_accepted"
617617+ required
618618+ style="margin-top: 0.25rem; accent-color: var(--accent);"
619619+ />
620620+ <span style="font-size: var(--t-s);">
621621+ I agree to the <a href="/terms" target="_blank">Terms of Service</a> and
622622+ <a href="/aup" target="_blank">Acceptable Use Policy</a>,
623623+ which may be updated with reasonable notice.
624624+ </span>
625625+ </label>
541626 // handle and resolved against /enroll/resolve on blur or submit.
542627 (function() {
543628 const form = document.getElementById('enroll-form');
···550635 return /^did:(plc|web):[A-Za-z0-9._%\-]+$/.test(s.trim());
551636 }
552637553553- function setHint(text, cls) {
554554- hint.textContent = text;
555555- hint.className = 'resolver-hint ' + (cls || '');
556556- }
638638+ <button type="submit" id="enroll-submit" data-default-text={ enrollLandingSubmitText(requireAuth) }>{ enrollLandingSubmitText(requireAuth) }</button>
639639+ </form>
640640+ <p class="section-lede" style="margin-top: 1rem; margin-bottom: 0;">
641641+ Already enrolled? Sign in at <a href="/account">Account</a> to see DKIM records, rotate your API key, or update your contact email.
642642+ </p>
643643+ <p class="section-lede" style="margin-top: 0.5rem; margin-bottom: 0; font-size: var(--t-xs);">
644644+ New here? The <a href="/">landing page</a> covers how this works.
645645+ </p>
646646+ <script>
647647+ (function() {
648648+ var SEARCH_API = 'https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead';
649649+ var DEBOUNCE_MS = 250;
650650+ var MIN_QUERY = 2;
651651+ var MAX_RESULTS = 6;
652652+653653+ var form = document.getElementById('enroll-form');
654654+ var identity = document.getElementById('identity');
655655+ var didField = document.getElementById('did');
656656+ var hint = document.getElementById('resolver-hint');
657657+ var submit = document.getElementById('enroll-submit');
658658+659659+ var debounceTimer = null;
660660+ var abortCtrl = null;
661661+ var activeIndex = -1;
662662+ var currentResults = [];
663663+664664+ function esc(s) {
665665+ return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
666666+ }
667667+ function isDID(s) {
668668+ return /^did:(plc|web):[A-Za-z0-9._%\-]+$/.test(s.trim());
669669+ }
670670+ function setHint(text, cls) {
671671+ hint.textContent = text;
672672+ hint.className = 'resolver-hint ' + (cls || '');
673673+ }
674674+675675+ var wrapper = document.createElement('div');
676676+ wrapper.className = 'handle-input-wrapper';
677677+ identity.parentElement.insertBefore(wrapper, identity);
678678+ wrapper.appendChild(identity);
679679+ wrapper.parentElement.insertBefore(hint, wrapper.nextSibling);
557680558558- async function resolve(raw) {
559559- const v = (raw || '').replace(/^@/, '').trim();
560560- if (!v) { setHint('', ''); didField.value = ''; return; }
561561- if (isDID(v)) { setHint(v, 'is-ok'); didField.value = v; return; }
681681+ var dropdown = document.createElement('div');
682682+ dropdown.className = 'handle-suggestions';
683683+ dropdown.setAttribute('role', 'listbox');
684684+ dropdown.style.display = 'none';
685685+ wrapper.appendChild(dropdown);
686686+ identity.setAttribute('role', 'combobox');
687687+ identity.setAttribute('aria-autocomplete', 'list');
688688+ identity.setAttribute('aria-expanded', 'false');
562689563563- setHint('Resolving ' + v + '…', 'is-loading');
564564- didField.value = '';
565565- try {
566566- const r = await fetch('/enroll/resolve?handle=' + encodeURIComponent(v), {
567567- headers: { Accept: 'application/json' },
568568- });
569569- if (!r.ok) {
570570- const body = await r.json().catch(() => ({error: 'resolution failed'}));
571571- setHint(body.error || 'resolution failed', 'is-err');
690690+ function renderSuggestions(results) {
691691+ if (!results.length) {
692692+ dropdown.style.display = 'none';
693693+ identity.setAttribute('aria-expanded', 'false');
572694 return;
573695 }
574574- const data = await r.json();
575575- if (data.did) {
576576- setHint(data.did, 'is-ok');
577577- didField.value = data.did;
696696+ dropdown.innerHTML = results.map(function(r, i) {
697697+ return '<div class="handle-suggestion" role="option" data-index="' + i + '" data-handle="' + esc(r.handle) + '">'
698698+ + (r.avatar
699699+ ? '<img src="' + esc(r.avatar) + '" alt="" class="suggestion-avatar"/>'
700700+ : '<div class="suggestion-avatar-placeholder"></div>')
701701+ + '<div class="suggestion-text">'
702702+ + '<span class="suggestion-name">' + esc(r.displayName) + '</span>'
703703+ + '<span class="suggestion-handle">@' + esc(r.handle) + '</span>'
704704+ + '</div></div>';
705705+ }).join('');
706706+ dropdown.style.display = '';
707707+ identity.setAttribute('aria-expanded', 'true');
708708+ }
709709+710710+ function updateActive() {
711711+ var items = dropdown.querySelectorAll('.handle-suggestion');
712712+ for (var i = 0; i < items.length; i++) {
713713+ if (i === activeIndex) items[i].classList.add('active');
714714+ else items[i].classList.remove('active');
578715 }
579579- } catch (e) {
580580- setHint('Network error — try again or paste a DID directly', 'is-err');
716716+ }
717717+718718+ function selectHandle(handle) {
719719+ identity.value = handle;
720720+ dropdown.style.display = 'none';
721721+ identity.setAttribute('aria-expanded', 'false');
722722+ currentResults = [];
723723+ activeIndex = -1;
724724+ resolve(handle);
581725 }
582582- }
583726584584- identity.addEventListener('blur', () => resolve(identity.value));
585585- identity.addEventListener('input', () => {
586586- // Clear the resolved DID while the user is editing; they'll
587587- // re-resolve on blur. Prevents stale DID from being submitted
588588- // if the user changes their mind mid-type.
589589- if (didField.value && identity.value.trim() !== didField.value) {
727727+ function searchHandles(query) {
728728+ if (abortCtrl) abortCtrl.abort();
729729+ if (query.length < MIN_QUERY) return Promise.resolve([]);
730730+ abortCtrl = new AbortController();
731731+ return fetch(SEARCH_API + '?q=' + encodeURIComponent(query) + '&limit=' + MAX_RESULTS, { signal: abortCtrl.signal })
732732+ .then(function(r) { return r.ok ? r.json() : { actors: [] }; })
733733+ .then(function(data) {
734734+ return (data.actors || []).map(function(a) {
735735+ return { handle: a.handle, displayName: a.displayName || a.handle, avatar: a.avatar || null };
736736+ });
737737+ })
738738+ .catch(function() { return []; });
739739+ }
740740+741741+ function debouncedSearch(query) {
742742+ if (debounceTimer) clearTimeout(debounceTimer);
743743+ if (query.length < MIN_QUERY) { renderSuggestions([]); return; }
744744+ debounceTimer = setTimeout(function() {
745745+ searchHandles(query).then(function(results) {
746746+ currentResults = results;
747747+ activeIndex = -1;
748748+ renderSuggestions(results);
749749+ });
750750+ }, DEBOUNCE_MS);
751751+ }
752752+753753+ async function resolve(raw) {
754754+ var v = (raw || '').replace(/^@/, '').trim();
755755+ if (!v) { setHint('', ''); didField.value = ''; return; }
756756+ if (isDID(v)) { setHint(v, 'is-ok'); didField.value = v; return; }
757757+ setHint('Resolving ' + v + '…', 'is-loading');
590758 didField.value = '';
591591- setHint('', '');
759759+ try {
760760+ var r = await fetch('/enroll/resolve?handle=' + encodeURIComponent(v), {
761761+ headers: { Accept: 'application/json' },
762762+ });
763763+ if (!r.ok) {
764764+ var body = await r.json().catch(function() { return {error:'resolution failed'}; });
765765+ setHint(body.error || 'resolution failed', 'is-err');
766766+ return;
767767+ }
768768+ var data = await r.json();
769769+ if (data.did) {
770770+ setHint(data.did, 'is-ok');
771771+ didField.value = data.did;
772772+ }
773773+ } catch (e) {
774774+ setHint('Network error — try again or paste a DID directly', 'is-err');
775775+ }
592776 }
593593- });
777777+778778+ identity.addEventListener('input', function() {
779779+ var q = identity.value.trim().replace(/^@/, '');
780780+ if (didField.value) { didField.value = ''; setHint('', ''); }
781781+ if (isDID(q)) {
782782+ renderSuggestions([]);
783783+ setHint(q, 'is-ok');
784784+ didField.value = q;
785785+ return;
786786+ }
787787+ debouncedSearch(q);
788788+ });
789789+790790+ identity.addEventListener('keydown', function(e) {
791791+ if (!currentResults.length) return;
792792+ if (e.key === 'ArrowDown') {
793793+ e.preventDefault();
794794+ activeIndex = Math.min(activeIndex + 1, currentResults.length - 1);
795795+ updateActive();
796796+ } else if (e.key === 'ArrowUp') {
797797+ e.preventDefault();
798798+ activeIndex = Math.max(activeIndex - 1, 0);
799799+ updateActive();
800800+ } else if (e.key === 'Enter' && activeIndex >= 0) {
801801+ e.preventDefault();
802802+ e.stopPropagation();
803803+ selectHandle(currentResults[activeIndex].handle);
804804+ } else if (e.key === 'Escape') {
805805+ dropdown.style.display = 'none';
806806+ identity.setAttribute('aria-expanded', 'false');
807807+ activeIndex = -1;
808808+ }
809809+ });
594810595595- form.addEventListener('submit', async (ev) => {
596596- if (didField.value) return; // already resolved
597597- ev.preventDefault();
598598- submit.disabled = true;
599599- submit.textContent = 'Resolving identity…';
600600- await resolve(identity.value);
601601- submit.disabled = false;
602602- submit.textContent = 'Start enrollment';
603603- if (didField.value) form.submit();
604604- });
811811+ dropdown.addEventListener('mousedown', function(e) {
812812+ e.preventDefault();
813813+ var target = e.target.closest('.handle-suggestion');
814814+ if (target) selectHandle(target.dataset.handle);
815815+ });
605816606606- })();
607607- </script>
608608- </section>
817817+ identity.addEventListener('blur', function() {
818818+ setTimeout(function() {
819819+ dropdown.style.display = 'none';
820820+ identity.setAttribute('aria-expanded', 'false');
821821+ }, 150);
822822+ if (!didField.value) resolve(identity.value);
823823+ });
824824+825825+ form.addEventListener('submit', async function(ev) {
826826+ if (didField.value) return;
827827+ ev.preventDefault();
828828+ submit.disabled = true;
829829+ submit.textContent = 'Resolving identity…';
830830+ await resolve(identity.value);
831831+ submit.disabled = false;
832832+ submit.textContent = submit.getAttribute('data-default-text') || 'Start enrollment';
833833+ if (didField.value) form.submit();
834834+ });
835835+ })();
836836+ </script>
837837+ </section>
838838+ } else {
839839+ <section class="section" style="margin-top: 1.25rem; padding-top: 0.75rem;">
840840+ <div class="credential" style="margin-top: 0; margin-bottom: 1.5rem;">
841841+ <div class="credential-label">Verified identity</div>
842842+ <p style="margin: 0.5rem 0 0; font-family: 'JetBrains Mono', monospace; font-size: var(--t-s); word-break: break-all;">
843843+ if authHandle != "" && authHandle != authDID {
844844+ { "@" + authHandle }<br/>
845845+ }
846846+ <span style="color: var(--muted); font-size: var(--t-xs);">{ authDID }</span>
847847+ </p>
848848+ </div>
849849+850850+ if len(existingDomains) > 0 {
851851+ <div class="credential" style="margin-top: 0; margin-bottom: 1.5rem;">
852852+ <div class="credential-label">Your enrolled domains</div>
853853+ for _, d := range existingDomains {
854854+ <p style="margin: 0.5rem 0 0; font-family: 'JetBrains Mono', monospace; font-size: var(--t-s);">{ d }</p>
855855+ }
856856+ </div>
857857+ }
858858+859859+ if len(existingDomains) >= 2 {
860860+ <p class="lede" style="margin-bottom: 1.25rem;">
861861+ You've reached the maximum of 2 sending domains for this alpha.
862862+ <a href="/enroll/manage">Manage your account</a> to view DKIM records, rotate your API key, or update your contact email.
863863+ </p>
864864+ } else if len(existingDomains) == 1 {
865865+ <p class="lede" style="margin-bottom: 1.25rem;">
866866+ You can add one more sending domain during this alpha.
867867+ </p>
868868+869869+ <form action="/enroll/start" method="POST">
870870+ <label for="domain">Sending domain</label>
871871+ <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>
872872+ <input
873873+ type="text"
874874+ id="domain"
875875+ name="domain"
876876+ placeholder="example.com"
877877+ required
878878+ pattern="[a-z0-9][a-z0-9\-.]*\.[a-z]{2,}"
879879+ autocomplete="off"
880880+ spellcheck="false"
881881+ autocapitalize="off"
882882+ />
883883+884884+ <label for="contact_email">Contact email</label>
885885+ <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>
886886+ <input
887887+ type="email"
888888+ id="contact_email"
889889+ name="contact_email"
890890+ placeholder="you@example.com"
891891+ required
892892+ autocomplete="email"
893893+ spellcheck="false"
894894+ autocapitalize="off"
895895+ />
896896+897897+ <button type="submit">Add domain →</button>
898898+ </form>
899899+ } else {
900900+ <p class="lede" style="margin-bottom: 1.25rem;">
901901+ Identity verified. Now tell us about the domain you want to send from.
902902+ </p>
903903+904904+ <form action="/enroll/start" method="POST">
905905+ <label for="domain">Sending domain</label>
906906+ <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>
907907+ <input
908908+ type="text"
909909+ id="domain"
910910+ name="domain"
911911+ placeholder="example.com"
912912+ required
913913+ pattern="[a-z0-9][a-z0-9\-.]*\.[a-z]{2,}"
914914+ autocomplete="off"
915915+ spellcheck="false"
916916+ autocapitalize="off"
917917+ />
918918+919919+ <label for="contact_email">Contact email</label>
920920+ <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>
921921+ <input
922922+ type="email"
923923+ id="contact_email"
924924+ name="contact_email"
925925+ placeholder="you@example.com"
926926+ required
927927+ autocomplete="email"
928928+ spellcheck="false"
929929+ autocapitalize="off"
930930+ />
931931+932932+ <button type="submit">Start enrollment →</button>
933933+ </form>
934934+ }
935935+936936+ <p class="section-lede" style="margin-top: 1rem; margin-bottom: 0;">
937937+ <a href="/enroll/reset">← Use a different account</a>
938938+ </p>
939939+ if len(existingDomains) > 0 {
940940+ <p class="section-lede" style="margin-top: 0.5rem; margin-bottom: 0;">
941941+ <a href="/enroll/manage">Manage your account →</a>
942942+ </p>
943943+ }
944944+ </section>
945945+ }
609946 }
947947+}
948948+949949+func enrollLandingAction(requireAuth bool) templ.SafeURL {
950950+ if requireAuth {
951951+ return templ.SafeURL("/enroll/auth")
952952+ }
953953+ return templ.SafeURL("/enroll/start")
954954+}
955955+956956+func enrollLandingSubmitText(requireAuth bool) string {
957957+ if requireAuth {
958958+ return "Verify identity →"
959959+ }
960960+ return "Start enrollment"
610961}
611962612963// EnrollStep2 shows the DNS TXT record the user needs to publish + a
+397-221
internal/admin/ui/templates/enroll_templ.go
···8282 if templ_7745c5c3_Err != nil {
8383 return templ_7745c5c3_Err
8484 }
8585- 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\">")
8585+ 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\">")
8686 if templ_7745c5c3_Err != nil {
8787 return templ_7745c5c3_Err
8888 }
···110110// to take (DID, domain, contact email) and start the DNS-verification
111111// handshake. Uses masthead-sub (no drop-cap) because the drop-cap is
112112// reserved for the root page's brand mark.
113113-func EnrollLanding() templ.Component {
113113+func EnrollLanding(authDID, authHandle string, requireAuth bool, existingDomains []string) templ.Component {
114114 return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
115115 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
116116 if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
···143143 }()
144144 }
145145 ctx = templ.InitializeContext(ctx)
146146- 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>")
146146+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<h1 class=\"masthead masthead-sub\">Enroll</h1>")
147147 if templ_7745c5c3_Err != nil {
148148 return templ_7745c5c3_Err
149149 }
150150+ if authDID == "" {
151151+ if requireAuth {
152152+ 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>")
153153+ if templ_7745c5c3_Err != nil {
154154+ return templ_7745c5c3_Err
155155+ }
156156+ } else {
157157+ 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>")
158158+ if templ_7745c5c3_Err != nil {
159159+ return templ_7745c5c3_Err
160160+ }
161161+ }
162162+ 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=\"")
163163+ if templ_7745c5c3_Err != nil {
164164+ return templ_7745c5c3_Err
165165+ }
166166+ var templ_7745c5c3_Var6 templ.SafeURL
167167+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(enrollLandingAction(requireAuth))
168168+ if templ_7745c5c3_Err != nil {
169169+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 568, Col: 68}
170170+ }
171171+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
172172+ if templ_7745c5c3_Err != nil {
173173+ return templ_7745c5c3_Err
174174+ }
175175+ 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=\"\"> ")
176176+ if templ_7745c5c3_Err != nil {
177177+ return templ_7745c5c3_Err
178178+ }
179179+ if !requireAuth {
180180+ 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> ")
181181+ if templ_7745c5c3_Err != nil {
182182+ return templ_7745c5c3_Err
183183+ }
184184+ }
185185+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<button type=\"submit\" id=\"enroll-submit\" data-default-text=\"")
186186+ if templ_7745c5c3_Err != nil {
187187+ return templ_7745c5c3_Err
188188+ }
189189+ var templ_7745c5c3_Var7 string
190190+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(enrollLandingSubmitText(requireAuth))
191191+ if templ_7745c5c3_Err != nil {
192192+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 613, Col: 102}
193193+ }
194194+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
195195+ if templ_7745c5c3_Err != nil {
196196+ return templ_7745c5c3_Err
197197+ }
198198+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\">")
199199+ if templ_7745c5c3_Err != nil {
200200+ return templ_7745c5c3_Err
201201+ }
202202+ var templ_7745c5c3_Var8 string
203203+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(enrollLandingSubmitText(requireAuth))
204204+ if templ_7745c5c3_Err != nil {
205205+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 613, Col: 143}
206206+ }
207207+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
208208+ if templ_7745c5c3_Err != nil {
209209+ return templ_7745c5c3_Err
210210+ }
211211+ 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/\"/g,'"');\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>")
212212+ if templ_7745c5c3_Err != nil {
213213+ return templ_7745c5c3_Err
214214+ }
215215+ } else {
216216+ 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;\">")
217217+ if templ_7745c5c3_Err != nil {
218218+ return templ_7745c5c3_Err
219219+ }
220220+ if authHandle != "" && authHandle != authDID {
221221+ var templ_7745c5c3_Var9 string
222222+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs("@" + authHandle)
223223+ if templ_7745c5c3_Err != nil {
224224+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 819, Col: 25}
225225+ }
226226+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
227227+ if templ_7745c5c3_Err != nil {
228228+ return templ_7745c5c3_Err
229229+ }
230230+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<br>")
231231+ if templ_7745c5c3_Err != nil {
232232+ return templ_7745c5c3_Err
233233+ }
234234+ }
235235+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<span style=\"color: var(--muted); font-size: var(--t-xs);\">")
236236+ if templ_7745c5c3_Err != nil {
237237+ return templ_7745c5c3_Err
238238+ }
239239+ var templ_7745c5c3_Var10 string
240240+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(authDID)
241241+ if templ_7745c5c3_Err != nil {
242242+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 821, Col: 74}
243243+ }
244244+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
245245+ if templ_7745c5c3_Err != nil {
246246+ return templ_7745c5c3_Err
247247+ }
248248+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</span></p></div>")
249249+ if templ_7745c5c3_Err != nil {
250250+ return templ_7745c5c3_Err
251251+ }
252252+ if len(existingDomains) > 0 {
253253+ 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>")
254254+ if templ_7745c5c3_Err != nil {
255255+ return templ_7745c5c3_Err
256256+ }
257257+ for _, d := range existingDomains {
258258+ 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);\">")
259259+ if templ_7745c5c3_Err != nil {
260260+ return templ_7745c5c3_Err
261261+ }
262262+ var templ_7745c5c3_Var11 string
263263+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(d)
264264+ if templ_7745c5c3_Err != nil {
265265+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 829, Col: 106}
266266+ }
267267+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
268268+ if templ_7745c5c3_Err != nil {
269269+ return templ_7745c5c3_Err
270270+ }
271271+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</p>")
272272+ if templ_7745c5c3_Err != nil {
273273+ return templ_7745c5c3_Err
274274+ }
275275+ }
276276+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div>")
277277+ if templ_7745c5c3_Err != nil {
278278+ return templ_7745c5c3_Err
279279+ }
280280+ }
281281+ if len(existingDomains) >= 2 {
282282+ 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>")
283283+ if templ_7745c5c3_Err != nil {
284284+ return templ_7745c5c3_Err
285285+ }
286286+ } else if len(existingDomains) == 1 {
287287+ 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>")
288288+ if templ_7745c5c3_Err != nil {
289289+ return templ_7745c5c3_Err
290290+ }
291291+ } else {
292292+ 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>")
293293+ if templ_7745c5c3_Err != nil {
294294+ return templ_7745c5c3_Err
295295+ }
296296+ }
297297+ 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>")
298298+ if templ_7745c5c3_Err != nil {
299299+ return templ_7745c5c3_Err
300300+ }
301301+ if len(existingDomains) > 0 {
302302+ 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>")
303303+ if templ_7745c5c3_Err != nil {
304304+ return templ_7745c5c3_Err
305305+ }
306306+ }
307307+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</section>")
308308+ if templ_7745c5c3_Err != nil {
309309+ return templ_7745c5c3_Err
310310+ }
311311+ }
150312 return nil
151313 })
152314 templ_7745c5c3_Err = publicLayout("Enroll", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var5), templ_7745c5c3_Buffer)
···157319 })
158320}
159321322322+func enrollLandingAction(requireAuth bool) templ.SafeURL {
323323+ if requireAuth {
324324+ return templ.SafeURL("/enroll/auth")
325325+ }
326326+ return templ.SafeURL("/enroll/start")
327327+}
328328+329329+func enrollLandingSubmitText(requireAuth bool) string {
330330+ if requireAuth {
331331+ return "Verify identity →"
332332+ }
333333+ return "Start enrollment"
334334+}
335335+160336// EnrollStep2 shows the DNS TXT record the user needs to publish + a
161337// "verify DNS" button that re-resolves and finalizes enrollment.
162338//
···185361 }()
186362 }
187363 ctx = templ.InitializeContext(ctx)
188188- templ_7745c5c3_Var6 := templ.GetChildren(ctx)
189189- if templ_7745c5c3_Var6 == nil {
190190- templ_7745c5c3_Var6 = templ.NopComponent
364364+ templ_7745c5c3_Var12 := templ.GetChildren(ctx)
365365+ if templ_7745c5c3_Var12 == nil {
366366+ templ_7745c5c3_Var12 = templ.NopComponent
191367 }
192368 ctx = templ.ClearChildren(ctx)
193193- templ_7745c5c3_Var7 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
369369+ templ_7745c5c3_Var13 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
194370 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
195371 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
196372 if !templ_7745c5c3_IsBuffer {
···202378 }()
203379 }
204380 ctx = templ.InitializeContext(ctx)
205205- 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>")
381381+ 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>")
206382 if templ_7745c5c3_Err != nil {
207383 return templ_7745c5c3_Err
208384 }
209209- var templ_7745c5c3_Var8 string
210210- templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(domain)
385385+ var templ_7745c5c3_Var14 string
386386+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(domain)
211387 if templ_7745c5c3_Err != nil {
212212- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 645, Col: 35}
388388+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 954, Col: 35}
213389 }
214214- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
390390+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
215391 if templ_7745c5c3_Err != nil {
216392 return templ_7745c5c3_Err
217393 }
218218- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</code> by publishing a single DNS TXT record. We re-resolve it and enroll <code>")
394394+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</code> by publishing a single DNS TXT record. We re-resolve it and enroll <code>")
219395 if templ_7745c5c3_Err != nil {
220396 return templ_7745c5c3_Err
221397 }
222222- var templ_7745c5c3_Var9 string
223223- templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(did)
398398+ var templ_7745c5c3_Var15 string
399399+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(did)
224400 if templ_7745c5c3_Err != nil {
225225- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 646, Col: 58}
401401+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 955, Col: 58}
226402 }
227227- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
403403+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
228404 if templ_7745c5c3_Err != nil {
229405 return templ_7745c5c3_Err
230406 }
231231- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</code> once it matches.</p>")
407407+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</code> once it matches.</p>")
232408 if templ_7745c5c3_Err != nil {
233409 return templ_7745c5c3_Err
234410 }
235411 if errorMessage != "" {
236236- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div class=\"error-note\" role=\"alert\"><strong>Verification failed:</strong> ")
412412+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<div class=\"error-note\" role=\"alert\"><strong>Verification failed:</strong> ")
237413 if templ_7745c5c3_Err != nil {
238414 return templ_7745c5c3_Err
239415 }
240240- var templ_7745c5c3_Var10 string
241241- templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
416416+ var templ_7745c5c3_Var16 string
417417+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
242418 if templ_7745c5c3_Err != nil {
243243- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 652, Col: 56}
419419+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 961, Col: 56}
244420 }
245245- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
421421+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
246422 if templ_7745c5c3_Err != nil {
247423 return templ_7745c5c3_Err
248424 }
249249- 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>")
425425+ 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>")
250426 if templ_7745c5c3_Err != nil {
251427 return templ_7745c5c3_Err
252428 }
253429 }
254254- 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>")
430430+ 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>")
255431 if templ_7745c5c3_Err != nil {
256432 return templ_7745c5c3_Err
257433 }
258258- var templ_7745c5c3_Var11 string
259259- templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(domain)
434434+ var templ_7745c5c3_Var17 string
435435+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(domain)
260436 if templ_7745c5c3_Err != nil {
261261- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 662, Col: 53}
437437+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 971, Col: 53}
262438 }
263263- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
439439+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
264440 if templ_7745c5c3_Err != nil {
265441 return templ_7745c5c3_Err
266442 }
267267- 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>")
443443+ 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>")
268444 if templ_7745c5c3_Err != nil {
269445 return templ_7745c5c3_Err
270446 }
271271- var templ_7745c5c3_Var12 string
272272- templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(dnsName)
447447+ var templ_7745c5c3_Var18 string
448448+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(dnsName)
273449 if templ_7745c5c3_Err != nil {
274274- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 670, Col: 18}
450450+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 979, Col: 18}
275451 }
276276- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
452452+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
277453 if templ_7745c5c3_Err != nil {
278454 return templ_7745c5c3_Err
279455 }
280280- 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>")
456456+ 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>")
281457 if templ_7745c5c3_Err != nil {
282458 return templ_7745c5c3_Err
283459 }
284284- var templ_7745c5c3_Var13 string
285285- templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(dnsValue)
460460+ var templ_7745c5c3_Var19 string
461461+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(dnsValue)
286462 if templ_7745c5c3_Err != nil {
287287- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 678, Col: 19}
463463+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 987, Col: 19}
288464 }
289289- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
465465+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
290466 if templ_7745c5c3_Err != nil {
291467 return templ_7745c5c3_Err
292468 }
293293- 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=\"")
469469+ 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=\"")
294470 if templ_7745c5c3_Err != nil {
295471 return templ_7745c5c3_Err
296472 }
297297- var templ_7745c5c3_Var14 string
298298- templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(did)
473473+ var templ_7745c5c3_Var20 string
474474+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(did)
299475 if templ_7745c5c3_Err != nil {
300300- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 687, Col: 47}
476476+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 996, Col: 47}
301477 }
302302- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
478478+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
303479 if templ_7745c5c3_Err != nil {
304480 return templ_7745c5c3_Err
305481 }
306306- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\"> <input type=\"hidden\" name=\"domain\" value=\"")
482482+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\"> <input type=\"hidden\" name=\"domain\" value=\"")
307483 if templ_7745c5c3_Err != nil {
308484 return templ_7745c5c3_Err
309485 }
310310- var templ_7745c5c3_Var15 string
311311- templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(domain)
486486+ var templ_7745c5c3_Var21 string
487487+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(domain)
312488 if templ_7745c5c3_Err != nil {
313313- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 688, Col: 53}
489489+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 997, Col: 53}
314490 }
315315- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
491491+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
316492 if templ_7745c5c3_Err != nil {
317493 return templ_7745c5c3_Err
318494 }
319319- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\"> <input type=\"hidden\" name=\"token\" value=\"")
495495+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\"> <input type=\"hidden\" name=\"token\" value=\"")
320496 if templ_7745c5c3_Err != nil {
321497 return templ_7745c5c3_Err
322498 }
323323- var templ_7745c5c3_Var16 string
324324- templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(token)
499499+ var templ_7745c5c3_Var22 string
500500+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(token)
325501 if templ_7745c5c3_Err != nil {
326326- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 689, Col: 51}
502502+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 998, Col: 51}
327503 }
328328- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
504504+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
329505 if templ_7745c5c3_Err != nil {
330506 return templ_7745c5c3_Err
331507 }
332332- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\"> <input type=\"hidden\" name=\"dnsName\" value=\"")
508508+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\"> <input type=\"hidden\" name=\"dnsName\" value=\"")
333509 if templ_7745c5c3_Err != nil {
334510 return templ_7745c5c3_Err
335511 }
336336- var templ_7745c5c3_Var17 string
337337- templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(dnsName)
512512+ var templ_7745c5c3_Var23 string
513513+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(dnsName)
338514 if templ_7745c5c3_Err != nil {
339339- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 690, Col: 55}
515515+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 999, Col: 55}
340516 }
341341- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
517517+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
342518 if templ_7745c5c3_Err != nil {
343519 return templ_7745c5c3_Err
344520 }
345345- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\"> <input type=\"hidden\" name=\"dnsValue\" value=\"")
521521+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\"> <input type=\"hidden\" name=\"dnsValue\" value=\"")
346522 if templ_7745c5c3_Err != nil {
347523 return templ_7745c5c3_Err
348524 }
349349- var templ_7745c5c3_Var18 string
350350- templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(dnsValue)
525525+ var templ_7745c5c3_Var24 string
526526+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(dnsValue)
351527 if templ_7745c5c3_Err != nil {
352352- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 691, Col: 57}
528528+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1000, Col: 57}
353529 }
354354- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
530530+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
355531 if templ_7745c5c3_Err != nil {
356532 return templ_7745c5c3_Err
357533 }
358358- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\"> <button type=\"submit\">Verify DNS & 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>")
534534+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\"> <button type=\"submit\">Verify DNS & 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>")
359535 if templ_7745c5c3_Err != nil {
360536 return templ_7745c5c3_Err
361537 }
362538 return nil
363539 })
364364- templ_7745c5c3_Err = publicLayout("Publish TXT record", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var7), templ_7745c5c3_Buffer)
540540+ templ_7745c5c3_Err = publicLayout("Publish TXT record", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var13), templ_7745c5c3_Buffer)
365541 if templ_7745c5c3_Err != nil {
366542 return templ_7745c5c3_Err
367543 }
···386562 }()
387563 }
388564 ctx = templ.InitializeContext(ctx)
389389- templ_7745c5c3_Var19 := templ.GetChildren(ctx)
390390- if templ_7745c5c3_Var19 == nil {
391391- templ_7745c5c3_Var19 = templ.NopComponent
565565+ templ_7745c5c3_Var25 := templ.GetChildren(ctx)
566566+ if templ_7745c5c3_Var25 == nil {
567567+ templ_7745c5c3_Var25 = templ.NopComponent
392568 }
393569 ctx = templ.ClearChildren(ctx)
394394- templ_7745c5c3_Var20 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
570570+ templ_7745c5c3_Var26 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
395571 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
396572 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
397573 if !templ_7745c5c3_IsBuffer {
···403579 }()
404580 }
405581 ctx = templ.InitializeContext(ctx)
406406- 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>")
582582+ 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>")
407583 if templ_7745c5c3_Err != nil {
408584 return templ_7745c5c3_Err
409585 }
410410- var templ_7745c5c3_Var21 string
411411- templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(result.APIKey)
586586+ var templ_7745c5c3_Var27 string
587587+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(result.APIKey)
412588 if templ_7745c5c3_Err != nil {
413413- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 718, Col: 24}
589589+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1027, Col: 24}
414590 }
415415- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
591591+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
416592 if templ_7745c5c3_Err != nil {
417593 return templ_7745c5c3_Err
418594 }
419419- 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>")
595595+ 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>")
420596 if templ_7745c5c3_Err != nil {
421597 return templ_7745c5c3_Err
422598 }
423423- var templ_7745c5c3_Var22 string
424424- templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(result.SMTPHost)
599599+ var templ_7745c5c3_Var28 string
600600+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(result.SMTPHost)
425601 if templ_7745c5c3_Err != nil {
426426- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 728, Col: 37}
602602+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1037, Col: 37}
427603 }
428428- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
604604+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
429605 if templ_7745c5c3_Err != nil {
430606 return templ_7745c5c3_Err
431607 }
432432- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</code></li><li>Port: <code>")
608608+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</code></li><li>Port: <code>")
433609 if templ_7745c5c3_Err != nil {
434610 return templ_7745c5c3_Err
435611 }
436436- var templ_7745c5c3_Var23 string
437437- templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(intToStr(result.SMTPPort))
612612+ var templ_7745c5c3_Var29 string
613613+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(intToStr(result.SMTPPort))
438614 if templ_7745c5c3_Err != nil {
439439- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 729, Col: 47}
615615+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1038, Col: 47}
440616 }
441441- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
617617+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
442618 if templ_7745c5c3_Err != nil {
443619 return templ_7745c5c3_Err
444620 }
445445- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</code> (STARTTLS)</li><li>Username: <code>")
621621+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</code> (STARTTLS)</li><li>Username: <code>")
446622 if templ_7745c5c3_Err != nil {
447623 return templ_7745c5c3_Err
448624 }
449449- var templ_7745c5c3_Var24 string
450450- templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(result.DID)
625625+ var templ_7745c5c3_Var30 string
626626+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(result.DID)
451627 if templ_7745c5c3_Err != nil {
452452- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 730, Col: 36}
628628+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1039, Col: 36}
453629 }
454454- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
630630+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
455631 if templ_7745c5c3_Err != nil {
456632 return templ_7745c5c3_Err
457633 }
458458- 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>")
634634+ 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>")
459635 if templ_7745c5c3_Err != nil {
460636 return templ_7745c5c3_Err
461637 }
462462- var templ_7745c5c3_Var25 string
463463- templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(result.Domain)
638638+ var templ_7745c5c3_Var31 string
639639+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(result.Domain)
464640 if templ_7745c5c3_Err != nil {
465465- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 738, Col: 62}
641641+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1047, Col: 62}
466642 }
467467- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
643643+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
468644 if templ_7745c5c3_Err != nil {
469645 return templ_7745c5c3_Err
470646 }
471471- 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\">")
647647+ 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\">")
472648 if templ_7745c5c3_Err != nil {
473649 return templ_7745c5c3_Err
474650 }
475475- var templ_7745c5c3_Var26 string
476476- templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(result.DKIM.RSADNSName)
651651+ var templ_7745c5c3_Var32 string
652652+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(result.DKIM.RSADNSName)
477653 if templ_7745c5c3_Err != nil {
478478- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 743, Col: 57}
654654+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1052, Col: 57}
479655 }
480480- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
656656+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
481657 if templ_7745c5c3_Err != nil {
482658 return templ_7745c5c3_Err
483659 }
484484- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</div><pre>")
660660+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</div><pre>")
485661 if templ_7745c5c3_Err != nil {
486662 return templ_7745c5c3_Err
487663 }
488488- var templ_7745c5c3_Var27 string
489489- templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(result.DKIM.RSARecord)
664664+ var templ_7745c5c3_Var33 string
665665+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(result.DKIM.RSARecord)
490666 if templ_7745c5c3_Err != nil {
491491- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 744, Col: 32}
667667+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1053, Col: 32}
492668 }
493493- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
669669+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
494670 if templ_7745c5c3_Err != nil {
495671 return templ_7745c5c3_Err
496672 }
497497- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</pre></div><div class=\"dns-block\"><div class=\"dns-block-label\">")
673673+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</pre></div><div class=\"dns-block\"><div class=\"dns-block-label\">")
498674 if templ_7745c5c3_Err != nil {
499675 return templ_7745c5c3_Err
500676 }
501501- var templ_7745c5c3_Var28 string
502502- templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(result.DKIM.EdDNSName)
677677+ var templ_7745c5c3_Var34 string
678678+ templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(result.DKIM.EdDNSName)
503679 if templ_7745c5c3_Err != nil {
504504- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 747, Col: 56}
680680+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1056, Col: 56}
505681 }
506506- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
682682+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
507683 if templ_7745c5c3_Err != nil {
508684 return templ_7745c5c3_Err
509685 }
510510- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</div><pre>")
686686+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</div><pre>")
511687 if templ_7745c5c3_Err != nil {
512688 return templ_7745c5c3_Err
513689 }
514514- var templ_7745c5c3_Var29 string
515515- templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(result.DKIM.EdRecord)
690690+ var templ_7745c5c3_Var35 string
691691+ templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(result.DKIM.EdRecord)
516692 if templ_7745c5c3_Err != nil {
517517- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 748, Col: 31}
693693+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1057, Col: 31}
518694 }
519519- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
695695+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
520696 if templ_7745c5c3_Err != nil {
521697 return templ_7745c5c3_Err
522698 }
523523- 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>")
699699+ 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>")
524700 if templ_7745c5c3_Err != nil {
525701 return templ_7745c5c3_Err
526702 }
527527- var templ_7745c5c3_Var30 string
528528- templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(`@ TXT "v=spf1 ip4:87.99.138.77 -all"
703703+ var templ_7745c5c3_Var36 string
704704+ templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(`@ TXT "v=spf1 ip4:87.99.138.77 -all"
529705_dmarc TXT "v=DMARC1; p=reject; adkim=r; aspf=r; rua=mailto:postmaster@atmos.email"`)
530706 if templ_7745c5c3_Err != nil {
531531- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 758, Col: 85}
707707+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1067, Col: 85}
532708 }
533533- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
709709+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
534710 if templ_7745c5c3_Err != nil {
535711 return templ_7745c5c3_Err
536712 }
537537- 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>")
713713+ 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>")
538714 if templ_7745c5c3_Err != nil {
539715 return templ_7745c5c3_Err
540716 }
541717 if result.AttestationPublished {
542542- 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>")
718718+ 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>")
543719 if templ_7745c5c3_Err != nil {
544720 return templ_7745c5c3_Err
545721 }
546546- var templ_7745c5c3_Var31 string
547547- templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(result.Domain)
722722+ var templ_7745c5c3_Var37 string
723723+ templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(result.Domain)
548724 if templ_7745c5c3_Err != nil {
549549- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 775, Col: 55}
725725+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1084, Col: 55}
550726 }
551551- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
727727+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
552728 if templ_7745c5c3_Err != nil {
553729 return templ_7745c5c3_Err
554730 }
555555- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</code> is live in your PDS. Labels typically appear within a minute.</p></div>")
731731+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</code> is live in your PDS. Labels typically appear within a minute.</p></div>")
556732 if templ_7745c5c3_Err != nil {
557733 return templ_7745c5c3_Err
558734 }
···561737 return templ_7745c5c3_Err
562738 }
563739 } else {
564564- 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=\"")
740740+ 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=\"")
565741 if templ_7745c5c3_Err != nil {
566742 return templ_7745c5c3_Err
567743 }
568568- var templ_7745c5c3_Var32 string
569569- templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(result.DID)
744744+ var templ_7745c5c3_Var38 string
745745+ templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(result.DID)
570746 if templ_7745c5c3_Err != nil {
571571- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 790, Col: 55}
747747+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1099, Col: 55}
572748 }
573573- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
749749+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
574750 if templ_7745c5c3_Err != nil {
575751 return templ_7745c5c3_Err
576752 }
577577- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\"> <input type=\"hidden\" name=\"domain\" value=\"")
753753+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\"> <input type=\"hidden\" name=\"domain\" value=\"")
578754 if templ_7745c5c3_Err != nil {
579755 return templ_7745c5c3_Err
580756 }
581581- var templ_7745c5c3_Var33 string
582582- templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(result.Domain)
757757+ var templ_7745c5c3_Var39 string
758758+ templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(result.Domain)
583759 if templ_7745c5c3_Err != nil {
584584- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 791, Col: 61}
760760+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1100, Col: 61}
585761 }
586586- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
762762+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
587763 if templ_7745c5c3_Err != nil {
588764 return templ_7745c5c3_Err
589765 }
590590- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\"> <input type=\"hidden\" name=\"dkim_selector\" value=\"")
766766+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\"> <input type=\"hidden\" name=\"dkim_selector\" value=\"")
591767 if templ_7745c5c3_Err != nil {
592768 return templ_7745c5c3_Err
593769 }
594594- var templ_7745c5c3_Var34 string
595595- templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(result.DKIM.Selector)
770770+ var templ_7745c5c3_Var40 string
771771+ templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(result.DKIM.Selector)
596772 if templ_7745c5c3_Err != nil {
597597- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 792, Col: 75}
773773+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1101, Col: 75}
598774 }
599599- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
775775+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
600776 if templ_7745c5c3_Err != nil {
601777 return templ_7745c5c3_Err
602778 }
603603- 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>")
779779+ 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>")
604780 if templ_7745c5c3_Err != nil {
605781 return templ_7745c5c3_Err
606782 }
607783 }
608608- 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 >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>")
784784+ 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 >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>")
609785 if templ_7745c5c3_Err != nil {
610786 return templ_7745c5c3_Err
611787 }
612612- var templ_7745c5c3_Var35 string
613613- templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(renderSwaksQuickstart(result))
788788+ var templ_7745c5c3_Var41 string
789789+ templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(renderSwaksQuickstart(result))
614790 if templ_7745c5c3_Err != nil {
615615- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 836, Col: 39}
791791+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1145, Col: 39}
616792 }
617617- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
793793+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
618794 if templ_7745c5c3_Err != nil {
619795 return templ_7745c5c3_Err
620796 }
621621- 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>")
797797+ 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>")
622798 if templ_7745c5c3_Err != nil {
623799 return templ_7745c5c3_Err
624800 }
625801 return nil
626802 })
627627- templ_7745c5c3_Err = publicLayout("Enrolled", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var20), templ_7745c5c3_Buffer)
803803+ templ_7745c5c3_Err = publicLayout("Enrolled", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var26), templ_7745c5c3_Buffer)
628804 if templ_7745c5c3_Err != nil {
629805 return templ_7745c5c3_Err
630806 }
···652828 }()
653829 }
654830 ctx = templ.InitializeContext(ctx)
655655- templ_7745c5c3_Var36 := templ.GetChildren(ctx)
656656- if templ_7745c5c3_Var36 == nil {
657657- templ_7745c5c3_Var36 = templ.NopComponent
831831+ templ_7745c5c3_Var42 := templ.GetChildren(ctx)
832832+ if templ_7745c5c3_Var42 == nil {
833833+ templ_7745c5c3_Var42 = templ.NopComponent
658834 }
659835 ctx = templ.ClearChildren(ctx)
660660- templ_7745c5c3_Var37 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
836836+ templ_7745c5c3_Var43 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
661837 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
662838 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
663839 if !templ_7745c5c3_IsBuffer {
···669845 }()
670846 }
671847 ctx = templ.InitializeContext(ctx)
672672- 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>")
848848+ 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>")
673849 if templ_7745c5c3_Err != nil {
674850 return templ_7745c5c3_Err
675851 }
676676- var templ_7745c5c3_Var38 string
677677- templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(did)
852852+ var templ_7745c5c3_Var44 string
853853+ templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(did)
678854 if templ_7745c5c3_Err != nil {
679679- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 868, Col: 29}
855855+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1177, Col: 29}
680856 }
681681- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
857857+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
682858 if templ_7745c5c3_Err != nil {
683859 return templ_7745c5c3_Err
684860 }
685685- 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>")
861861+ 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>")
686862 if templ_7745c5c3_Err != nil {
687863 return templ_7745c5c3_Err
688864 }
689689- var templ_7745c5c3_Var39 string
690690- templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(domain)
865865+ var templ_7745c5c3_Var45 string
866866+ templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(domain)
691867 if templ_7745c5c3_Err != nil {
692692- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 885, Col: 26}
868868+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1194, Col: 26}
693869 }
694694- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
870870+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
695871 if templ_7745c5c3_Err != nil {
696872 return templ_7745c5c3_Err
697873 }
698698- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</code></p></section>")
874874+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</code></p></section>")
699875 if templ_7745c5c3_Err != nil {
700876 return templ_7745c5c3_Err
701877 }
702878 return nil
703879 })
704704- templ_7745c5c3_Err = publicLayout("Attestation published", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var37), templ_7745c5c3_Buffer)
880880+ templ_7745c5c3_Err = publicLayout("Attestation published", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var43), templ_7745c5c3_Buffer)
705881 if templ_7745c5c3_Err != nil {
706882 return templ_7745c5c3_Err
707883 }
···756932 }()
757933 }
758934 ctx = templ.InitializeContext(ctx)
759759- templ_7745c5c3_Var40 := templ.GetChildren(ctx)
760760- if templ_7745c5c3_Var40 == nil {
761761- templ_7745c5c3_Var40 = templ.NopComponent
935935+ templ_7745c5c3_Var46 := templ.GetChildren(ctx)
936936+ if templ_7745c5c3_Var46 == nil {
937937+ templ_7745c5c3_Var46 = templ.NopComponent
762938 }
763939 ctx = templ.ClearChildren(ctx)
764764- templ_7745c5c3_Var41 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
940940+ templ_7745c5c3_Var47 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
765941 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
766942 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
767943 if !templ_7745c5c3_IsBuffer {
···773949 }()
774950 }
775951 ctx = templ.InitializeContext(ctx)
776776- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<h1 class=\"masthead\"><span class=\"drop\">C</span>ouldn't enroll</h1><p class=\"lede\">")
952952+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<h1 class=\"masthead\"><span class=\"drop\">C</span>ouldn't enroll</h1><p class=\"lede\">")
777953 if templ_7745c5c3_Err != nil {
778954 return templ_7745c5c3_Err
779955 }
780780- var templ_7745c5c3_Var42 string
781781- templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(message)
956956+ var templ_7745c5c3_Var48 string
957957+ templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(message)
782958 if templ_7745c5c3_Err != nil {
783783- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 925, Col: 27}
959959+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1234, Col: 27}
784960 }
785785- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
961961+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
786962 if templ_7745c5c3_Err != nil {
787963 return templ_7745c5c3_Err
788964 }
789789- 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>")
965965+ 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>")
790966 if templ_7745c5c3_Err != nil {
791967 return templ_7745c5c3_Err
792968 }
793969 return nil
794970 })
795795- templ_7745c5c3_Err = publicLayout("Error", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var41), templ_7745c5c3_Buffer)
971971+ templ_7745c5c3_Err = publicLayout("Error", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var47), templ_7745c5c3_Buffer)
796972 if templ_7745c5c3_Err != nil {
797973 return templ_7745c5c3_Err
798974 }
···8381014 }()
8391015 }
8401016 ctx = templ.InitializeContext(ctx)
841841- templ_7745c5c3_Var43 := templ.GetChildren(ctx)
842842- if templ_7745c5c3_Var43 == nil {
843843- templ_7745c5c3_Var43 = templ.NopComponent
10171017+ templ_7745c5c3_Var49 := templ.GetChildren(ctx)
10181018+ if templ_7745c5c3_Var49 == nil {
10191019+ templ_7745c5c3_Var49 = templ.NopComponent
8441020 }
8451021 ctx = templ.ClearChildren(ctx)
846846- templ_7745c5c3_Var44 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
10221022+ templ_7745c5c3_Var50 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
8471023 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
8481024 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
8491025 if !templ_7745c5c3_IsBuffer {
···8551031 }()
8561032 }
8571033 ctx = templ.InitializeContext(ctx)
858858- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<h1 class=\"masthead masthead-sub\">Terms of Service</h1><p class=\"effective\">Effective ")
10341034+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<h1 class=\"masthead masthead-sub\">Terms of Service</h1><p class=\"effective\">Effective ")
8591035 if templ_7745c5c3_Err != nil {
8601036 return templ_7745c5c3_Err
8611037 }
862862- var templ_7745c5c3_Var45 string
863863- templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(legalEffectiveDate())
10381038+ var templ_7745c5c3_Var51 string
10391039+ templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(legalEffectiveDate())
8641040 if templ_7745c5c3_Err != nil {
865865- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 962, Col: 55}
10411041+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1271, Col: 55}
8661042 }
867867- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
10431043+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
8681044 if templ_7745c5c3_Err != nil {
8691045 return templ_7745c5c3_Err
8701046 }
871871- 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>")
10471047+ 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>")
8721048 if templ_7745c5c3_Err != nil {
8731049 return templ_7745c5c3_Err
8741050 }
8751051 return nil
8761052 })
877877- templ_7745c5c3_Err = publicLayout("Terms of Service", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var44), templ_7745c5c3_Buffer)
10531053+ templ_7745c5c3_Err = publicLayout("Terms of Service", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var50), templ_7745c5c3_Buffer)
8781054 if templ_7745c5c3_Err != nil {
8791055 return templ_7745c5c3_Err
8801056 }
···9011077 }()
9021078 }
9031079 ctx = templ.InitializeContext(ctx)
904904- templ_7745c5c3_Var46 := templ.GetChildren(ctx)
905905- if templ_7745c5c3_Var46 == nil {
906906- templ_7745c5c3_Var46 = templ.NopComponent
10801080+ templ_7745c5c3_Var52 := templ.GetChildren(ctx)
10811081+ if templ_7745c5c3_Var52 == nil {
10821082+ templ_7745c5c3_Var52 = templ.NopComponent
9071083 }
9081084 ctx = templ.ClearChildren(ctx)
909909- templ_7745c5c3_Var47 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
10851085+ templ_7745c5c3_Var53 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
9101086 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
9111087 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
9121088 if !templ_7745c5c3_IsBuffer {
···9181094 }()
9191095 }
9201096 ctx = templ.InitializeContext(ctx)
921921- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<h1 class=\"masthead masthead-sub\">Privacy Policy</h1><p class=\"effective\">Effective ")
10971097+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<h1 class=\"masthead masthead-sub\">Privacy Policy</h1><p class=\"effective\">Effective ")
9221098 if templ_7745c5c3_Err != nil {
9231099 return templ_7745c5c3_Err
9241100 }
925925- var templ_7745c5c3_Var48 string
926926- templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(legalEffectiveDate())
11011101+ var templ_7745c5c3_Var54 string
11021102+ templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(legalEffectiveDate())
9271103 if templ_7745c5c3_Err != nil {
928928- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1095, Col: 55}
11041104+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1404, Col: 55}
9291105 }
930930- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
11061106+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
9311107 if templ_7745c5c3_Err != nil {
9321108 return templ_7745c5c3_Err
9331109 }
934934- 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 & 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>")
11101110+ 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 & 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>")
9351111 if templ_7745c5c3_Err != nil {
9361112 return templ_7745c5c3_Err
9371113 }
9381114 return nil
9391115 })
940940- templ_7745c5c3_Err = publicLayout("Privacy Policy", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var47), templ_7745c5c3_Buffer)
11161116+ templ_7745c5c3_Err = publicLayout("Privacy Policy", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var53), templ_7745c5c3_Buffer)
9411117 if templ_7745c5c3_Err != nil {
9421118 return templ_7745c5c3_Err
9431119 }
···9641140 }()
9651141 }
9661142 ctx = templ.InitializeContext(ctx)
967967- templ_7745c5c3_Var49 := templ.GetChildren(ctx)
968968- if templ_7745c5c3_Var49 == nil {
969969- templ_7745c5c3_Var49 = templ.NopComponent
11431143+ templ_7745c5c3_Var55 := templ.GetChildren(ctx)
11441144+ if templ_7745c5c3_Var55 == nil {
11451145+ templ_7745c5c3_Var55 = templ.NopComponent
9701146 }
9711147 ctx = templ.ClearChildren(ctx)
972972- templ_7745c5c3_Var50 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
11481148+ templ_7745c5c3_Var56 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
9731149 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
9741150 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
9751151 if !templ_7745c5c3_IsBuffer {
···9811157 }()
9821158 }
9831159 ctx = templ.InitializeContext(ctx)
984984- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<h1 class=\"masthead masthead-sub\">Acceptable Use</h1><p class=\"effective\">Effective ")
11601160+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<h1 class=\"masthead masthead-sub\">Acceptable Use</h1><p class=\"effective\">Effective ")
9851161 if templ_7745c5c3_Err != nil {
9861162 return templ_7745c5c3_Err
9871163 }
988988- var templ_7745c5c3_Var51 string
989989- templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(legalEffectiveDate())
11641164+ var templ_7745c5c3_Var57 string
11651165+ templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(legalEffectiveDate())
9901166 if templ_7745c5c3_Err != nil {
991991- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1195, Col: 55}
11671167+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1504, Col: 55}
9921168 }
993993- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
11691169+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
9941170 if templ_7745c5c3_Err != nil {
9951171 return templ_7745c5c3_Err
9961172 }
997997- 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>")
11731173+ 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>")
9981174 if templ_7745c5c3_Err != nil {
9991175 return templ_7745c5c3_Err
10001176 }
10011177 return nil
10021178 })
10031003- templ_7745c5c3_Err = publicLayout("Acceptable Use Policy", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var50), templ_7745c5c3_Buffer)
11791179+ templ_7745c5c3_Err = publicLayout("Acceptable Use Policy", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var56), templ_7745c5c3_Buffer)
10041180 if templ_7745c5c3_Err != nil {
10051181 return templ_7745c5c3_Err
10061182 }
···10271203 }()
10281204 }
10291205 ctx = templ.InitializeContext(ctx)
10301030- templ_7745c5c3_Var52 := templ.GetChildren(ctx)
10311031- if templ_7745c5c3_Var52 == nil {
10321032- templ_7745c5c3_Var52 = templ.NopComponent
12061206+ templ_7745c5c3_Var58 := templ.GetChildren(ctx)
12071207+ if templ_7745c5c3_Var58 == nil {
12081208+ templ_7745c5c3_Var58 = templ.NopComponent
10331209 }
10341210 ctx = templ.ClearChildren(ctx)
10351035- templ_7745c5c3_Var53 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
12111211+ templ_7745c5c3_Var59 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
10361212 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
10371213 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
10381214 if !templ_7745c5c3_IsBuffer {
···10441220 }()
10451221 }
10461222 ctx = templ.InitializeContext(ctx)
10471047- 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\">")
12231223+ 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\">")
10481224 if templ_7745c5c3_Err != nil {
10491225 return templ_7745c5c3_Err
10501226 }
10511051- var templ_7745c5c3_Var54 string
10521052- templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs("@scottlanoue.com")
12271227+ var templ_7745c5c3_Var60 string
12281228+ templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs("@scottlanoue.com")
10531229 if templ_7745c5c3_Err != nil {
10541054- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1300, Col: 84}
12301230+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/admin/ui/templates/enroll.templ`, Line: 1609, Col: 84}
10551231 }
10561056- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
12321232+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60))
10571233 if templ_7745c5c3_Err != nil {
10581234 return templ_7745c5c3_Err
10591235 }
10601060- 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 & 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>")
12361236+ 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 & 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>")
10611237 if templ_7745c5c3_Err != nil {
10621238 return templ_7745c5c3_Err
10631239 }
10641240 return nil
10651241 })
10661066- templ_7745c5c3_Err = publicLayout("About", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var53), templ_7745c5c3_Buffer)
12421242+ templ_7745c5c3_Err = publicLayout("About", false).Render(templ.WithChildren(ctx, templ_7745c5c3_Var59), templ_7745c5c3_Buffer)
10671243 if templ_7745c5c3_Err != nil {
10681244 return templ_7745c5c3_Err
10691245 }
···1616 "io"
1717 "strings"
18181919+ "atmosphere-mail/internal/relaystore"
2020+1921 "github.com/a-h/templ"
2022)
2123···4042 MessageErr bool
4143}
42444545+// RecoverSelectDomainData drives the post-OAuth domain picker for DIDs
4646+// with more than one enrolled sending domain.
4747+type RecoverSelectDomainData struct {
4848+ DID string
4949+ Domains []relaystore.MemberDomain
5050+ ExpiresAt string
5151+}
5252+4353// RecoverCompleteData is the one-time new-key-reveal view model.
4454type RecoverCompleteData struct {
4555 DID string
···4757 APIKey string
4858}
49595050-// RecoverLanding renders the /account entry page: a single-field form
5151-// asking for the sending domain, with an optional inline error banner.
6060+// RecoverLanding renders the /account entry page: a handle/DID form with
6161+// an optional inline error banner.
5262func RecoverLanding(errMsg string) templ.Component {
5363 return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error {
5464 inner := templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
5565 var b strings.Builder
5666 b.WriteString(`<h1 class="masthead masthead-sub">Account</h1>`)
5757- 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>`)
6767+ 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>`)
5868 b.WriteString(`<section class="section" style="margin-top: 1.25rem; padding-top: 0.75rem;">`)
5959- b.WriteString(`<h2 style="margin-bottom: 0.75rem;">Which domain?</h2>`)
6969+ b.WriteString(`<h2 style="margin-bottom: 0.75rem;">Sign in</h2>`)
6070 if errMsg != "" {
6171 fmt.Fprintf(&b, `<div class="error-note" role="alert">%s</div>`, html.EscapeString(errMsg))
6272 }
6363- b.WriteString(`<form method="POST" action="/account/start">`)
6464- b.WriteString(`<label for="domain">Sending domain</label>`)
6565- b.WriteString(`<small>The domain you originally enrolled. We'll look up the DID and start an OAuth handshake against your PDS.</small>`)
6666- 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">`)
6767- b.WriteString(`<button type="submit">Sign in</button>`)
7373+ b.WriteString(`<form id="account-form" method="POST" action="/account/start">`)
7474+ b.WriteString(`<label for="identity">Your handle</label>`)
7575+ 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>`)
7676+ b.WriteString(`<input type="text" id="identity" name="identity" placeholder="alice.bsky.social" required autocomplete="off" spellcheck="false" autocapitalize="off">`)
7777+ b.WriteString(`<div id="resolver-hint" class="resolver-hint" aria-live="polite"></div>`)
7878+ b.WriteString(`<input type="hidden" id="did" name="did" value="">`)
7979+ b.WriteString(`<button type="submit" id="account-submit" data-default-text="Sign in">Sign in</button>`)
6880 b.WriteString(`</form>`)
6981 b.WriteString(`<p class="section-lede" style="margin-top: 1rem; margin-bottom: 0;">Not enrolled yet? <a href="/enroll">Start here</a>.</p>`)
8282+ b.WriteString(`<script>
8383+(function() {
8484+ var SEARCH_API = 'https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead';
8585+ var DEBOUNCE_MS = 250;
8686+ var MIN_QUERY = 2;
8787+ var MAX_RESULTS = 6;
8888+8989+ var form = document.getElementById('account-form');
9090+ var identity = document.getElementById('identity');
9191+ var didField = document.getElementById('did');
9292+ var hint = document.getElementById('resolver-hint');
9393+ var submit = document.getElementById('account-submit');
9494+9595+ var debounceTimer = null;
9696+ var abortCtrl = null;
9797+ var activeIndex = -1;
9898+ var currentResults = [];
9999+100100+ function esc(s) {
101101+ return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
102102+ }
103103+ function isDID(s) {
104104+ return /^did:(plc|web):[A-Za-z0-9._%\-]+$/.test(s.trim());
105105+ }
106106+ function setHint(text, cls) {
107107+ hint.textContent = text;
108108+ hint.className = 'resolver-hint ' + (cls || '');
109109+ }
110110+111111+ var wrapper = document.createElement('div');
112112+ wrapper.className = 'handle-input-wrapper';
113113+ identity.parentElement.insertBefore(wrapper, identity);
114114+ wrapper.appendChild(identity);
115115+ wrapper.parentElement.insertBefore(hint, wrapper.nextSibling);
116116+117117+ var dropdown = document.createElement('div');
118118+ dropdown.className = 'handle-suggestions';
119119+ dropdown.setAttribute('role', 'listbox');
120120+ dropdown.style.display = 'none';
121121+ wrapper.appendChild(dropdown);
122122+ identity.setAttribute('role', 'combobox');
123123+ identity.setAttribute('aria-autocomplete', 'list');
124124+ identity.setAttribute('aria-expanded', 'false');
125125+126126+ function renderSuggestions(results) {
127127+ if (!results.length) {
128128+ dropdown.style.display = 'none';
129129+ identity.setAttribute('aria-expanded', 'false');
130130+ return;
131131+ }
132132+ dropdown.innerHTML = results.map(function(r, i) {
133133+ return '<div class="handle-suggestion" role="option" data-index="' + i + '" data-handle="' + esc(r.handle) + '">'
134134+ + (r.avatar
135135+ ? '<img src="' + esc(r.avatar) + '" alt="" class="suggestion-avatar"/>'
136136+ : '<div class="suggestion-avatar-placeholder"></div>')
137137+ + '<div class="suggestion-text">'
138138+ + '<span class="suggestion-name">' + esc(r.displayName) + '</span>'
139139+ + '<span class="suggestion-handle">@' + esc(r.handle) + '</span>'
140140+ + '</div></div>';
141141+ }).join('');
142142+ dropdown.style.display = '';
143143+ identity.setAttribute('aria-expanded', 'true');
144144+ }
145145+146146+ function updateActive() {
147147+ var items = dropdown.querySelectorAll('.handle-suggestion');
148148+ for (var i = 0; i < items.length; i++) {
149149+ if (i === activeIndex) items[i].classList.add('active');
150150+ else items[i].classList.remove('active');
151151+ }
152152+ }
153153+154154+ function selectHandle(handle) {
155155+ identity.value = handle;
156156+ dropdown.style.display = 'none';
157157+ identity.setAttribute('aria-expanded', 'false');
158158+ currentResults = [];
159159+ activeIndex = -1;
160160+ resolve(handle);
161161+ }
162162+163163+ function searchHandles(query) {
164164+ if (abortCtrl) abortCtrl.abort();
165165+ if (query.length < MIN_QUERY) return Promise.resolve([]);
166166+ abortCtrl = new AbortController();
167167+ return fetch(SEARCH_API + '?q=' + encodeURIComponent(query) + '&limit=' + MAX_RESULTS, { signal: abortCtrl.signal })
168168+ .then(function(r) { return r.ok ? r.json() : { actors: [] }; })
169169+ .then(function(data) {
170170+ return (data.actors || []).map(function(a) {
171171+ return { handle: a.handle, displayName: a.displayName || a.handle, avatar: a.avatar || null };
172172+ });
173173+ })
174174+ .catch(function() { return []; });
175175+ }
176176+177177+ function debouncedSearch(query) {
178178+ if (debounceTimer) clearTimeout(debounceTimer);
179179+ if (query.length < MIN_QUERY) { renderSuggestions([]); return; }
180180+ debounceTimer = setTimeout(function() {
181181+ searchHandles(query).then(function(results) {
182182+ currentResults = results;
183183+ activeIndex = -1;
184184+ renderSuggestions(results);
185185+ });
186186+ }, DEBOUNCE_MS);
187187+ }
188188+189189+ async function resolve(raw) {
190190+ var v = (raw || '').replace(/^@/, '').trim();
191191+ if (!v) { setHint('', ''); didField.value = ''; return; }
192192+ if (isDID(v)) { setHint(v, 'is-ok'); didField.value = v; return; }
193193+ setHint('Resolving ' + v + '…', 'is-loading');
194194+ didField.value = '';
195195+ try {
196196+ var r = await fetch('/enroll/resolve?handle=' + encodeURIComponent(v), {
197197+ headers: { Accept: 'application/json' },
198198+ });
199199+ if (!r.ok) {
200200+ var body = await r.json().catch(function() { return {error:'resolution failed'}; });
201201+ setHint(body.error || 'resolution failed', 'is-err');
202202+ return;
203203+ }
204204+ var data = await r.json();
205205+ if (data.did) {
206206+ setHint(data.did, 'is-ok');
207207+ didField.value = data.did;
208208+ }
209209+ } catch (e) {
210210+ setHint('Network error — try again or paste a DID directly', 'is-err');
211211+ }
212212+ }
213213+214214+ identity.addEventListener('input', function() {
215215+ var q = identity.value.trim().replace(/^@/, '');
216216+ if (didField.value) { didField.value = ''; setHint('', ''); }
217217+ if (isDID(q)) {
218218+ renderSuggestions([]);
219219+ setHint(q, 'is-ok');
220220+ didField.value = q;
221221+ return;
222222+ }
223223+ debouncedSearch(q);
224224+ });
225225+226226+ identity.addEventListener('keydown', function(e) {
227227+ if (!currentResults.length) return;
228228+ if (e.key === 'ArrowDown') {
229229+ e.preventDefault();
230230+ activeIndex = Math.min(activeIndex + 1, currentResults.length - 1);
231231+ updateActive();
232232+ } else if (e.key === 'ArrowUp') {
233233+ e.preventDefault();
234234+ activeIndex = Math.max(activeIndex - 1, 0);
235235+ updateActive();
236236+ } else if (e.key === 'Enter' && activeIndex >= 0) {
237237+ e.preventDefault();
238238+ e.stopPropagation();
239239+ selectHandle(currentResults[activeIndex].handle);
240240+ } else if (e.key === 'Escape') {
241241+ dropdown.style.display = 'none';
242242+ identity.setAttribute('aria-expanded', 'false');
243243+ activeIndex = -1;
244244+ }
245245+ });
246246+247247+ dropdown.addEventListener('mousedown', function(e) {
248248+ e.preventDefault();
249249+ var target = e.target.closest('.handle-suggestion');
250250+ if (target) selectHandle(target.dataset.handle);
251251+ });
252252+253253+ identity.addEventListener('blur', function() {
254254+ setTimeout(function() {
255255+ dropdown.style.display = 'none';
256256+ identity.setAttribute('aria-expanded', 'false');
257257+ }, 150);
258258+ if (!didField.value) resolve(identity.value);
259259+ });
260260+261261+ form.addEventListener('submit', async function(ev) {
262262+ if (didField.value) return;
263263+ ev.preventDefault();
264264+ submit.disabled = true;
265265+ submit.textContent = 'Resolving identity…';
266266+ await resolve(identity.value);
267267+ submit.disabled = false;
268268+ submit.textContent = submit.getAttribute('data-default-text') || 'Sign in';
269269+ if (didField.value) form.submit();
270270+ });
271271+})();
272272+</script>`)
70273 b.WriteString(`</section>`)
71274 _, err := io.WriteString(w, b.String())
72275 return err
···75278 })
76279}
77280281281+// RecoverSelectDomain renders after OAuth when the verified DID has more
282282+// than one enrolled domain. The ticket remains in the HttpOnly cookie; each
283283+// button posts the chosen domain for server-side DID/domain validation.
284284+func RecoverSelectDomain(d RecoverSelectDomainData) templ.Component {
285285+ return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error {
286286+ inner := templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
287287+ var b strings.Builder
288288+ b.WriteString(`<h1 class="masthead masthead-sub">Account</h1>`)
289289+ 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))
290290+ b.WriteString(`<section class="section">`)
291291+ b.WriteString(`<h2>Sending domain</h2>`)
292292+ b.WriteString(`<p class="section-lede">API key rotation, contact email, and DKIM records are scoped to one enrolled sending domain.</p>`)
293293+ for _, domain := range d.Domains {
294294+ b.WriteString(`<form method="POST" action="/account/select-domain" style="margin-top: 0.75rem;">`)
295295+ fmt.Fprintf(&b, `<input type="hidden" name="domain" value="%s">`, html.EscapeString(domain.Domain))
296296+ fmt.Fprintf(&b, `<button type="submit">%s</button>`, html.EscapeString(domain.Domain))
297297+ b.WriteString(`</form>`)
298298+ }
299299+ fmt.Fprintf(&b, `<p class="section-lede" style="margin-top: 1.5rem;"><small>Session expires at %s.</small></p>`, html.EscapeString(d.ExpiresAt))
300300+ b.WriteString(`</section>`)
301301+ _, err := io.WriteString(w, b.String())
302302+ return err
303303+ })
304304+ return publicLayout("Choose account domain", false).Render(templ.WithChildren(ctx, inner), w)
305305+ })
306306+}
307307+78308// RecoverManage renders the signed-in member's account dashboard.
79309func RecoverManage(d RecoverManageData) templ.Component {
80310 return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error {
···129359 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))
130360 b.WriteString(`<button type="submit">Regenerate API key</button>`)
131361 b.WriteString(`</form>`)
362362+ b.WriteString(`</section>`)
363363+364364+ // Add another domain
365365+ b.WriteString(`<section class="section">`)
366366+ b.WriteString(`<h2>Add another domain</h2>`)
367367+ b.WriteString(`<p class="section-lede">Enroll an additional sending domain under this account (up to 2 during the alpha).</p>`)
368368+ b.WriteString(`<a href="/enroll" class="btn">Add domain →</a>`)
132369 b.WriteString(`</section>`)
133370134371 // Sign out — ends the session and redirects to /.
+2-3
internal/atpoauth/client.go
···9393 ClientID string
9494 // CallbackURL is the single, fully-qualified redirect URI.
9595 CallbackURL string
9696- // Scopes requested on every flow. Must include "atproto". For record
9797- // writes we also pass "transition:generic".
9696+ // Scopes requested on every flow. Must include "atproto".
9897 Scopes []string
9998 // SigningKeyPath is where the confidential-client ES256 private key is
10099 // persisted (PEM-wrapped multibase). If the file is missing, a new key
···128127 return nil, fmt.Errorf("atpoauth: ClientID and CallbackURL are required")
129128 }
130129 if len(cfg.Scopes) == 0 {
131131- cfg.Scopes = []string{"atproto", "transition:generic"}
130130+ cfg.Scopes = []string{"atproto", "repo:email.atmos.attestation"}
132131 }
133132 if cfg.KeyID == "" {
134133 cfg.KeyID = "atmosphere-mail-1"
+6
internal/notify/webhook.go
···5757 // KindMemberReactivated fires when a suspended member is returned
5858 // to active status by operator action.
5959 KindMemberReactivated EventKind = "member_reactivated"
6060+6161+ // KindMemberDomainAdded fires when an existing member enrolls an
6262+ // additional sending domain. Informational — the operator may want
6363+ // visibility but no action is required when the member is already
6464+ // approved.
6565+ KindMemberDomainAdded EventKind = "member_domain_added"
6066)
61676268// Event is the payload shape every webhook call carries. Fields are
+142-1
internal/relay/metrics.go
···11package relay
2233-import "github.com/prometheus/client_golang/prometheus"
33+import (
44+ "fmt"
55+ "net/http"
66+ "strings"
77+ "time"
88+99+ "github.com/prometheus/client_golang/prometheus"
1010+)
411512// Metrics holds all Prometheus metrics for the relay.
613type Metrics struct {
···1320 AuthAttempts *prometheus.CounterVec // result: success, failure
1421 RateLimitHits *prometheus.CounterVec // limit_type: hourly, daily, global
15222323+ // HTTP request tracking
2424+ HTTPRequestsTotal *prometheus.CounterVec // host, method, path, status
2525+ HTTPRequestDuration *prometheus.HistogramVec // host, method, path
2626+2727+ // Enrollment funnel
2828+ EnrollFunnel *prometheus.CounterVec // step: marketing, landing, auth_start, enroll_start, enroll_verify, enroll_success, attest_start, attest_callback
2929+1630 // Gauges
1731 DeliveryQueueDepth prometheus.Gauge
1832 MembersTotal *prometheus.GaugeVec // status: active, suspended
···2640 OspreyEventsEmitted *prometheus.CounterVec // event_type
2741 OspreyEventsFailed *prometheus.CounterVec // event_type
28424343+ // FBL/ARF complaint tracking
4444+ ComplaintsTotal *prometheus.CounterVec // feedback_type, provider
4545+4646+ // OAuth callback results
4747+ OAuthCallbacks *prometheus.CounterVec // type: enroll_auth, recovery, attestation, error
4848+2949 // Inbound mail classification + forwarding (Phase 1b)
3050 InboundMessages *prometheus.CounterVec // classification: verp_bounce, srs_bounce, reply, postmaster
3151 RepliesForwarded *prometheus.CounterVec // status: sent, failed
···6282 Name: "atmosphere_relay_ratelimit_hits_total",
6383 Help: "Total rate limit hits, by limit type.",
6484 }, []string{"limit_type"}),
8585+ HTTPRequestsTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
8686+ Name: "atmos_http_requests_total",
8787+ Help: "Total HTTP requests by host, method, path, and status code.",
8888+ }, []string{"host", "method", "path", "status"}),
8989+ HTTPRequestDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
9090+ Name: "atmos_http_request_duration_seconds",
9191+ Help: "HTTP request duration in seconds by host, method, and path.",
9292+ Buckets: []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5},
9393+ }, []string{"host", "method", "path"}),
9494+ EnrollFunnel: prometheus.NewCounterVec(prometheus.CounterOpts{
9595+ Name: "atmos_enroll_funnel_total",
9696+ Help: "Enrollment funnel step visits.",
9797+ }, []string{"step"}),
6598 DeliveryQueueDepth: prometheus.NewGauge(prometheus.GaugeOpts{
6699 Name: "atmosphere_relay_delivery_queue_depth",
67100 Help: "Current number of messages in the delivery queue.",
···90123 Name: "atmosphere_relay_osprey_events_failed_total",
91124 Help: "Osprey events that failed to write to Kafka (marshal, sync buffer-full, or async broker error), by event type.",
92125 }, []string{"event_type"}),
126126+ ComplaintsTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
127127+ Name: "atmosphere_relay_complaints_total",
128128+ Help: "Total FBL/ARF complaints received.",
129129+ }, []string{"feedback_type", "provider"}),
93130 InboundMessages: prometheus.NewCounterVec(prometheus.CounterOpts{
94131 Name: "atmosphere_relay_inbound_messages_total",
95132 Help: "Inbound messages received on :25, by classification.",
···98135 Name: "atmosphere_relay_replies_forwarded_total",
99136 Help: "Outcome of reply-forwarding attempts, by status.",
100137 }, []string{"status"}),
138138+ OAuthCallbacks: prometheus.NewCounterVec(prometheus.CounterOpts{
139139+ Name: "atmos_enroll_oauth_callbacks_total",
140140+ Help: "OAuth callback completions, by result type.",
141141+ }, []string{"type"}),
101142 }
102143103144 reg.MustRegister(
···115156 m.OspreyChecksTotal,
116157 m.OspreyEventsEmitted,
117158 m.OspreyEventsFailed,
159159+ m.ComplaintsTotal,
118160 m.InboundMessages,
119161 m.RepliesForwarded,
162162+ m.HTTPRequestsTotal,
163163+ m.HTTPRequestDuration,
164164+ m.EnrollFunnel,
165165+ m.OAuthCallbacks,
120166 )
121167122168 // Initialize label values so they appear in /metrics output even at zero
···147193 m.OspreyEventsEmitted.WithLabelValues("delivery_result")
148194 m.OspreyEventsEmitted.WithLabelValues("bounce_received")
149195 m.OspreyEventsEmitted.WithLabelValues("member_suspended")
196196+ m.OspreyEventsEmitted.WithLabelValues("complaint_received")
150197 m.OspreyEventsFailed.WithLabelValues("relay_attempt")
151198 m.OspreyEventsFailed.WithLabelValues("relay_rejected")
152199 m.OspreyEventsFailed.WithLabelValues("delivery_result")
153200 m.OspreyEventsFailed.WithLabelValues("bounce_received")
154201 m.OspreyEventsFailed.WithLabelValues("member_suspended")
202202+ m.OspreyEventsFailed.WithLabelValues("complaint_received")
203203+ for _, ft := range []string{"abuse", "fraud", "not-spam", "other"} {
204204+ for _, p := range []string{"gmail", "microsoft", "yahoo", "other"} {
205205+ m.ComplaintsTotal.WithLabelValues(ft, p)
206206+ }
207207+ }
155208 m.InboundMessages.WithLabelValues("verp_bounce")
156209 m.InboundMessages.WithLabelValues("srs_bounce")
157210 m.InboundMessages.WithLabelValues("reply")
158211 m.InboundMessages.WithLabelValues("postmaster")
159212 m.RepliesForwarded.WithLabelValues("sent")
160213 m.RepliesForwarded.WithLabelValues("failed")
214214+ for _, step := range []string{"marketing", "landing", "auth_start", "enroll_start", "enroll_verify", "enroll_success", "attest_start", "attest_callback"} {
215215+ m.EnrollFunnel.WithLabelValues(step)
216216+ }
217217+ for _, t := range []string{"enroll_auth", "recovery", "attestation", "error"} {
218218+ m.OAuthCallbacks.WithLabelValues(t)
219219+ }
161220162221 return m
163222}
164223224224+// RecordEnrollStep increments the enrollment funnel counter for the given step.
225225+func (m *Metrics) RecordEnrollStep(step string) {
226226+ m.EnrollFunnel.WithLabelValues(step).Inc()
227227+}
228228+229229+// RecordOAuthCallback increments the OAuth callback counter for the given type.
230230+func (m *Metrics) RecordOAuthCallback(callbackType string) {
231231+ m.OAuthCallbacks.WithLabelValues(callbackType).Inc()
232232+}
233233+165234// RecordInbound implements relay.InboundMetrics.
166235func (m *Metrics) RecordInbound(classification string) {
167236 m.InboundMessages.WithLabelValues(classification).Inc()
···185254func (a *EmitterMetricsAdapter) IncFailed(eventType string) {
186255 a.Failed.WithLabelValues(eventType).Inc()
187256}
257257+258258+// HTTPMiddleware wraps an http.Handler to record request count and duration.
259259+func (m *Metrics) HTTPMiddleware(next http.Handler) http.Handler {
260260+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
261261+ start := time.Now()
262262+ rw := &statusWriter{ResponseWriter: w, status: 200}
263263+ next.ServeHTTP(rw, r)
264264+265265+ host := normalizeMetricsHost(r.Host)
266266+ path := collapseRoute(r.URL.Path)
267267+ status := fmt.Sprintf("%d", rw.status)
268268+269269+ m.HTTPRequestsTotal.WithLabelValues(host, r.Method, path, status).Inc()
270270+ m.HTTPRequestDuration.WithLabelValues(host, r.Method, path).Observe(time.Since(start).Seconds())
271271+ })
272272+}
273273+274274+type statusWriter struct {
275275+ http.ResponseWriter
276276+ status int
277277+ wroteHeader bool
278278+}
279279+280280+func (w *statusWriter) WriteHeader(code int) {
281281+ if !w.wroteHeader {
282282+ w.status = code
283283+ w.wroteHeader = true
284284+ }
285285+ w.ResponseWriter.WriteHeader(code)
286286+}
287287+288288+func (w *statusWriter) Write(b []byte) (int, error) {
289289+ if !w.wroteHeader {
290290+ w.wroteHeader = true
291291+ }
292292+ return w.ResponseWriter.Write(b)
293293+}
294294+295295+func normalizeMetricsHost(h string) string {
296296+ h = strings.ToLower(h)
297297+ if i := strings.LastIndex(h, ":"); i != -1 {
298298+ return h[:i]
299299+ }
300300+ return h
301301+}
302302+303303+// collapseRoute maps request paths to a small set of route patterns
304304+// to avoid unbounded cardinality in metrics labels.
305305+func collapseRoute(path string) string {
306306+ switch {
307307+ case path == "/" || path == "":
308308+ return "/"
309309+ case path == "/enroll" || path == "/enroll/":
310310+ return "/enroll"
311311+ case strings.HasPrefix(path, "/enroll/"):
312312+ return "/enroll/*"
313313+ case strings.HasPrefix(path, "/u/"):
314314+ return "/u/*"
315315+ case path == "/healthz":
316316+ return "/healthz"
317317+ case path == "/verify-email":
318318+ return "/verify-email"
319319+ case path == "/.well-known/atproto-oauth-client-metadata.json":
320320+ return "/.well-known/atproto-oauth-client-metadata.json"
321321+ case strings.HasPrefix(path, "/.well-known/"):
322322+ return "/.well-known/*"
323323+ case path == "/metrics":
324324+ return "/metrics"
325325+ default:
326326+ return "/other"
327327+ }
328328+}
+103
internal/relay/metrics_test.go
···3232 "atmosphere_relay_labeler_reachable": false,
3333 "atmosphere_relay_auth_attempts_total": false,
3434 "atmosphere_relay_ratelimit_hits_total": false,
3535+ "atmosphere_relay_complaints_total": false,
3536 }
36373738 for _, f := range families {
···102103 }
103104 if v := testutil.ToFloat64(m.LabelerReachable); v != 1 {
104105 t.Errorf("LabelerReachable = %v, want 1", v)
106106+ }
107107+}
108108+109109+func TestMetricsComplaintsTotalPreInitialized(t *testing.T) {
110110+ reg := prometheus.NewRegistry()
111111+ NewMetrics(reg)
112112+113113+ families, err := reg.Gather()
114114+ if err != nil {
115115+ t.Fatalf("Gather: %v", err)
116116+ }
117117+118118+ type combo struct{ ft, p string }
119119+ want := map[combo]bool{
120120+ {"abuse", "gmail"}: false,
121121+ {"abuse", "microsoft"}: false,
122122+ {"abuse", "yahoo"}: false,
123123+ {"abuse", "other"}: false,
124124+ {"fraud", "gmail"}: false,
125125+ {"fraud", "other"}: false,
126126+ {"not-spam", "gmail"}: false,
127127+ {"other", "other"}: false,
128128+ }
129129+130130+ for _, f := range families {
131131+ if f.GetName() != "atmosphere_relay_complaints_total" {
132132+ continue
133133+ }
134134+ for _, m := range f.Metric {
135135+ var ft, p string
136136+ for _, l := range m.Label {
137137+ switch l.GetName() {
138138+ case "feedback_type":
139139+ ft = l.GetValue()
140140+ case "provider":
141141+ p = l.GetValue()
142142+ }
143143+ }
144144+ c := combo{ft, p}
145145+ if _, ok := want[c]; ok {
146146+ want[c] = true
147147+ }
148148+ }
149149+ }
150150+151151+ for c, found := range want {
152152+ if !found {
153153+ t.Errorf("ComplaintsTotal{feedback_type=%q, provider=%q} not pre-initialized", c.ft, c.p)
154154+ }
155155+ }
156156+}
157157+158158+func TestMetricsComplaintReceiredOspreyPreInitialized(t *testing.T) {
159159+ reg := prometheus.NewRegistry()
160160+ NewMetrics(reg)
161161+162162+ families, err := reg.Gather()
163163+ if err != nil {
164164+ t.Fatalf("Gather: %v", err)
165165+ }
166166+167167+ for _, label := range []string{"complaint_received"} {
168168+ for _, metricName := range []string{
169169+ "atmosphere_relay_osprey_events_emitted_total",
170170+ "atmosphere_relay_osprey_events_failed_total",
171171+ } {
172172+ found := false
173173+ for _, f := range families {
174174+ if f.GetName() != metricName {
175175+ continue
176176+ }
177177+ for _, m := range f.Metric {
178178+ for _, l := range m.Label {
179179+ if l.GetName() == "event_type" && l.GetValue() == label {
180180+ found = true
181181+ }
182182+ }
183183+ }
184184+ }
185185+ if !found {
186186+ t.Errorf("event_type=%q must be pre-initialized in %s", label, metricName)
187187+ }
188188+ }
189189+ }
190190+}
191191+192192+func TestMetricsComplaintsTotalIncrement(t *testing.T) {
193193+ reg := prometheus.NewRegistry()
194194+ m := NewMetrics(reg)
195195+196196+ m.ComplaintsTotal.WithLabelValues("abuse", "gmail").Inc()
197197+ m.ComplaintsTotal.WithLabelValues("abuse", "gmail").Inc()
198198+ m.ComplaintsTotal.WithLabelValues("fraud", "microsoft").Inc()
199199+200200+ if v := testutil.ToFloat64(m.ComplaintsTotal.WithLabelValues("abuse", "gmail")); v != 2 {
201201+ t.Errorf("ComplaintsTotal(abuse, gmail) = %v, want 2", v)
202202+ }
203203+ if v := testutil.ToFloat64(m.ComplaintsTotal.WithLabelValues("fraud", "microsoft")); v != 1 {
204204+ t.Errorf("ComplaintsTotal(fraud, microsoft) = %v, want 1", v)
205205+ }
206206+ if v := testutil.ToFloat64(m.ComplaintsTotal.WithLabelValues("abuse", "yahoo")); v != 0 {
207207+ t.Errorf("ComplaintsTotal(abuse, yahoo) = %v, want 0", v)
105208 }
106209}
107210
+71
internal/relay/opmail.go
···5757 // OpMailKeyRegenerated tells a member their API key was rotated.
5858 // Recipient is the member's contact_email.
5959 OpMailKeyRegenerated OpMailKind = "key_regenerated"
6060+ // OpMailFBLComplaint notifies the operator that a FBL/ARF complaint
6161+ // was received for a member's sending domain.
6262+ OpMailFBLComplaint OpMailKind = "fbl_complaint"
6363+ // OpMailEmailVerification asks a member to verify their contact_email
6464+ // by clicking a link. Recipient is the member's contact_email.
6565+ OpMailEmailVerification OpMailKind = "email_verification"
6066)
61676268// OpMailSendFunc is the swappable transport for system mail. Production
···102108 Domain string
103109}
104110111111+// FBLComplaintData drives the fbl_complaint template.
112112+type FBLComplaintData struct {
113113+ MemberDID string
114114+ SenderDomain string
115115+ RecipientDomain string
116116+ FeedbackType string
117117+ Provider string
118118+}
119119+120120+// EmailVerificationData drives the email_verification template.
121121+type EmailVerificationData struct {
122122+ Domain string
123123+ VerifyURL string
124124+}
125125+105126// OpMailer renders and dispatches system mail. Built once at startup and
106127// shared across call sites (admin API enroll path, UI approve action,
107128// regenerate-key endpoint). Safe for concurrent use — the signer and
···160181 return m.sendTemplated(ctx, OpMailKeyRegenerated, "welcome", to, subject, body)
161182}
162183184184+// SendFBLComplaint notifies the operator that a FBL/ARF complaint was
185185+// received. Recipient is the operator's forwarding address. Unlike member
186186+// mail, complaints always have a recipient (the operator), so no
187187+// ErrNoContactEmail path.
188188+func (m *OpMailer) SendFBLComplaint(ctx OpMailContext, to string, data FBLComplaintData) (string, error) {
189189+ if to == "" {
190190+ return "", fmt.Errorf("fbl_complaint: recipient required")
191191+ }
192192+ subject := fmt.Sprintf("atmos.email — FBL complaint: %s (%s)", data.SenderDomain, data.Provider)
193193+ body := renderFBLComplaint(data)
194194+ return m.sendTemplated(ctx, OpMailFBLComplaint, "ops", to, subject, body)
195195+}
196196+197197+// SendEmailVerification asks a member to verify their contact_email by
198198+// clicking a link. Returns ErrNoContactEmail if `to` is empty.
199199+func (m *OpMailer) SendEmailVerification(ctx OpMailContext, to string, data EmailVerificationData) (string, error) {
200200+ if to == "" {
201201+ return "", ErrNoContactEmail
202202+ }
203203+ subject := "atmos.email — verify your contact email"
204204+ body := renderEmailVerification(data)
205205+ return m.sendTemplated(ctx, OpMailEmailVerification, "welcome", to, subject, body)
206206+}
207207+163208// ErrNoContactEmail is returned when a send was requested for a member
164209// who never supplied a contact mailbox. Callers should log a warning and
165210// continue — the user-visible operation (enrollment, approve, rotate)
···280325281326Docs: https://atmospheremail.com/docs
282327`, d.Domain)
328328+}
329329+330330+func renderFBLComplaint(d FBLComplaintData) string {
331331+ return fmt.Sprintf(`A feedback loop complaint was received for a member's sending domain.
332332+333333+Member: %s
334334+Domain: %s
335335+Provider: %s
336336+Type: %s
337337+Recipient: %s
338338+339339+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.
340340+341341+This notification was sent by the atmos.email relay to its configured operator forwarding address.
342342+`, d.MemberDID, d.SenderDomain, d.Provider, d.FeedbackType, d.RecipientDomain, d.Provider, d.SenderDomain)
343343+}
344344+345345+func renderEmailVerification(d EmailVerificationData) string {
346346+ return fmt.Sprintf(`Please verify the contact email address for your Atmosphere Mail domain %s.
347347+348348+Click the link below to confirm this is your email:
349349+350350+ %s
351351+352352+This link expires in 72 hours. If you did not enroll this domain with Atmosphere Mail, you can safely ignore this message.
353353+`, d.Domain, d.VerifyURL)
283354}
284355285356// DefaultOpMailSender returns a production-ready OpMailSendFunc that
+195
internal/relay/warmup.go
···11+package relay
22+33+import (
44+ "context"
55+ "fmt"
66+ "log"
77+ "strings"
88+ "time"
99+)
1010+1111+// WarmupSender builds and queues warmup emails on behalf of a member.
1212+// Bypasses rate limiting and suppression since these are operator-initiated
1313+// sends to known seed addresses.
1414+type WarmupSender struct {
1515+ seedAddresses []string
1616+ memberLookup func(ctx context.Context, did string) (*MemberWithDomains, error)
1717+ queue *Queue
1818+ operatorKeys *DKIMKeys
1919+ operatorDKIMDomain string
2020+ relayDomain string
2121+2222+ insertMessage func(ctx context.Context, did, from, to, msgID string) (int64, error)
2323+ incrSendCount func(ctx context.Context, did string)
2424+}
2525+2626+// WarmupConfig configures the warmup sender.
2727+type WarmupConfig struct {
2828+ SeedAddresses []string
2929+ MemberLookup func(ctx context.Context, did string) (*MemberWithDomains, error)
3030+ Queue *Queue
3131+ OperatorKeys *DKIMKeys
3232+ OperatorDKIMDomain string
3333+ RelayDomain string
3434+ InsertMessage func(ctx context.Context, did, from, to, msgID string) (int64, error)
3535+ IncrSendCount func(ctx context.Context, did string)
3636+}
3737+3838+func NewWarmupSender(cfg WarmupConfig) *WarmupSender {
3939+ return &WarmupSender{
4040+ seedAddresses: cfg.SeedAddresses,
4141+ memberLookup: cfg.MemberLookup,
4242+ queue: cfg.Queue,
4343+ operatorKeys: cfg.OperatorKeys,
4444+ operatorDKIMDomain: cfg.OperatorDKIMDomain,
4545+ relayDomain: cfg.RelayDomain,
4646+ insertMessage: cfg.InsertMessage,
4747+ incrSendCount: cfg.IncrSendCount,
4848+ }
4949+}
5050+5151+func (w *WarmupSender) SeedCount() int { return len(w.seedAddresses) }
5252+5353+// WarmupResult reports what happened for each seed address.
5454+type WarmupResult struct {
5555+ Sent int `json:"sent"`
5656+ Failed int `json:"failed"`
5757+ Errors []string `json:"errors,omitempty"`
5858+}
5959+6060+// SendBatch sends one warmup email to each seed address on behalf of the
6161+// given member DID. Returns the number sent and any per-recipient errors.
6262+func (w *WarmupSender) SendBatch(ctx context.Context, did string) (*WarmupResult, error) {
6363+ if len(w.seedAddresses) == 0 {
6464+ return nil, fmt.Errorf("no warmup seed addresses configured")
6565+ }
6666+6767+ member, err := w.memberLookup(ctx, did)
6868+ if err != nil {
6969+ return nil, fmt.Errorf("member lookup: %w", err)
7070+ }
7171+ if member == nil || len(member.Domains) == 0 {
7272+ return nil, fmt.Errorf("member %s not found or has no domains", did)
7373+ }
7474+7575+ domain := member.Domains[0]
7676+ from := "postmaster@" + domain.Domain
7777+7878+ result := &WarmupResult{}
7979+ for _, to := range w.seedAddresses {
8080+ msgID := fmt.Sprintf("<%d.warmup@%s>", time.Now().UnixNano(), w.relayDomain)
8181+ msg := buildWarmupMessage(from, to, msgID, domain.Domain)
8282+8383+ verpFrom := VERPReturnPath(did, to, w.relayDomain)
8484+8585+ raw := []byte(msg)
8686+ stamped := append([]byte("X-Atmos-Member-Did: "+did+"\r\n"), raw...)
8787+ stamped = PrependFeedbackID(stamped, "transactional", did, domain.Domain)
8888+8989+ signer := NewDualDomainSigner(domain.DKIMKeys, w.operatorKeys, domain.Domain, w.operatorDKIMDomain)
9090+ signed, err := signer.Sign(strings.NewReader(string(stamped)))
9191+ if err != nil {
9292+ result.Failed++
9393+ result.Errors = append(result.Errors, fmt.Sprintf("%s: DKIM sign: %v", to, err))
9494+ continue
9595+ }
9696+9797+ entryID := int64(0)
9898+ if w.insertMessage != nil {
9999+ id, err := w.insertMessage(ctx, did, from, to, msgID)
100100+ if err != nil {
101101+ log.Printf("warmup.insert_message: did=%s to=%s error=%v", did, to, err)
102102+ } else {
103103+ entryID = id
104104+ }
105105+ }
106106+ if w.incrSendCount != nil {
107107+ w.incrSendCount(ctx, did)
108108+ }
109109+110110+ if err := w.queue.Enqueue(&QueueEntry{
111111+ ID: entryID,
112112+ From: verpFrom,
113113+ To: to,
114114+ Data: signed,
115115+ MemberDID: did,
116116+ }); err != nil {
117117+ result.Failed++
118118+ result.Errors = append(result.Errors, fmt.Sprintf("%s: enqueue: %v", to, err))
119119+ continue
120120+ }
121121+122122+ result.Sent++
123123+ log.Printf("warmup.queued: did=%s to=%s msg_id=%s", did, to, msgID)
124124+ }
125125+126126+ return result, nil
127127+}
128128+129129+type warmupTemplate struct {
130130+ subject string
131131+ body string
132132+}
133133+134134+func warmupTemplates(domain string) []warmupTemplate {
135135+ return []warmupTemplate{
136136+ {
137137+ subject: "Re: setting up email for " + domain,
138138+ body: "Hi,\r\n\r\n" +
139139+ "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" +
140140+ "Let me know if you run into any issues or have questions about the setup.\r\n\r\n" +
141141+ "Best,\r\n" +
142142+ "Scott",
143143+ },
144144+ {
145145+ subject: "Quick note about " + domain,
146146+ body: "Hey,\r\n\r\n" +
147147+ "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" +
148148+ "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" +
149149+ "Thanks,\r\n" +
150150+ "Scott",
151151+ },
152152+ {
153153+ subject: domain + " is looking good",
154154+ body: "Hi,\r\n\r\n" +
155155+ "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" +
156156+ "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" +
157157+ "Cheers,\r\n" +
158158+ "Scott",
159159+ },
160160+ {
161161+ subject: "Checking in — " + domain,
162162+ body: "Hey,\r\n\r\n" +
163163+ "Just checking in on " + domain + ". The mail pipeline is healthy and I don't see any issues on our side.\r\n\r\n" +
164164+ "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" +
165165+ "Best,\r\n" +
166166+ "Scott",
167167+ },
168168+ {
169169+ subject: "All good with " + domain,
170170+ body: "Hi,\r\n\r\n" +
171171+ "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" +
172172+ "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" +
173173+ "Thanks,\r\n" +
174174+ "Scott",
175175+ },
176176+ }
177177+}
178178+179179+func buildWarmupMessage(from, to, msgID, domain string) string {
180180+ templates := warmupTemplates(domain)
181181+ idx := int(time.Now().Unix()/60) % len(templates)
182182+ t := templates[idx]
183183+184184+ return strings.Join([]string{
185185+ "From: " + from,
186186+ "To: " + to,
187187+ "Subject: " + t.subject,
188188+ "Message-ID: " + msgID,
189189+ "Date: " + time.Now().UTC().Format(time.RFC1123Z),
190190+ "MIME-Version: 1.0",
191191+ "Content-Type: text/plain; charset=utf-8",
192192+ "",
193193+ t.body,
194194+ }, "\r\n")
195195+}
+2
internal/relaystore/pending_notifications.go
···1515const (
1616 NotificationKindWelcome = "welcome"
1717 NotificationKindKeyRegenerated = "key_regenerated"
1818+ NotificationKindFBLComplaint = "fbl_complaint"
1919+ NotificationKindEmailVerification = "email_verification"
1820)
19212022// MaxNotificationAttempts is the cap at which a pending notification is
+217-38
internal/relaystore/store.go
···2323 StatusPending = "pending"
2424)
25252626+// CurrentTermsVersion is the date-based version of the Terms of Service
2727+// that enrolling members agree to. Bump this when the terms change
2828+// materially; the old value is preserved in member records so we know
2929+// which version each member accepted.
3030+const CurrentTermsVersion = "2026-04-23"
3131+2632// Message status constants.
2733const (
2834 MsgQueued = "queued"
···5157 // enrollments (operator vouches) and for members who later complete
5258 // atproto OAuth (Phase 2). Downstream consumers (labeler, trust scoring)
5359 // read this to distinguish weak vs strong identity proof.
5454- DIDVerified bool
5555- CreatedAt time.Time
5656- UpdatedAt time.Time
6060+ DIDVerified bool
6161+ TermsAcceptedAt time.Time
6262+ TermsVersion string
6363+ CreatedAt time.Time
6464+ UpdatedAt time.Time
5765}
58665967// AgeDays returns whole days since the member was created, floored at zero.
···8391// one pending enrollment at a time; creating a second replaces the first
8492// so re-running the wizard with a fresh token works cleanly.
8593type PendingEnrollment struct {
8686- Token string
8787- DID string
8888- Domain string
8989- ContactEmail string
9090- CreatedAt time.Time
9191- ExpiresAt time.Time
9494+ Token string
9595+ DID string
9696+ Domain string
9797+ ContactEmail string
9898+ TermsAccepted bool
9999+ CreatedAt time.Time
100100+ ExpiresAt time.Time
92101}
9310294103// MemberDomain represents a sending domain registered under a member DID.
···109118 // are skipped with a logged warning (operator-ping still fires, since
110119 // that uses the relay-configured operator-forward address).
111120 ContactEmail string
112112- CreatedAt time.Time
121121+ // EmailVerified indicates whether the member has proven ownership of
122122+ // ContactEmail by clicking a verification link. False until verified.
123123+ EmailVerified bool
124124+ CreatedAt time.Time
113125}
114126115127type Message struct {
···402414 }
403415 }
404416417417+ // Terms-of-service agreement tracking on members. Recorded at enrollment
418418+ // time so we have an audit trail of which version each member agreed to.
419419+ var hasTermsAcceptedAt int
420420+ _ = s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('members') WHERE name = 'terms_accepted_at'`).Scan(&hasTermsAcceptedAt)
421421+ if hasTermsAcceptedAt == 0 {
422422+ if _, err := s.db.Exec(`ALTER TABLE members ADD COLUMN terms_accepted_at TEXT NOT NULL DEFAULT ''`); err != nil {
423423+ return fmt.Errorf("add terms_accepted_at column: %v", err)
424424+ }
425425+ }
426426+ var hasTermsVersion int
427427+ _ = s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('members') WHERE name = 'terms_version'`).Scan(&hasTermsVersion)
428428+ if hasTermsVersion == 0 {
429429+ if _, err := s.db.Exec(`ALTER TABLE members ADD COLUMN terms_version TEXT NOT NULL DEFAULT ''`); err != nil {
430430+ return fmt.Errorf("add terms_version column: %v", err)
431431+ }
432432+ }
433433+434434+ // Terms acceptance flag on pending_enrollments, carried from enroll-start
435435+ // through to enroll-complete where it stamps the member record.
436436+ var hasPendingTerms int
437437+ _ = s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('pending_enrollments') WHERE name = 'terms_accepted'`).Scan(&hasPendingTerms)
438438+ if hasPendingTerms == 0 {
439439+ if _, err := s.db.Exec(`ALTER TABLE pending_enrollments ADD COLUMN terms_accepted INTEGER NOT NULL DEFAULT 0`); err != nil {
440440+ return fmt.Errorf("add terms_accepted to pending_enrollments: %v", err)
441441+ }
442442+ }
443443+444444+ // Email verification columns on member_domains. Tracks whether the
445445+ // member has proven ownership of their contact_email by clicking a
446446+ // link in a verification email. email_verified is 0/1 (SQLite bool),
447447+ // email_verify_token is the hex-encoded random token, and
448448+ // email_verify_expires is an RFC3339 expiry timestamp.
449449+ var hasEmailVerified int
450450+ _ = s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('member_domains') WHERE name = 'email_verified'`).Scan(&hasEmailVerified)
451451+ if hasEmailVerified == 0 {
452452+ if _, err := s.db.Exec(`ALTER TABLE member_domains ADD COLUMN email_verified INTEGER DEFAULT 0`); err != nil {
453453+ return fmt.Errorf("add email_verified column: %v", err)
454454+ }
455455+ }
456456+ var hasEmailVerifyToken int
457457+ _ = s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('member_domains') WHERE name = 'email_verify_token'`).Scan(&hasEmailVerifyToken)
458458+ if hasEmailVerifyToken == 0 {
459459+ if _, err := s.db.Exec(`ALTER TABLE member_domains ADD COLUMN email_verify_token TEXT DEFAULT ''`); err != nil {
460460+ return fmt.Errorf("add email_verify_token column: %v", err)
461461+ }
462462+ }
463463+ var hasEmailVerifyExpires int
464464+ _ = s.db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('member_domains') WHERE name = 'email_verify_expires'`).Scan(&hasEmailVerifyExpires)
465465+ if hasEmailVerifyExpires == 0 {
466466+ if _, err := s.db.Exec(`ALTER TABLE member_domains ADD COLUMN email_verify_expires TEXT DEFAULT ''`); err != nil {
467467+ return fmt.Errorf("add email_verify_expires column: %v", err)
468468+ }
469469+ }
470470+405471 // Relay-local events store (replaces Druid for Osprey rule-evaluation
406472 // events). Split into its own method so the migration lives next to
407473 // the RelayEvent model.
···489555490556func (s *Store) InsertMember(ctx context.Context, m *Member) error {
491557 _, err := s.db.ExecContext(ctx,
492492- `INSERT INTO members (did, status, suspend_reason, send_count, hourly_limit, daily_limit, did_verified, created_at, updated_at)
493493- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
558558+ `INSERT INTO members (did, status, suspend_reason, send_count, hourly_limit, daily_limit, did_verified, terms_accepted_at, terms_version, created_at, updated_at)
559559+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
494560 m.DID, m.Status, m.SuspendReason, m.SendCount, m.HourlyLimit, m.DailyLimit,
495561 boolToInt(m.DIDVerified),
562562+ formatTime(m.TermsAcceptedAt), m.TermsVersion,
496563 formatTime(m.CreatedAt), formatTime(m.UpdatedAt),
497564 )
498565 if err != nil {
···513580514581 if member != nil {
515582 _, err := tx.ExecContext(ctx,
516516- `INSERT INTO members (did, status, suspend_reason, send_count, hourly_limit, daily_limit, did_verified, created_at, updated_at)
517517- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
583583+ `INSERT INTO members (did, status, suspend_reason, send_count, hourly_limit, daily_limit, did_verified, terms_accepted_at, terms_version, created_at, updated_at)
584584+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
518585 member.DID, member.Status, member.SuspendReason, member.SendCount,
519586 member.HourlyLimit, member.DailyLimit,
520587 boolToInt(member.DIDVerified),
588588+ formatTime(member.TermsAcceptedAt), member.TermsVersion,
521589 formatTime(member.CreatedAt), formatTime(member.UpdatedAt),
522590 )
523591 if err != nil {
···547615// ListMembersWithDomains returns all members with their domain names in a single query.
548616func (s *Store) ListMembersWithDomains(ctx context.Context) ([]MemberWithDomainSummary, error) {
549617 rows, err := s.db.QueryContext(ctx,
550550- `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,
618618+ `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,
551619 COALESCE(GROUP_CONCAT(md.domain, ','), '')
552620 FROM members m
553621 LEFT JOIN member_domains md ON md.did = m.did
···562630 var result []MemberWithDomainSummary
563631 for rows.Next() {
564632 var mwd MemberWithDomainSummary
565565- var createdAt, updatedAt, domainCSV string
633633+ var createdAt, updatedAt, termsAcceptedAt, domainCSV string
566634 var didVerified int
567635 if err := rows.Scan(
568636 &mwd.DID, &mwd.Status, &mwd.SuspendReason, &mwd.SendCount,
569569- &mwd.HourlyLimit, &mwd.DailyLimit, &didVerified, &createdAt, &updatedAt, &domainCSV,
637637+ &mwd.HourlyLimit, &mwd.DailyLimit, &didVerified,
638638+ &termsAcceptedAt, &mwd.TermsVersion,
639639+ &createdAt, &updatedAt, &domainCSV,
570640 ); err != nil {
571641 return nil, fmt.Errorf("scan member with domains: %v", err)
572642 }
573643 mwd.DIDVerified = didVerified != 0
644644+ mwd.TermsAcceptedAt = parseTime(termsAcceptedAt)
574645 mwd.CreatedAt = parseTime(createdAt)
575646 mwd.UpdatedAt = parseTime(updatedAt)
576647 if domainCSV != "" {
···583654584655func (s *Store) GetMember(ctx context.Context, did string) (*Member, error) {
585656 row := s.db.QueryRowContext(ctx,
586586- `SELECT did, status, suspend_reason, send_count, hourly_limit, daily_limit, did_verified, created_at, updated_at
657657+ `SELECT did, status, suspend_reason, send_count, hourly_limit, daily_limit, did_verified, terms_accepted_at, terms_version, created_at, updated_at
587658 FROM members WHERE did = ?`, did,
588659 )
589660 return scanMember(row)
···591662592663func (s *Store) ListMembers(ctx context.Context) ([]Member, error) {
593664 rows, err := s.db.QueryContext(ctx,
594594- `SELECT did, status, suspend_reason, send_count, hourly_limit, daily_limit, did_verified, created_at, updated_at
665665+ `SELECT did, status, suspend_reason, send_count, hourly_limit, daily_limit, did_verified, terms_accepted_at, terms_version, created_at, updated_at
595666 FROM members ORDER BY created_at ASC`,
596667 )
597668 if err != nil {
···663734664735func scanMember(sc scanner) (*Member, error) {
665736 var m Member
666666- var createdAt, updatedAt string
737737+ var createdAt, updatedAt, termsAcceptedAt string
667738 var didVerified int
668739669740 err := sc.Scan(
670741 &m.DID, &m.Status, &m.SuspendReason, &m.SendCount,
671671- &m.HourlyLimit, &m.DailyLimit, &didVerified, &createdAt, &updatedAt,
742742+ &m.HourlyLimit, &m.DailyLimit, &didVerified,
743743+ &termsAcceptedAt, &m.TermsVersion,
744744+ &createdAt, &updatedAt,
672745 )
673746 if err == sql.ErrNoRows {
674747 return nil, nil
···678751 }
679752680753 m.DIDVerified = didVerified != 0
754754+ m.TermsAcceptedAt = parseTime(termsAcceptedAt)
681755 m.CreatedAt = parseTime(createdAt)
682756 m.UpdatedAt = parseTime(updatedAt)
683757 return &m, nil
···701775func (s *Store) GetMemberDomain(ctx context.Context, domain string) (*MemberDomain, error) {
702776 var d MemberDomain
703777 var createdAt string
778778+ var emailVerified int
704779 err := s.db.QueryRowContext(ctx,
705705- `SELECT domain, did, api_key_hash, dkim_rsa_privkey, dkim_ed_privkey, dkim_selector, forward_to, contact_email, created_at
780780+ `SELECT domain, did, api_key_hash, dkim_rsa_privkey, dkim_ed_privkey, dkim_selector, forward_to, contact_email, email_verified, created_at
706781 FROM member_domains WHERE domain = ?`, domain,
707707- ).Scan(&d.Domain, &d.DID, &d.APIKeyHash, &d.DKIMRSAPriv, &d.DKIMEdPriv, &d.DKIMSelector, &d.ForwardTo, &d.ContactEmail, &createdAt)
782782+ ).Scan(&d.Domain, &d.DID, &d.APIKeyHash, &d.DKIMRSAPriv, &d.DKIMEdPriv, &d.DKIMSelector, &d.ForwardTo, &d.ContactEmail, &emailVerified, &createdAt)
708783 if err == sql.ErrNoRows {
709784 return nil, nil
710785 }
711786 if err != nil {
712787 return nil, fmt.Errorf("get member domain: %v", err)
713788 }
789789+ d.EmailVerified = emailVerified != 0
714790 d.CreatedAt = parseTime(createdAt)
715791 return &d, nil
716792}
717793718794func (s *Store) ListMemberDomains(ctx context.Context, did string) ([]MemberDomain, error) {
719795 rows, err := s.db.QueryContext(ctx,
720720- `SELECT domain, did, api_key_hash, dkim_rsa_privkey, dkim_ed_privkey, dkim_selector, forward_to, contact_email, created_at
796796+ `SELECT domain, did, api_key_hash, dkim_rsa_privkey, dkim_ed_privkey, dkim_selector, forward_to, contact_email, email_verified, created_at
721797 FROM member_domains WHERE did = ? ORDER BY created_at ASC`, did,
722798 )
723799 if err != nil {
···729805 for rows.Next() {
730806 var d MemberDomain
731807 var createdAt string
732732- if err := rows.Scan(&d.Domain, &d.DID, &d.APIKeyHash, &d.DKIMRSAPriv, &d.DKIMEdPriv, &d.DKIMSelector, &d.ForwardTo, &d.ContactEmail, &createdAt); err != nil {
808808+ var emailVerified int
809809+ if err := rows.Scan(&d.Domain, &d.DID, &d.APIKeyHash, &d.DKIMRSAPriv, &d.DKIMEdPriv, &d.DKIMSelector, &d.ForwardTo, &d.ContactEmail, &emailVerified, &createdAt); err != nil {
733810 return nil, fmt.Errorf("scan member domain: %v", err)
734811 }
812812+ d.EmailVerified = emailVerified != 0
735813 d.CreatedAt = parseTime(createdAt)
736814 domains = append(domains, d)
737815 }
···761839}
762840763841// UpdateDomainContactEmail atomically replaces the contact_email for a
764764-// registered domain. Called from the /account self-service flow and
842842+// registered domain and resets email verification state (email_verified=0,
843843+// token/expiry cleared). Called from the /account self-service flow and
765844// from back-fill tooling. Returns an error if the domain isn't
766845// registered. Empty contactEmail clears the field.
767846func (s *Store) UpdateDomainContactEmail(ctx context.Context, domain, contactEmail string) error {
768847 res, err := s.db.ExecContext(ctx,
769769- `UPDATE member_domains SET contact_email = ? WHERE domain = ?`,
848848+ `UPDATE member_domains SET contact_email = ?, email_verified = 0, email_verify_token = '', email_verify_expires = '' WHERE domain = ?`,
770849 contactEmail, domain,
771850 )
772851 if err != nil {
···782861 return nil
783862}
784863864864+// --- Email verification ---
865865+866866+// SetEmailVerifyToken stores a verification token and its expiry for the
867867+// given domain, and resets email_verified to 0. Called when a verification
868868+// email is about to be sent (new enrollment or contact_email change).
869869+func (s *Store) SetEmailVerifyToken(ctx context.Context, domain, token string, expiresAt time.Time) error {
870870+ res, err := s.db.ExecContext(ctx,
871871+ `UPDATE member_domains SET email_verified = 0, email_verify_token = ?, email_verify_expires = ? WHERE domain = ?`,
872872+ token, formatTime(expiresAt), domain,
873873+ )
874874+ if err != nil {
875875+ return fmt.Errorf("set email verify token: %v", err)
876876+ }
877877+ n, err := res.RowsAffected()
878878+ if err != nil {
879879+ return fmt.Errorf("set email verify token rows: %v", err)
880880+ }
881881+ if n == 0 {
882882+ return fmt.Errorf("domain %q not registered", domain)
883883+ }
884884+ return nil
885885+}
886886+887887+// VerifyEmailByToken looks up a verification token across all domains,
888888+// checks that it hasn't expired, marks the domain as email_verified=1,
889889+// and clears the token. Returns the domain name on success so callers
890890+// can render a confirmation page. Returns an error if the token is not
891891+// found or has expired.
892892+func (s *Store) VerifyEmailByToken(ctx context.Context, token string) (string, error) {
893893+ if token == "" {
894894+ return "", fmt.Errorf("empty verification token")
895895+ }
896896+ var domain, expiresAtStr string
897897+ err := s.db.QueryRowContext(ctx,
898898+ `SELECT domain, email_verify_expires FROM member_domains WHERE email_verify_token = ?`,
899899+ token,
900900+ ).Scan(&domain, &expiresAtStr)
901901+ if err == sql.ErrNoRows {
902902+ return "", fmt.Errorf("verification token not found")
903903+ }
904904+ if err != nil {
905905+ return "", fmt.Errorf("verify email lookup: %v", err)
906906+ }
907907+ expiresAt := parseTime(expiresAtStr)
908908+ if !expiresAt.IsZero() && time.Now().UTC().After(expiresAt) {
909909+ // Clear the expired token so it can't be retried.
910910+ s.db.ExecContext(ctx,
911911+ `UPDATE member_domains SET email_verify_token = '', email_verify_expires = '' WHERE domain = ?`,
912912+ domain,
913913+ )
914914+ return "", fmt.Errorf("verification token expired")
915915+ }
916916+ _, err = s.db.ExecContext(ctx,
917917+ `UPDATE member_domains SET email_verified = 1, email_verify_token = '', email_verify_expires = '' WHERE domain = ?`,
918918+ domain,
919919+ )
920920+ if err != nil {
921921+ return "", fmt.Errorf("mark email verified: %v", err)
922922+ }
923923+ return domain, nil
924924+}
925925+926926+// IsEmailVerified returns whether the contact_email for the given domain
927927+// has been verified. Returns false for unknown domains.
928928+func (s *Store) IsEmailVerified(ctx context.Context, domain string) (bool, error) {
929929+ var verified int
930930+ err := s.db.QueryRowContext(ctx,
931931+ `SELECT email_verified FROM member_domains WHERE domain = ?`,
932932+ domain,
933933+ ).Scan(&verified)
934934+ if err == sql.ErrNoRows {
935935+ return false, nil
936936+ }
937937+ if err != nil {
938938+ return false, fmt.Errorf("is email verified: %v", err)
939939+ }
940940+ return verified != 0, nil
941941+}
942942+943943+// ResetEmailVerification sets email_verified=0 and clears the token
944944+// for a domain. Called when contact_email changes and the member needs
945945+// to re-verify.
946946+func (s *Store) ResetEmailVerification(ctx context.Context, domain string) error {
947947+ _, err := s.db.ExecContext(ctx,
948948+ `UPDATE member_domains SET email_verified = 0, email_verify_token = '', email_verify_expires = '' WHERE domain = ?`,
949949+ domain,
950950+ )
951951+ if err != nil {
952952+ return fmt.Errorf("reset email verification: %v", err)
953953+ }
954954+ return nil
955955+}
956956+785957// GetMemberByDomain returns the member and domain record for a given domain name.
786958// Returns (nil, nil, nil) if the domain is not found.
787959func (s *Store) GetMemberByDomain(ctx context.Context, domain string) (*Member, *MemberDomain, error) {
788960 var m Member
789961 var d MemberDomain
790790- var mCreatedAt, mUpdatedAt, dCreatedAt string
791791- var didVerified int
962962+ var mCreatedAt, mUpdatedAt, mTermsAcceptedAt, dCreatedAt string
963963+ var didVerified, emailVerified int
792964793965 err := s.db.QueryRowContext(ctx,
794794- `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,
795795- 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
966966+ `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,
967967+ 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
796968 FROM member_domains d JOIN members m ON d.did = m.did
797969 WHERE d.domain = ?`, domain,
798970 ).Scan(
799799- &m.DID, &m.Status, &m.SuspendReason, &m.SendCount, &m.HourlyLimit, &m.DailyLimit, &didVerified, &mCreatedAt, &mUpdatedAt,
800800- &d.Domain, &d.DID, &d.APIKeyHash, &d.DKIMRSAPriv, &d.DKIMEdPriv, &d.DKIMSelector, &d.ForwardTo, &d.ContactEmail, &dCreatedAt,
971971+ &m.DID, &m.Status, &m.SuspendReason, &m.SendCount, &m.HourlyLimit, &m.DailyLimit, &didVerified,
972972+ &mTermsAcceptedAt, &m.TermsVersion,
973973+ &mCreatedAt, &mUpdatedAt,
974974+ &d.Domain, &d.DID, &d.APIKeyHash, &d.DKIMRSAPriv, &d.DKIMEdPriv, &d.DKIMSelector, &d.ForwardTo, &d.ContactEmail, &emailVerified, &dCreatedAt,
801975 )
802976 if err == sql.ErrNoRows {
803977 return nil, nil, nil
···807981 }
808982809983 m.DIDVerified = didVerified != 0
984984+ m.TermsAcceptedAt = parseTime(mTermsAcceptedAt)
810985 m.CreatedAt = parseTime(mCreatedAt)
811986 m.UpdatedAt = parseTime(mUpdatedAt)
987987+ d.EmailVerified = emailVerified != 0
812988 d.CreatedAt = parseTime(dCreatedAt)
813989 return &m, &d, nil
814990}
···17391915 // constraint), then inserts. Token is the PK so we don't need a
17401916 // separate conflict target for it — a collision there is astronomical.
17411917 _, err := s.db.ExecContext(ctx,
17421742- `INSERT OR REPLACE INTO pending_enrollments (token, did, domain, contact_email, created_at, expires_at)
17431743- VALUES (?, ?, ?, ?, ?, ?)`,
17441744- p.Token, p.DID, p.Domain, p.ContactEmail, formatTime(p.CreatedAt), formatTime(p.ExpiresAt),
19181918+ `INSERT OR REPLACE INTO pending_enrollments (token, did, domain, contact_email, terms_accepted, created_at, expires_at)
19191919+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
19201920+ p.Token, p.DID, p.Domain, p.ContactEmail, boolToInt(p.TermsAccepted),
19211921+ formatTime(p.CreatedAt), formatTime(p.ExpiresAt),
17451922 )
17461923 if err != nil {
17471924 return fmt.Errorf("create pending enrollment: %v", err)
···17571934func (s *Store) GetPendingEnrollment(ctx context.Context, token string) (*PendingEnrollment, error) {
17581935 var p PendingEnrollment
17591936 var createdAt, expiresAt string
19371937+ var termsAccepted int
17601938 err := s.db.QueryRowContext(ctx,
17611761- `SELECT token, did, domain, contact_email, created_at, expires_at
19391939+ `SELECT token, did, domain, contact_email, terms_accepted, created_at, expires_at
17621940 FROM pending_enrollments WHERE token = ?`, token,
17631763- ).Scan(&p.Token, &p.DID, &p.Domain, &p.ContactEmail, &createdAt, &expiresAt)
19411941+ ).Scan(&p.Token, &p.DID, &p.Domain, &p.ContactEmail, &termsAccepted, &createdAt, &expiresAt)
17641942 if err == sql.ErrNoRows {
17651943 return nil, nil
17661944 }
17671945 if err != nil {
17681946 return nil, fmt.Errorf("get pending enrollment: %v", err)
17691947 }
19481948+ p.TermsAccepted = termsAccepted != 0
17701949 p.CreatedAt = parseTime(createdAt)
17711950 p.ExpiresAt = parseTime(expiresAt)
17721951 return &p, nil