Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

RequirementsFR-CON-8 to FR-CON-13 (PRD)
Edge casesW20–W34
Codecrates/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)
TablesD1 users (new columns), oauth_identities, oauth_states, waitlist (§11)

1. Who signs in where

PersonHow they get accessWhere they work
Cloud customer (a developer or a team buying Pylota Mail)Self-serve sign-up, this pageThe console on Pylota Mail Cloud
Teammate of a Cloud customerAn invitation (Invitations)The same console, in the inviter’s workspace
Self-hosterpmail setup --owner-email creates the first owner (Console)The console on their own deployment
Pylota car-rental operatorNever signs in here. Pylota’s backend creates their workspace with a platform keyInside 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.

HostServesNotes
pylotamail.com (apex) and www.pylotamail.comThe landing page and docs (the assets-only site Worker, site/wrangler.jsonc), and the shared mail domain, PM_PLATFORM_DOMAINAddresses 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.comThe console, PM_CONSOLE_HOSTSame Worker as the API. Only console routes answer on this host
api.pylotamail.comREST API, MCP, signed links (/v1/links/*), /hooks/*, the Stripe webhook (/billing/stripe/webhook), /health, PM_API_HOSTNo 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

MethodCloudSelf-hosted defaultNotes
Email link or six-digit codeOnOnAs in Sign-in
Continue with GoogleOnOff (needs PM_OAUTH_GOOGLE_CLIENT_ID and secret)OpenID Connect, scopes openid email profile only
Continue with GitHubOnOff (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 itSameTOTP (§5)
PasskeysNot in v1.0–They need browser JavaScript, and the console has none (FR-CON-1). Planned for v1.1
SAML or OIDC single sign-onNot 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.

  1. GET /console/oauth/{provider}/start?intent={sign_in|sign_up}&next={path}&plan={plan}. The Worker creates an oauth_states row valid for 10 minutes. The row holds a keyed hash of a 32-byte state (under the current link key, whose kid is stored as key_kid), a PKCE verifier sealed under PM_MASTER_KEY, a nonce (Google), and the validated next and plan. 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 exact redirect_uri https://{PM_CONSOLE_HOST}/console/oauth/{provider}/callback, state, code_challenge (S256) and the scopes above.
  2. GET /console/oauth/{provider}/callback. The handler requires the state row to exist, be unexpired and unused, and match the __Host-pm_oauth cookie. It marks the row used, then exchanges the code with the PKCE verifier at the token endpoint (W20).
  3. 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.com or accounts.google.com), aud = the client ID, exp, nonce, and email_verified = true. The subject is the sub claim.
  4. GitHub. GET https://api.github.com/user gives the numeric id, which is the subject. GET /user/emails gives the address marked primary and verified. If there is none, the flow is refused with a page telling the person to verify an email address on GitHub (W21).
  5. 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:
    1. oauth_identities has (provider, subject) → that user; set its last_used_at = now.
    2. Otherwise a users row with the verified email exists → link: insert oauth_identities. The same person can then use any method (W22).
    3. 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).
  6. 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 (the qrcode crate, pure Rust) and the base32 secret as text. The person confirms with a current code. The secret (20 random bytes) is sealed under PM_MASTER_KEY in users.totp_sealed, and totp_enabled_at is set in the same statement. “Enrolled” means totp_enabled_at IS NOT NULL: the sign-in step and the workspace requirement read it, and /console/settings/security shows 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 resets totp_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_sealed is a pm1 envelope under PM_MASTER_KEY of [{ "hash": SHA-256(code), "used_at": null }]. They are not keyed hashes under the link keyring, because a link key is deleted 7 days after rotation and recovery codes live for months. The pmail secrets rotate-master re-seal sweep covers them, as it covers users.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_factor in 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_step and recovery_codes_sealed to NULL, 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:

RequestPOST /v1/platform/waitlist/invite, body { "count": 50, "plan": null }. count is 1–500; plan filters by plan of interest (null: any)
Permissionplatform:ops; audit action waitlist.invite
Response200 { "invited": 50, "waiting": 262 }
EffectInvites 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.

  1. 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.
  2. 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_DOMAINS are refused before any mail is sent (W29).
  3. 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 with suffix_taken (W33). On success the tenant is created on the Free plan with this person as owner, and users.last_tenant_id is set.
  4. 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).
  5. 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:

SituationLands 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 addressAccept the invitation, then that workspace’s Overview
No workspace, and sign-up is openCreate 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 noneEnrol two-step verification, then continue
One workspaceIts Overview
Several workspacesThe 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:

  1. 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 failing or suspended (“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).
  2. First-run checklist, until its required steps are done (below).

  3. 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 uncertain and 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).

  4. 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.

  5. Inboxes. Per identity, for the last 24 hours: received, sent, waiting for a reply, unread. Each row opens the inbox.

  6. 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.

StepRequiredDone whenScreen
Create your first inboxYesThe workspace has an identity/console/inboxes/new: name it and see its address, for example bookings.brightwell@pylotamail.com
Send it a test emailYesAn inbound message existsThe 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 keyYesA key exists/console/keys/new. The secret is shown once
Connect your agentYesA 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 webhookNoAn endpoint returned 2xx to a test event/console/webhooks
Connect your own domainNoA domain is healthy/console/domains/new, the method chooser from Domains on any DNS host
Invite your teamNo (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}.

  1. The handler retrieves the Checkout Session from Stripe with PM_STRIPE_SECRET_KEY. It requires the session’s customer to equal this workspace’s billing_accounts.stripe_customer_id. Otherwise it shows a neutral “Nothing to show” page and changes nothing (W26).
  2. 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.
  3. 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

RiskControl
Sign-in mail used as a spam cannon, and code guessing3 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 spamA 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 addressesPM_SIGNUP_BLOCKED_DOMAINS (W29)
Who system mail comes fromPM_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 addressNo 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 authenticatorRecovery codes; otherwise the support route above
LeavingA 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 secretDefaultMeaning
PM_CONSOLE_HOSTPM_API_HOSTHost that serves the console (§2)
PM_SIGNUPclosedclosed, waitlist or open
PM_SYSTEM_FROMPylota 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_VERSIONunsetRequired when PM_SIGNUP is not closed
PM_SIGNUP_BLOCKED_DOMAINSunsetComma-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_SECRETunsetEnables Google
PM_OAUTH_GITHUB_CLIENT_ID / secret PM_OAUTH_GITHUB_CLIENT_SECRETunsetEnables GitHub
Binding RL_SIGNIN10 per 60 sKeyed 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

TestCovers
it::signup::email_creates_account_only_on_useNo 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_waitlistNo 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_refusedAn 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_raceTwo workspaces created at once with the same suffix: one succeeds, the other form returns suffix_taken (W33)
it::console::delete_account_owner_requiredDeleting your account while you own a workspace → 409 owner_required; after ownership moves, the deletion succeeds (W34)
it::oauth::state_cookie_bindingMissing, reused, expired or other-browser state → refused (W20)
it::oauth::unverified_email_refusedGitHub without a verified primary address; Google email_verified: false (W21)
it::oauth::link_by_verified_emailGoogle, then an email link → one user (W22)
it::oauth::invitation_email_mismatchInvitation 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_vectorsRFC 6238 test vectors; drift ±1; replay in the same step refused
it::totp::workspace_requirementrequire_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_useA 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_rotationRecovery 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_tableEvery row of §7, including a hostile next (W31, FR-CON-11)
it::checkout::return_wrong_workspaceA session ID for another customer changes nothing (W26)
it::checkout::return_before_webhookWaits, then “within a minute”; the plan is applied by the webhook only (W25, FR-CON-13)
it::onboarding::derived_stepsEach 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_ramp51st send on day 1 of a Free workspace → 429 daily_cap_reached; lifted on upgrade (W30)
it::hosts::console_api_splitWith two hosts, console paths 404 on the API host and API paths 404 on the console host; no Set-Cookie on the API host