Accounts provisionable for an active domain
closedDeliverable
Make accounts fully provisionable once a domain is active and its PDS
instance exists: mail.createAccount provisions the Stalwart mailbox and
persists the protocol-TID ↔ Stalwart-id mapping, plus app-password
issue/list/remove. No space or permissioned records.
Current state
ProvisionAccountWithStatus (internal/appview/provision.go) and
internal/account already do the Stalwart+DB work and map a UNIQUE
collision to ErrAccountExists. This task verifies and hardens the path for
an active saga-provisioned domain, including the pending-ATProto-account
status handling that the account/login issues depend on.
Non-goals
- Pending ATProto account activation / password setup (
31b625f). at.sovrn.mail.accountspace records (Spaces Stage B).
Acceptance
- For an
activedomain: create/list/remove account succeeds; duplicates are idempotent/ErrAccountExists; app passwords work end-to-end. - An account created against a saga-provisioned domain is usable via the existing app-password/IMAP flows.
3 Comments
Implementation Plan — Initial RFC 9553 contact card on account creation + verify/harden active-domain provisioning
Investigation findings (Stalwart 0.16.19)
x:Account/set;UserAccounthas no card member. Contact data is a separate standard JMAP object,ContactCard/set, under capabilityurn:ietf:params:jmap:contacts, serialized as RFC 9553 JSContact (via thecalcard0.3.12 crate).name.components(kind: given|surname),titles,organizations, andonlineServices(service/user/uri). Stalwart indexesonlineServicesfor search.defaultis auto-created lazily on first DAV fetch (AddressBook settings defaultdefaultHrefName="default"), so the flow isAddressBook/get(triggers creation, returns id) →ContactCard/setwithaddressBookIds.accountIdis the target user’s numeric Stalwart account id (already stored asstore.Account.StalwartRef); authorization needsPermission::Impersonateor a shared ACL. The control plane authenticates as the bootstrap admin (full admin), so this works today. The least-privilege service role (docs/05 T3) must grant contacts/impersonate.stalwart.Clientcan send it viaNewRequest(CoreCapability, contactsURN)+Invoke("ContactCard/set", …)+Send.Locked decisions
onlineServicesentry when the owner handle is known;newAccountuses the placeholder DID (did:plc:pending:<handle>), later rewritten by31b625fat activation.Task 1 —
internal/contact: address-book lookup + card create (TDD)internal/contact/card.go,internal/contact/card_test.go.Card,OnlineService,Atmosphere(handle,did),EnsureAddressBook(ctx,c,accountID),CreateCard(ctx,c,accountID,card).stalwart.NewRequest(ContactsCapability)+Invoke+Send; payload@type:Card,addressBookIds:{id:true},name.componentsgiven/surname,titles,organizations,onlineServices.httptestJMAP double asserting request JSON.Task 2 — Lexicon input + regenerate
lexicons/at/sovrn/mail/defs.json: addcontactCard+onlineServicedefs.lexicons/at/sovrn/mail/createAccount.json: add optionalcontactref.go generate ./api/sovrn; expectMailDefs_ContactCard,MailDefs_OnlineService,MailCreateAccount_Input.Contact.Task 3 — Thread details through provisioning (TDD)
internal/appview/provision.go: addAccountContact,OnlineServiceInput, andProvisionAccountWithDetails(...);ProvisionAccountWithStatusdelegates with zero details.store.CreateAccount, build card (Atmosphere prepended when handle known), callcontact.CreateCard; on errorslog.Warnand still return success.Task 4 — Wire handlers
internal/appview/mail_create_account.go: pass handle on newAccount/handle branches; mapin.Contact.internal/appview/ui/handler.go: pass handle only (form fields deferred).Task 5 — Verify/harden active-domain provisioning (acceptance)
ErrAccountExists(409) with no second row; pending persisted + listed; AssignedError mapping.internal/integration/account_provision_test.go: domain.create (service→active) → create/list/remove → duplicate conflict → app-password IMAP usability.Task 6 — Docs + bug hygiene
docs/05-stalwart-integration.md: contact-cards subsection.Verification
Assumption
JSContact
onlineServices[].useris@+ handle, matching the example (@alice.at.domain.com).Implementation complete (backend) — ready for review
Backend for the initial RFC 9553 contact card + account-provisioning verification is implemented on jj change stack ending
a6c274d49e0d. Not closing pending your review.What shipped
internal/contact(new) —EnsureAddressBook(AddressBook/get, prefersisDefault, triggers Stalwart’s lazy default-book creation) andCreateCard(ContactCard/set, RFC 9553 JSContact).Atmosphere(handle,did)buildsservice:"Atmosphere",user:"@"+handle,uri:"at://"+did. Errors arecontact:-wrapped; blank members are trimmed/omitted andCard.IsEmptymirrors the renderer.Lexicon —
at.sovrn.mail.createAccountgains an optionalcontactobject (MailDefs_ContactCard:givenName,surname,titles[],organizations[],onlineServices[]). Generated types regenerated via lexgen (idempotent).Provisioning —
internal/appview.ProvisionAccountWithDetails(...)writes the card best-effort after the mailbox + DB row: failures areslog.Errorand never fail account creation.ProvisionAccountWithStatusunchanged for existing callers.newAccountusesdid:plc:pending:<handle>(to be rewritten by31b625f); bare-DID owners get no Atmosphere entry; explicit-handle paths are lowercased for the card.Handlers — XRPC and UI map the owner handle (and, for XRPC, the full
contactinput) intoAccountContact.Hardening fix (found during review) — the XRPC
newAccountbranch authorized after calling the PDS provisioner;RequireTenantOwnernow runs before anyPreviewHandle/Provisioncall, with a non-owner test proving zero provisioner calls.Tests — unit coverage in
internal/contactandinternal/appview(request-shape, nil/blank handling, non-fatal card failure, cross-tenant vs same-tenant duplicate). IntegrationTestIntegrationAccountProvisioning: active-domain create (newAccount→pending, DID→active)/list/remove, duplicate→HTTP 409Exists, app-password→IMAP, and liveContactCard/getassertions for both cards (Atmosphere for the pending mailbox; names/titles/orgs/Mastodon for the explicit-contact mailbox).Verification
go build ./...,go test ./internal/contact ./internal/appview ./internal/appview/ui— pass.go generate ./api/sovrnidempotent;go vetclean.SOVRN_INTEGRATION=0; harness not running) — needs a run against the live harness to exercise the live Stalwart/DKIM/IMAP assertions.Decisions followed
Card best-effort/non-fatal; always create with Atmosphere when a handle is known; placeholder DID for
newAccount; omit Atmosphere for bare-DID owners;useruses the@handleform from the example.Deferred / notes
c83864d(under this issue) adds the form inputs; today the UI passes only the handle.verifying(matches “add mailboxes immediately”); not changed. Flag if you want creation rejected until active.UNIQUE constraintstring.api/sovrn/domaindefs.gocomment reflow (lexgen canonical output).UI half landed in c83864d (jj change zurxussy): /domains/{id}/users/new creates the account and the initial card. Added appview.ProvisionAccountWithContact so the UI can warn when a card write fails; ProvisionAccountWithDetails behaviour is unchanged.