Cloud sign-up, sign-in and first run
How a customer of Pylota Mail Cloud goes from the pricing page to a working agent inbox. It covers how they sign up and sign in, how they pay, which screen they land on, and the first-run checklist. It closes open point 6 of Console and workspaces and extends that design. Money is in Plans, metering and billing.
| Requirements | FR-CON-8 to FR-CON-13 (PRD) |
| Edge cases | W20–W34 |
| Code | crates/worker/src/console/{signup.rs, oauth.rs, totp.rs, landing.rs, onboarding.rs, pages/overview.rs}; crates/core/src/totp.rs (RFC 6238 codes, pure) |
| Tables | D1 users (new columns), oauth_identities, oauth_states, waitlist (§11) |
1. Who signs in where
| Person | How they get access | Where they work |
|---|---|---|
| Cloud customer (a developer or a team buying Pylota Mail) | Self-serve sign-up, this page | The console on Pylota Mail Cloud |
| Teammate of a Cloud customer | An invitation (Invitations) | The same console, in the inviter’s workspace |
| Self-hoster | pmail setup --owner-email creates the first owner (Console) | The console on their own deployment |
| Pylota car-rental operator | Never signs in here. Pylota’s backend creates their workspace with a platform key | Inside the Pylota app, which reads and acts on mail through the API |
Self-serve sign-up exists only where PM_SIGNUP is waitlist or open (Cloud). It is closed by
default, so a self-hosted deployment has no public sign-up unless its operator turns it on.
2. Hostnames
Decided on 2026-10-09: TREFT LTD bought pylotamail.com on Cloudflare, and one zone serves the whole Cloud
product. The shared mail domain has to be a zone apex, because catch-all routing exists only at an apex.
Using pylota.io would mix agent mail with Pylota’s own sign-in mail and its booking wildcard.
| Host | Serves | Notes |
|---|---|---|
pylotamail.com (apex) and www.pylotamail.com | The landing page and docs (the assets-only site Worker, site/wrangler.jsonc), and the shared mail domain, PM_PLATFORM_DOMAIN | Addresses like bookings.brightwell@pylotamail.com. The site adds only web records (Workers Custom Domains); the mail records (MX, SPF and DKIM TXT, _dmarc) are separate records that pmail setup writes, so the two never collide. Sign-up buttons link to app.pylotamail.com |
app.pylotamail.com | The console, PM_CONSOLE_HOST | Same Worker as the API. Only console routes answer on this host |
api.pylotamail.com | REST API, MCP, signed links (/v1/links/*), /hooks/*, the Stripe webhook (/billing/stripe/webhook), /health, PM_API_HOST | No cookies are ever set or read on this host |
Pylota’s own car-rental operators are tenants of the same deployment with billing.mode = exempt, so there is
one service to run. Amazon SES for this deployment runs in eu-west-2 (London), also decided on 2026-10-09.
PM_CONSOLE_HOST defaults to PM_API_HOST, so a self-hosted deployment keeps one hostname. When the two
differ, the router answers console paths only on the console host and API paths only on the API host;
everything else gets 404. That keeps session cookies off the API, and API keys out of browser history.
3. Sign-in methods
| Method | Cloud | Self-hosted default | Notes |
|---|---|---|---|
| Email link or six-digit code | On | On | As in Sign-in |
| Continue with Google | On | Off (needs PM_OAUTH_GOOGLE_CLIENT_ID and secret) | OpenID Connect, scopes openid email profile only |
| Continue with GitHub | On | Off (needs PM_OAUTH_GITHUB_CLIENT_ID and secret) | OAuth app, scopes read:user user:email |
| Two-step verification (authenticator app) | Optional per person; a workspace can require it | Same | TOTP (§5) |
| Passkeys | Not in v1.0 | – | They need browser JavaScript, and the console has none (FR-CON-1). Planned for v1.1 |
| SAML or OIDC single sign-on | Not in v1.0 | – | A candidate for a future Enterprise plan |
There is no password anywhere. Every method ends in the same session creation as the email method, with the same cookie, lifetimes and re-authentication rules (Sessions).
4. Google and GitHub
Both flows are server-side redirects. They need no JavaScript.
GET /console/oauth/{provider}/start?intent={sign_in|sign_up}&next={path}&plan={plan}. The Worker creates anoauth_statesrow valid for 10 minutes. The row holds a keyed hash of a 32-bytestate(under the currentlinkkey, whose kid is stored askey_kid), a PKCE verifier sealed underPM_MASTER_KEY, anonce(Google), and the validatednextandplan. It also sets__Host-pm_oauth(HttpOnly, Secure, SameSite=Lax, Path=/, 10 minutes) to a random value whose keyed hash is stored in the row, binding the flow to this browser. It then redirects to the provider with the exactredirect_urihttps://{PM_CONSOLE_HOST}/console/oauth/{provider}/callback,state,code_challenge(S256) and the scopes above.GET /console/oauth/{provider}/callback. The handler requires thestaterow to exist, be unexpired and unused, and match the__Host-pm_oauthcookie. It marks the row used, then exchanges thecodewith the PKCE verifier at the token endpoint (W20).- Google. The ID token comes straight from Google’s token endpoint over TLS, so TLS server validation
may stand in for checking its signature (OpenID Connect Core 1.0, §3.1.3.7, step 6). The handler still
checks
iss(https://accounts.google.comoraccounts.google.com),aud= the client ID,exp,nonce, andemail_verified = true. The subject is thesubclaim. - GitHub.
GET https://api.github.com/usergives the numericid, which is the subject.GET /user/emailsgives the address markedprimaryandverified. If there is none, the flow is refused with a page telling the person to verify an email address on GitHub (W21). - Find or create the person. When the flow started from an invitation link, the verified address
must first equal the invited address. Otherwise the flow is refused, nothing is created or linked, and
the invitation stays pending (W23). Then:
oauth_identitieshas(provider, subject)→ that user; set itslast_used_at = now.- Otherwise a
usersrow with the verified email exists → link: insertoauth_identities. The same person can then use any method (W22). - Otherwise, if sign-up is open or a pending invitation exists for that verified address, create the user (§6). If neither, show the “no workspace yet” page; no account is created (W32).
- If the person has two-step verification, ask for it (§5). Then create the session and route (§7).
intent selects the page shown when no account matches: sign_up continues to workspace creation,
sign_in shows “no workspace yet” (W32). Re-authentication never uses OAuth: it is an
emailed code (Console › Re-authentication). /console/settings/security
lists the person’s linked providers with the address each was linked with (email_at_link) and when it
was last used (last_used_at).
Provider endpoints and claim names must be re-read from Google’s and GitHub’s current documentation when
M24 is built. Errors from a provider (error=access_denied, timeouts) show a page with a “try another way”
link. They never reveal whether an account exists.
5. Two-step verification
- Enrol at
/console/settings/security. It needs re-authentication. The page shows a QR code rendered on the server as an inline SVG (theqrcodecrate, pure Rust) and the base32 secret as text. The person confirms with a current code. The secret (20 random bytes) is sealed underPM_MASTER_KEYinusers.totp_sealed, andtotp_enabled_atis set in the same statement. “Enrolled” meanstotp_enabled_at IS NOT NULL: the sign-in step and the workspace requirement read it, and/console/settings/securityshows the date. - Codes follow RFC 6238: HMAC-SHA1, 30-second step, six digits, one step of clock drift either way. A
code is refused if it was already used in its step. Attempts are limited to 5 a minute per person, and
10 failures in a row lock two-step sign-in for 15 minutes. The counters are columns of
users(totp_window_start,totp_window_count,totp_failures,totp_locked_until, §11); a success resetstotp_failures. - Recovery codes. Ten codes of 10 characters (Crockford base32) are shown once at enrolment, and each
works once. Generating new ones invalidates the old (W28). They are stored sealed:
users.recovery_codes_sealedis apm1envelope underPM_MASTER_KEYof[{ "hash": SHA-256(code), "used_at": null }]. They are not keyed hashes under thelinkkeyring, because a link key is deleted 7 days after rotation and recovery codes live for months. Thepmail secrets rotate-masterre-seal sweep covers them, as it coversusers.totp_sealed. - When it is asked for. After any first factor (link, code, Google or GitHub), before the session is created. It is also asked for at re-authentication when enrolled.
- Workspace requirement. An owner can set
require_two_factorin workspace settings. Pylota Mail Cloud recommends it for Team workspaces. A member without two-step verification who opens that workspace goes to enrolment first (W27). The API is unaffected: keys are not people. - Turning it off needs re-authentication with a current code. It sets
totp_sealed,totp_enabled_at,totp_last_stepandrecovery_codes_sealedtoNULL, emails the person and writes an audit row.
6. Sign-up
6.1 Before launch: the waitlist
With PM_SIGNUP=waitlist, the landing page’s “Get early access” buttons go to
https://app.pylotamail.com/console/waitlist?plan={plan}. The person enters an email address and gets a
confirmation link (double opt-in, using the same token machinery as sign-in). A confirmed address goes into
waitlist with the plan of interest. No account is created.
The operator invites people in batches with pmail waitlist invite --count 50 [--plan P], which calls the
platform API:
| Request | POST /v1/platform/waitlist/invite, body { "count": 50, "plan": null }. count is 1–500; plan filters by plan of interest (null: any) |
| Permission | platform:ops; audit action waitlist.invite |
| Response | 200 { "invited": 50, "waiting": 262 } |
| Effect | Invites the oldest confirmed, uninvited entries. Each gets a normal sign-up link valid for 7 days, which works while PM_SIGNUP is waitlist. Its token is stored only as a keyed hash under the current link key (waitlist.invite_token_hash, with the kid in key_kid), like an invitation |
An address is written to waitlist only when its confirmation link is used (confirmed_at), so there are
no unconfirmed entries: an unused confirmation token simply expires after 10 minutes, like a sign-in
token. Invitations go to the oldest confirmed_at first. Entries are deleted 30 days after invitation.
6.2 After launch: open sign-up
With PM_SIGNUP=open, the landing CTAs go to https://app.pylotamail.com/console/sign-up?plan={free|developer|team}.
An unknown plan value means free.
- Choose a method. “Continue with Google”, “Continue with GitHub”, or an email address. A checkbox
accepts the Terms of Service, the Privacy Policy and the Data Processing Addendum (
PM_TERMS_URL,PM_PRIVACY_URL,PM_DPA_URL). It is required; the version (PM_TERMS_VERSION) and the time are stored on the user. - Prove the address. With email, the account is created only when the link or code is used, so there
are never unverified accounts. With Google or GitHub, the provider’s verified address is used.
Addresses on the built-in list of disposable-mail domains (it ships with each release) or on a domain in
PM_SIGNUP_BLOCKED_DOMAINSare refused before any mail is sent (W29). - Create the workspace (
/console/workspaces/new), shown when the person has no workspace and no pending invitation. The fields are the workspace name, the address suffix (pre-filled from the name, for example.brightwell, with the resulting example address shown under it) and the time zone. A taken suffix returns the form withsuffix_taken(W33). On success the tenant is created on the Free plan with this person as owner, andusers.last_tenant_idis set. - Pay, when a paid plan was chosen. The owner goes straight to Stripe Checkout for that plan (Billing › Checkout). Coming back from Checkout is §9. Cancelling Checkout lands on the Overview, on Free, with the banner “Finish upgrading to Developer” (W24).
- Land on the Overview with the first-run checklist (§8).
7. Where people land
After any successful sign-in (and two-step verification), the first matching row decides:
| Situation | Lands on |
|---|---|
A valid next was carried through sign-in: a relative path starting with /console/, with no //, no backslash and no scheme (W31) | That page |
| A pending invitation exists for this address | Accept the invitation, then that workspace’s Overview |
| No workspace, and sign-up is open | Create your workspace (§6.2) |
| No workspace, and sign-up is closed or waitlist | “No workspace yet”, explaining how to be invited |
| The target workspace requires two-step verification and the person has none | Enrol two-step verification, then continue |
| One workspace | Its Overview |
| Several workspaces | The last one used (users.last_tenant_id); if that is gone, the workspace picker |
8. The Overview: the screen people land on
/console is the workspace home. Viewers see the same page without action buttons.
Frame. A header with the workspace switcher, a “Test” badge for test tenants, the plan name and the user menu (settings, security, sign out). A left navigation, in this order: Overview, Inboxes, Search, Quarantine, Domains, Webhooks, API keys, Connect, Members, Plan and usage, Audit log, Settings.
Body, top to bottom:
-
Banners, most urgent first, each with one action:
- payment failed, with the grace end date and “Update payment method”;
- an allowance used up (“Sends are paused until 1 Nov. Add 1,000 sends for £1 or upgrade”);
- a domain
failingorsuspended(“Sending from bookings@brightwell.example uses your Pylota Mail address until the DNS is fixed”); - an identity paused for bounces or complaints;
- two-step verification required but missing;
- your notification emails paused after a bounce or complaint, with “Confirm your address” (Notifications § 5).
-
First-run checklist, until its required steps are done (below).
-
Needs a person. The actions that only a person should take, each linking to the screen that resolves it:
- quarantined messages waiting for review (count, plus the five oldest with their reasons);
- sends whose outcome is
uncertainand must be resolved; - domains with issues to fix;
- webhook endpoints that are failing or disabled;
- invitations about to expire.
The daily “needs a person” email reads the same counts (Notifications).
-
Usage. A meter per allowance (inboxes, sends, triage analyses, custom domains, storage, seats) from
GET /v1/usage, with the reset date and a link to Plan and usage. -
Inboxes. Per identity, for the last 24 hours: received, sent, waiting for a reply, unread. Each row opens the inbox.
-
Recent activity. The last 20 events of the workspace (the same events webhooks receive), as one line each.
On a deployment with PM_BILLING=off, items 1 (billing banners) and 4 (plan limits) show usage only.
First-run checklist
Each step’s state is worked out from real data on every render, never stored, so it cannot drift. Only
“dismiss the checklist” is stored (tenants.onboarding_dismissed_at), and it is offered once the required
steps are done: the “Dismiss” button (owners and admins) posts to a console handler that sets the column
to the current time, and the Overview render reads it and leaves the checklist out while it is set.
| Step | Required | Done when | Screen |
|---|---|---|---|
| Create your first inbox | Yes | The workspace has an identity | /console/inboxes/new: name it and see its address, for example bookings.brightwell@pylotamail.com |
| Send it a test email | Yes | An inbound message exists | The address with a copy-friendly box, and a “Check for email” button that reloads the step. It reports only that a message arrived |
| Create an API key | Yes | A key exists | /console/keys/new. The secret is shown once |
| Connect your agent | Yes | A workspace key made an authenticated API or MCP request in the last 7 days (keys.last_used_at) | /console/connect: the claude mcp add line, .mcp.json, a curl request and pmail login, with the key ID filled in (never the secret) |
| Add a webhook | No | An endpoint returned 2xx to a test event | /console/webhooks |
| Connect your own domain | No | A domain is healthy | /console/domains/new, the method chooser from Domains on any DNS host |
| Invite your team | No (Team plan only) | The workspace has a second member | /console/members |
Compared with goshen-email’s guided setup, the checklist adds the “Connect your agent” proof, the domain method chooser and team invitations. The “Needs a person” queue below it is new, and turns the human approvals in the product promise into a daily task list.
9. Coming back from Checkout
Stripe redirects to /console/plan/return?session_id={CHECKOUT_SESSION_ID}.
- The handler retrieves the Checkout Session from Stripe with
PM_STRIPE_SECRET_KEY. It requires the session’s customer to equal this workspace’sbilling_accounts.stripe_customer_id. Otherwise it shows a neutral “Nothing to show” page and changes nothing (W26). - Stripe webhooks are the only source of plan state (FR-BILL). If the webhook has already changed the plan, the page says “You’re on Team” and links to the Overview.
- If it has not, the page says “Confirming your payment” and reloads itself with
<meta http-equiv="refresh" content="3">(no JavaScript), at most 7 times. After that it says “Payment received; your plan updates within a minute” and links to the Overview. The Overview shows the same message until the webhook arrives (W25).
10. Abuse and safety on Cloud
| Risk | Control |
|---|---|
| Sign-in mail used as a spam cannon, and code guessing | 3 link or code requests per 10 minutes per address and 10 attempts per code, after which the token is burned (Sign-in). Plus a rate-limit binding RL_SIGNIN: 10 requests per 60 s per client IP, keyed by CF-Connecting-IP, on POST /console/sign-in, /console/sign-in/link, /console/sign-in/code, /console/sign-up and /console/waitlist. This closes Console open point 1 |
| Free workspaces created to send spam | A new-workspace ramp: tenant_daily_send_cap is 50 for the first 7 days on Free. It lifts on day 7 if the bounce and complaint rates are under the auto-pause thresholds, or at once on a paid plan. The usual auto-pause still applies (W30) |
| Disposable addresses | PM_SIGNUP_BLOCKED_DOMAINS (W29) |
| Who system mail comes from | PM_SYSTEM_FROM, for example Pylota Mail <no-reply@pylotamail.com>, sent through the platform domain by the system identity (Identities and domains › The system identity). It is the identity that other pages name as the sender of sign-in, invitation and notification mail. This closes Console open point 2 |
Open redirects through next | §7 |
| Lost access to the sign-in address | No self-service recovery. Pylota support verifies the requester against the workspace’s Stripe billing details and a recent invoice number, then moves ownership to a new verified address. The audit log records it with via: support |
| Lost authenticator | Recovery codes; otherwise the support route above |
| Leaving | A person can delete their account at /console/settings when they own no workspace (otherwise 409 owner_required) (W34). An owner can delete a workspace after re-authentication and typing its name. That cancels the subscription at once and starts tenant erasure (Privacy). Deleting an account deletes the person’s sessions, oauth_identities and waitlist row, and scrubs the users row (Privacy › People) |
11. Data model
-- users: new columns
last_tenant_id TEXT,
terms_version TEXT,
terms_accepted_at INTEGER,
totp_sealed BLOB, -- pm1 envelope of the 20-byte TOTP secret
totp_enabled_at INTEGER,
totp_last_step INTEGER, -- last accepted time step, against replay
recovery_codes_sealed BLOB, -- pm1 envelope of [{ "hash": SHA-256(code), "used_at": null }]
totp_window_start INTEGER, -- start of the current one-minute attempt window
totp_window_count INTEGER NOT NULL DEFAULT 0, -- attempts in that window (at most 5)
totp_failures INTEGER NOT NULL DEFAULT 0, -- failed codes in a row; 10 sets totp_locked_until
totp_locked_until INTEGER, -- two-step sign-in locked until this time (15 minutes)
-- tenants: new columns
require_two_factor INTEGER NOT NULL DEFAULT 0,
onboarding_dismissed_at INTEGER,
CREATE TABLE oauth_identities (
provider TEXT NOT NULL CHECK (provider IN ('google','github')),
subject TEXT NOT NULL, -- Google sub, GitHub numeric id
user_id TEXT NOT NULL REFERENCES users(id),
email_at_link TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_used_at INTEGER,
PRIMARY KEY (provider, subject)
);
CREATE INDEX oauth_identities_user ON oauth_identities (user_id);
CREATE TABLE oauth_states (
state_hash TEXT PRIMARY KEY, -- keyed hash of state
cookie_hash TEXT NOT NULL, -- keyed hash of the __Host-pm_oauth value
key_kid TEXT NOT NULL, -- the link-key kid of state_hash and cookie_hash
provider TEXT NOT NULL CHECK (provider IN ('google','github')),
intent TEXT NOT NULL CHECK (intent IN ('sign_in','sign_up')),
pkce_sealed BLOB NOT NULL,
nonce TEXT,
next_path TEXT,
plan TEXT,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
used_at INTEGER
);
CREATE TABLE waitlist (
email TEXT PRIMARY KEY, -- needed to send the invitation; deleted per §6.1
plan TEXT,
created_at INTEGER NOT NULL,
confirmed_at INTEGER,
invited_at INTEGER,
invite_token_hash TEXT UNIQUE, -- keyed hash of the sign-up link token (link keyring)
key_kid TEXT -- the link-key kid of invite_token_hash
);
Erasure of a person deletes their oauth_identities and any waitlist row
(Console open point 5, Privacy › People).
The global retention job deletes oauth_states rows 24 hours after expires_at, and waitlist rows as in
§6.1 (Privacy › Global retention job).
12. Configuration
| Variable or secret | Default | Meaning |
|---|---|---|
PM_CONSOLE_HOST | PM_API_HOST | Host that serves the console (§2) |
PM_SIGNUP | closed | closed, waitlist or open |
PM_SYSTEM_FROM | Pylota Mail <no-reply@{PM_PLATFORM_DOMAIN}> | Sender of sign-in, invitation and notification mail |
PM_TERMS_URL, PM_PRIVACY_URL, PM_DPA_URL, PM_TERMS_VERSION | unset | Required when PM_SIGNUP is not closed |
PM_SIGNUP_BLOCKED_DOMAINS | unset | Comma-separated domains refused at sign-up, in addition to the built-in list of disposable-mail domains |
PM_OAUTH_GOOGLE_CLIENT_ID / secret PM_OAUTH_GOOGLE_CLIENT_SECRET | unset | Enables Google |
PM_OAUTH_GITHUB_CLIENT_ID / secret PM_OAUTH_GITHUB_CLIENT_SECRET | unset | Enables GitHub |
Binding RL_SIGNIN | 10 per 60 s | Keyed by client IP (CF-Connecting-IP), on POST /console/sign-in, /console/sign-in/link, /console/sign-in/code, /console/sign-up and /console/waitlist |
13. Tests
| Test | Covers |
|---|---|
it::signup::email_creates_account_only_on_use | No users row until the link or code is used; terms version recorded |
it::signup::plan_intent_to_checkout | ?plan=team → workspace → Checkout; cancel → Free with banner (W24) |
it::signup::closed_and_waitlist | No account is created when sign-up is closed; waitlist double opt-in and batch invite through POST /v1/platform/waitlist/invite (W32) |
it::signup::disposable_domain_refused | An address on a PM_SIGNUP_BLOCKED_DOMAINS domain is refused at email sign-up before any mail is sent, and at Google or GitHub sign-up (W29) |
it::signup::suffix_taken_race | Two workspaces created at once with the same suffix: one succeeds, the other form returns suffix_taken (W33) |
it::console::delete_account_owner_required | Deleting your account while you own a workspace → 409 owner_required; after ownership moves, the deletion succeeds (W34) |
it::oauth::state_cookie_binding | Missing, reused, expired or other-browser state → refused (W20) |
it::oauth::unverified_email_refused | GitHub without a verified primary address; Google email_verified: false (W21) |
it::oauth::link_by_verified_email | Google, then an email link → one user (W22) |
it::oauth::invitation_email_mismatch | Invitation for one address, OAuth with another → refused, invitation still pending (W23); with the invited address on a deployment where sign-up is closed → account created and invitation accepted |
core::totp::rfc6238_vectors | RFC 6238 test vectors; drift ±1; replay in the same step refused |
it::totp::workspace_requirement | require_two_factor sends an unenrolled member to enrolment before the workspace opens; API keys of that workspace still work (W27) |
it::totp::recovery_code_single_use | A recovery code signs in once and is refused the second time; generating new codes makes every old code fail (W28) |
it::totp::recovery_codes_survive_key_rotation | Recovery codes are stored only in recovery_codes_sealed (no plain code in D1); one still works after the link key is rotated and the fake clock moves 8 days on; with all ten used, the page points to the support route (W28) |
it::landing::routing_table | Every row of §7, including a hostile next (W31, FR-CON-11) |
it::checkout::return_wrong_workspace | A session ID for another customer changes nothing (W26) |
it::checkout::return_before_webhook | Waits, then “within a minute”; the plan is applied by the webhook only (W25, FR-CON-13) |
it::onboarding::derived_steps | Each checklist step turns done from real data alone; each Overview banner condition shows its banner and hides it once resolved (FR-CON-12) |
it::abuse::free_ramp | 51st send on day 1 of a Free workspace → 429 daily_cap_reached; lifted on upgrade (W30) |
it::hosts::console_api_split | With two hosts, console paths 404 on the API host and API paths 404 on the console host; no Set-Cookie on the API host |