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

Console and workspaces

Binding design for the console at /console, and for the workspaces, members, roles, invitations, sign-in and sessions behind it. It implements FR-CON-1 to FR-CON-7 and NFR-CON-1, build plan milestone M21, and the edge-case rows W8–W10 and W15–W18 in the edge-case register; and the console parts of agent signing keys (FR-IDN-6, M25) and of notifications (FR-CON-14, FR-CON-15, M26; rows O17–O19). The plan and usage page and everything about money is in Plans, metering and billing. Self-serve sign-up, Google and GitHub sign-in, two-step verification, the landing rules and the Overview (FR-CON-8 to FR-CON-13) are in Cloud sign-up, sign-in and first run, which extends this design.

Codecrates/worker/src/console/{mod.rs, router.rs, session.rs, signin.rs, csrf.rs, layout.rs, pages/*.rs} (pages/notifications.rs for the settings screen), crates/worker/src/members/{mod.rs, invitations.rs, roles.rs}, handlers/members.rs, crates/worker/src/notify/unsubscribe.rs
TablesD1 users, members, invitations, login_tokens, sessions (Data model); oauth_identities, oauth_states, waitlist (Cloud sign-up §11); notification_prefs (Notifications §2); identity_keys (Agent signing keys §8)
ConfigurationPM_CONSOLE, PM_SIGNUP, PM_NOTIFICATIONS (Configuration); also PM_CONSOLE_HOST and PM_SYSTEM_FROM, which are top-level settings read with the console off; binding RL_SIGNIN (Bindings)
ContractsMembers and invitations endpoints and the members:read and members:manage permissions (REST API); member.* events (Webhook events); 409 owner_required, 402 billing_limit (Errors)
LimitsLimits › Console
External facts verified on 2026-10-09The Fetch Standard’s “append a request Origin header” algorithm (fetch.spec.whatwg.org)

What the console is

The console is for the people who run the agents. Agents keep using the REST API and MCP. The console holds the views a person needs to check on them, and the actions that should need a person: keys, domains, members, quarantine release and billing (PRD §4). It is not a webmail client: it has no compose or reply form.

  • Same Worker. fetch routes /console and /console/* to console::router. With PM_CONSOLE=off those routes are not registered and answer 404 with the standard envelope (FR-CON-7), except two pairs that mail links to: the invitation-accept pair (Invitations) and the unsubscribe pair, GET and POST /console/notifications/unsubscribe (Unsubscribe links), so the List-Unsubscribe header of every notification works. The members API, invitation emails and notifications keep working.

  • Its own host, if configured. The console is served on PM_CONSOLE_HOST, which defaults to PM_API_HOST. When the two differ, console paths answer only on the console host and API paths (REST /v1/* with signed links /v1/links/*, MCP /mcp, /openapi.json, /health, /.well-known/*, /hooks/* and /billing/stripe/webhook) only on the API host; anything else gets 404, and no cookie is set or read on the API host (Cloud sign-up §2).

  • Rendered on the server in Rust with maud templates (layout.rs, pages/*.rs). Pages are HTML and one stylesheet, /console/assets/console.css. There is no JavaScript, no web font, and no request to another origin (FR-CON-1). maud is pinned at =0.27.0 in Rust workspace.

  • Forms only. Every state change is a POST from a <form> with a CSRF token (CSRF). A GET never changes state. Lists paginate with links that carry the API’s cursor.

  • Same services as the API. A console handler calls the same internal service functions as the REST handler for that action, with a session principal instead of an API key:

    pub enum Principal {
        Key(ResolvedKey),                                     // REST and MCP
        Session { user_id: String, tenant_id: String, role: Role, permissions: PermissionSet },
    }

    The permissions come from the member’s role (Roles). For every level check a session acts as a tenant-level principal of its workspace (level = tenant, tenant_id the session’s): it may do what a tenant key holding the same permissions may do (tenant search, resume of an abuse pause, tenant-scope erasure, nameservers when policy allows it), and never what needs a platform key. Validation, error codes, idempotency, metering and audit are therefore identical to the API’s.

  • Budget. Server render time p95 ≤ 300 ms (NFR-CON-1). A page makes at most one D1 query for the session, then the same calls the API would make.

Workspaces

A workspace is a tenant (FR-CON-2). Everything in it (identities, domains, keys, webhooks, plan) belongs to that tenant, and its scope always comes from the session, never from a form field (W18).

  • A workspace has exactly one owner and any number of members up to its seat limit. The unique partial index members_one_owner (ON members(tenant_id) WHERE role = 'owner') makes a second owner impossible at the database level.
  • A person (users row) can belong to several workspaces. The session’s tenant_id is the active one; /console/workspaces lists the others and switches with a POST.
  • Workspaces are created by POST /v1/tenants with owner (a platform key), which creates the users row if needed, adds the owner and emails a sign-in link. A self-hosted deployment creates its first owner on the default tenant with pmail setup --owner-email (FR-CON-7). Where PM_SIGNUP is waitlist or open (Pylota Mail Cloud), people also create their own workspace at /console/workspaces/new (Cloud sign-up §6).
  • Test tenants are workspaces too. Their mail goes to the simulator as usual; the console marks them with a “Test” badge.
  • There is no cross-workspace administration view in v1.0. Platform operators use platform keys and the CLI.

Roles

Four roles, with these permissions in the console. The second table lists the API permissions that each role’s session principal holds, so the same checks run as for an API key.

ActionOwnerAdminMemberViewer
Read inboxes, threads, messages and attachments; keyword, semantic and hybrid searchYesYesYesYes
Agentic searchYesYesYesNo
Labels, read state, re-run triageYesYesYesNo
See quarantined mail and release it (sensitive)YesYesYesNo
Create, pause and resume identitiesYesYesNoNo
See an identity’s signing keys and its JWKS linkYesYesYesYes
Create, rotate and revoke an identity’s signing keys (sensitive)YesYesNoNo
See domains, their health and DNS recordsYesYesYesYes
Add, verify and remove domains (add and remove are sensitive)YesYesNoNo
Webhook endpoints: create, edit, rotate the secret, replayYesYesNoNo
API keys: list, create (sensitive), revokeYesYesNoNo
Erasure and legal holds (sensitive): message, thread, counterparty and identity scopeYesYesNoNo
Delete the workspace (tenant-scope erasure, sensitive)YesNoNoNo
See members and pending invitationsYesYesYesYes
Invite, revoke invitations, change roles, remove members (sensitive)YesYes, except anything that touches the ownerNoNo
Transfer ownership to an admin (sensitive)YesNoNoNo
See plan and usageYesYesYesYes
Upgrade, buy top-ups, open the Customer Portal (sensitive)YesNoNoNo
See the audit logYesYesNoNo
Your own notification settings for this workspaceYesYesYesYes
Leave the workspaceNo: transfer ownership firstYesYesYes
RolePermission set of the session principal
ownerEvery tenant-level permission: identities:read, identities:write, identities:sign, domains:read, domains:write, messages:read, messages:send, messages:write, attachments:read, search:read, search:agentic, quarantine:review, webhooks:read, webhooks:manage, keys:manage, erasure:manage, suppressions:manage, usage:read, audit:read, members:read, members:manage; plus the console-only owner rights: billing, ownership transfer, deleting the workspace, and the workspace settings below
adminThe owner’s tenant-level permissions (identities:sign included), without the console-only owner rights. Its erasure:manage covers every scope except tenant: the console’s tenant-erasure route also checks role = owner
memberidentities:read, domains:read, messages:read, messages:write, attachments:read, search:read, search:agentic, quarantine:review, usage:read, members:read
vieweridentities:read, domains:read, messages:read, attachments:read, search:read, usage:read, members:read

Rules:

  • One owner. The owner cannot leave, be removed, or have their role changed; each attempt returns 409 owner_required (W10). Ownership moves only by a transfer to an existing admin (Members).
  • Admins and the owner. An admin can manage admins, members and viewers, but cannot change the owner or make anyone owner.
  • Keys from the console are tenant-level or identity-level, never platform-level, and can never hold a permission the session lacks (FR-KEY-1). Owners and admins hold identities:sign, so they can create API keys that sign as an identity; members and viewers cannot. The level rules of Security §4.6 apply as in the API: an identity-level key never carries a tenant-only permission (members:read, members:manage, suppressions:manage, audit:read, usage:read), so the key form does not offer them for that level.
  • Tenant policy is changed with a platform key, as in the API (PATCH /v1/tenants/{id} needs tenants:manage). The settings page shows the effective policy read-only.
  • Workspace settings (name, time zone, require_two_factor) are console-only owner rights, like billing: the settings form posts to a console handler that checks role = owner and updates exactly those three columns of tenants, with an audit row. It never calls PATCH /v1/tenants and never touches the platform-only fields (policy, status, mode, slug, address_suffix, billing).
  • Members list. Every role can see members and pending invitations, through GET /v1/tenants/{tenant_id}/members, which needs members:read (included in members:manage).
  • Quarantine release is possible for a signed-in person with the role above. On Pylota Mail Cloud no API key can release; a self-hosted deployment can also allow keys with quarantine:review (FR-CON-6).
  • Every handler checks the role, through the console’s route table, which registers each route with its required permission exactly like the API’s deny-by-default table (Security). A viewer’s POST to a write route gets 403, and a resource ID from another workspace gets the same 404 as a missing one (W18).

Sign-in

Sign-in is passwordless (FR-CON-3). One request sends one email with both a magic link and a six-digit code; either signs the person in, once.

Two more ways are designed in Cloud sign-up:

  • Continue with Google or GitHub (FR-CON-9), on when the deployment has that provider’s client ID and secret. Only a verified email is accepted, and it links to an existing person with the same address (Cloud sign-up §4).
  • Two-step verification with an authenticator app (FR-CON-10), optional per person and required by a workspace that sets require_two_factor. It is asked for after any first factor, before the session is created (Cloud sign-up §5).

Every method ends in the same session creation (Sessions), and the landing page is chosen by Cloud sign-up §7.

LimitValue
Link or code requests3 per 10 minutes per address
Code verification attempts10 per code; the token is burned after 10 failures
Requests per client IPRL_SIGNIN: 10 per 60 seconds 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
Two-step verification codes5 attempts a minute per person; 10 failures in a row lock two-step sign-in for 15 minutes
Link and code lifetime10 minutes, single use (using one burns the other)
Session lifetime7 days rolling, 30 days absolute
Re-authentication for sensitive actionsSigned in within the last 10 minutes

POST /console/sign-in with email:

  1. Normalise the address (lower case, IDNA A-label domain) and validate it.
  2. If login_tokens already has 3 rows for this address created in the last 10 minutes, answer the “too many requests, wait 10 minutes” page. The page is the same whether the address is known or not.
  3. Insert a login_tokens row: a 32-byte random link token and a six-digit code from the platform RNG (uniform, by rejection sampling), stored only as token_hash and code_hash, keyed hashes under the current link signing key, whose kid goes in key_kid (Keyed hashes), with expires_at = now + 10 minutes. The row is written for every address, known or not, so the limits behave the same.
  4. Answer 200 with the “check your email” page, which holds the code form.
  5. After the response (wait_until), send the email only if the address belongs to an active user with at least one membership, or has a pending invitation. Otherwise send nothing.

The email goes through the normal outbound pipeline from the system identity (Identities and domains › The system identity), whose address is PM_SYSTEM_FROM (default Pylota Mail <no-reply@{PM_PLATFORM_DOMAIN}>) and whose tenant is the default tenant (billing disabled or exempt, so it is never metered), with Idempotency-Key: signin:{login_token_id}; tests use the simulator (build plan M21). It contains the link https://{PM_CONSOLE_HOST}/console/sign-in/link?t=<token>, the code, and the request time. It never says whether the address has an account.

Doing the lookup and the send after the response keeps the response identical in content and timing for registered and unregistered addresses (W15).

GET /console/sign-in/link?t=… changes nothing. It shows a page with one Sign in button, which POSTs the token. Mail security scanners often open links in email; because the GET does not consume the token, a scanner cannot burn it.

The POST hashes the token and looks for a row that is unexpired, unused and has fewer than 10 attempts. On success it sets used_at, creates the users row if the address only had a pending invitation, sets last_login_at, asks for two-step verification if the person has it, creates a session and answers 303 to the page chosen by Cloud sign-up §7 (normally /console).

Using the code

POST /console/sign-in/code with email and code computes HMAC(link key {key_kid}, email || code) for each unexpired, unused token of the address (at most three) and compares it in constant time with that row’s code_hash. A failure increments attempts on each of them; a token reaching 10 is burned. Success continues as for the link.

Each token allows 10 attempts. On top of the per-address limits, the Workers rate-limiting binding RL_SIGNIN (Configuration › Bindings) allows 10 requests per 60 seconds 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 (Cloud sign-up §10).

Keyed hashes

Link tokens, codes, invitation tokens, session cookies and OAuth state values (with their __Host-pm_oauth cookie values) are never stored. Each table keeps HMAC-SHA256(link key {kid}, value) and the key_kid it used, where the link key is the Worker-generated signing_keys key of purpose link (Configuration › Thread and link keys). Tokens and cookie values start with that one-character kid, so the Worker knows which key to hash with. For OAuth, oauth_states.state_hash and cookie_hash are hashed this way, with oauth_states.key_kid (Cloud sign-up §4).

After POST /v1/platform/keys/link/rotate, the old kid keeps verifying for 7 days. Codes and sign-in links live 10 minutes, OAuth flows 10 minutes and invitations 7 days, so they are unaffected. A session whose key_kid is not the current one is re-hashed under the current key on its next request (new id_hash and key_kid in one UPDATE), so active sessions survive a rotation; a session idle for the whole 7 days ends, as its rolling lifetime would. With ?revoke_previous=true the old kid is deleted at once: every sign-in token, invitation, OAuth flow and session hashed under it stops working, and people sign in again (Security › Rotation procedures).

login_tokens rows hold a clear address, so the daily maintenance deletes them 24 hours after they expire.

Sessions

PropertyValue
Cookie__Host-pm_session=<value>; Path=/; Secure; HttpOnly; SameSite=Lax (W16)
ValueThe link key’s kid, then 32 random bytes in base64url. Only id_hash and key_kid are stored (Keyed hashes)
Lifetimeexpires_at = min(last_seen_at + 7 days, authenticated_at + 30 days)
last_seen_atUpdated at most once a minute, which also moves expires_at
csrf_secret32 random bytes per session
user_agent_hintBrowser family only (for the “your sessions” list), never the full header

The REST API and /mcp never read this cookie; they authenticate API keys only. The cookie is set by PM_CONSOLE_HOST; when that differs from PM_API_HOST, no cookie is set or read on the API host.

Every console request loads the session, the user and the member row for the active workspace in one D1 query:

SELECT s.user_id, s.tenant_id, s.csrf_secret, s.authenticated_at, s.last_seen_at, u.email, m.role
FROM sessions s
JOIN users u ON u.id = s.user_id AND u.status = 'active'
LEFT JOIN members m ON m.tenant_id = s.tenant_id AND m.user_id = s.user_id
WHERE s.id_hash = ?1 AND s.revoked_at IS NULL AND s.expires_at > ?2;

No row: redirect to /console/sign-in. A session whose active workspace has no member row (the person was removed) is sent to the workspace picker, so a removed member can never act in that workspace, even in a request that was already in flight.

Sign-out sets revoked_at and clears the cookie. Sign out everywhere (settings) revokes every session of the user. Revoked and expired rows are deleted 30 days later.

Re-authentication

Sensitive actions require a sign-in within the last 10 minutes and write an audit row (FR-CON-5): creating keys, creating, rotating or revoking an identity’s signing keys, inviting or removing members, changing roles, transferring ownership, adding or removing domains, releasing quarantine, erasure and legal holds, and billing (Checkout and the Customer Portal). Confirming your address after a notification bounce is audited but needs no recent sign-in, because the re-authentication code would go to the suppressed address (Notification settings).

If authenticated_at is older, the POST answers 303 to /console/reauth?next=<path>. That page sends a code to the signed-in address (a new login_tokens row, counted in the same limits), and also asks for a two-step verification code when the person is enrolled. A correct code creates a new session (new cookie, authenticated_at = now) and revokes the old one, then answers 303 to next, which must be a path under /console/. The person submits the action again; the console never replays a form on its own.

CSRF

Three layers, all required (W16):

  1. Token. Every form has a hidden _csrf field: base64url(HMAC-SHA256(csrf_secret, "console-form")). A POST without it, or with a different value (constant-time comparison), gets 403.
  2. Origin. Every POST must carry an Origin header equal to https://{PM_CONSOLE_HOST} (which is https://{PM_API_HOST} by default). A missing header, null, or any other value gets 403.
  3. Cookie. SameSite=Lax, so cross-site POSTs carry no session at all.

The forms used before a session exists (sign-in, code, link, invitation acceptance, sign-up, waitlist) are checked by Origin alone; they cannot act as a signed-in person. The OAuth callback is a GET from the provider and is bound to the browser by its state and __Host-pm_oauth cookie instead (Cloud sign-up §4).

The unsubscribe pair (GET and POST /console/notifications/unsubscribe) is exempt from all three layers: it needs no session, and its POST is checked by neither the CSRF token nor Origin, because a mail provider sends the RFC 8058 one-click POST with neither (W16). The token in the URL is its only authority, and it can only turn one notification kind off for one person in one workspace (Unsubscribe links).

Console pages are served with Referrer-Policy: same-origin, not the API’s no-referrer. The Fetch Standard sets the Origin header of a non-CORS POST to null when the page’s referrer policy is no-referrer, which would make every legitimate form submission fail the check above.

Other console response headers: Content-Security-Policy: default-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; base-uri 'none', Cache-Control: no-store, X-Content-Type-Options: nosniff and Strict-Transport-Security: max-age=31536000. There is no script source at all. 'unsafe-inline' for styles is there for sanitised mail, which uses inline styles and inherits the page’s policy inside its srcdoc frame.

Showing untrusted mail

Mail content is untrusted (Security). The console shows it so that it cannot act (W17):

  • The default view is text: extracted_text, or the full text, HTML-escaped.
  • The HTML view puts the sanitised HTML (sanitised at ingest) in <iframe sandbox srcdoc="…">. The sandbox attribute has no tokens, so the frame runs no scripts, has no same-origin access, submits no forms, opens no pop-ups and cannot navigate the page.
  • Remote images are not loaded: the policy allows images only from the console itself and data:. cid: images are rewritten to the attachment URL. A Load remote images link re-renders that one message with img-src https: after a warning that the sender may learn the mail was opened.
  • Quarantined messages show the text view and the quarantine reason only. Risky attachments are never offered for preview.
  • Display names, subjects and filenames are always escaped; links in text view are not made clickable.

Invitations

Members are invited by email (FR-CON-4). The owner and admins can invite, from the console or with POST /v1/tenants/{id}/invitations (members:manage).

  1. Validate the address and the role (admin, member or viewer). An address that is already a member is refused with 400 invalid_request.
  2. If a pending invitation for the address exists (invitations_pending is unique per workspace and address), it is re-sent instead: new token, expires_at restarted, no new seat.
  3. Take a seats hold in TenantQuota (ref = the new inv_ ID). With no seat left the request fails with 402 billing_limit and details.feature: "seats", before anything is written (W8, Billing).
  4. Insert the invitation (token_hash and key_kid as in Keyed hashes, expires_at = now + 7 days) with its audit row and member.invited event in one D1 batch, then settle the hold.
  5. Email the link https://{PM_CONSOLE_HOST}/console/invitations/accept?t=<token> from the system identity, as for sign-in. The email says who invited the person: the inviter’s name from invitations.invited_by (the signed-in user; NULL when an API key created the invitation, and then the workspace name alone). This works with PM_CONSOLE=off too: PM_CONSOLE_HOST and PM_SYSTEM_FROM are top-level settings, and with the console off the router still registers the two invitation routes (GET and POST /console/invitations/accept). Accepting there creates the user and the membership and shows “You are now a member of {workspace}”; no session is created, because the deployment has no console to sign in to.

A pending invitation counts as a seat until it is accepted, revoked or expires.

Accepting. The GET shows the workspace name and the role with one Accept button; the POST consumes the token. The link was sent to the invited address, so it proves control of it: acceptance creates the users row if needed, inserts the members row with the invited role, marks the invitation accepted, and signs the person in with the new workspace active. The seat taken by the invitation becomes the member’s seat; TenantQuota does not change. Event member.joined.

Revoking (DELETE /v1/tenants/{id}/invitations/{inv} or the console) sets revoked and releases the seat (Adjust −1). Expiry: the hourly roll-up sets expired on pending invitations past expires_at and releases their seats; acceptance also checks expires_at, so a late click never works.

Members

Changing a role (owner or admin, sensitive) updates members.role and emits member.role_changed. It takes effect on the person’s next request, because the role is read on every request.

Removing a member (owner or admin, sensitive; DELETE /v1/tenants/{id}/members/{user_id}) and leaving run one D1 batch: delete the members row, delete the person’s notification_prefs rows for this workspace, revoke every session of that user whose active workspace is this one, write the audit row and the member.removed event. Then the seat is released with Adjust −1; if that call is lost, the hourly reconciliation corrects the count. After the batch the handler calls NotifierRequest::MemberRemoved { user_id } on the workspace’s Notifier, which drops the person’s pending notifications in this workspace, so nothing more is sent to them about it (O19, Notifications §2). The person’s next request redirects to sign-in (W9). The owner cannot be removed and cannot leave (409 owner_required, W10).

Transferring ownership (owner only, sensitive) to an existing admin. One D1 batch, in this order, because the unique index on the owner is checked statement by statement:

-- ?1 tenant, ?2 current owner, ?3 target admin
UPDATE members SET role = 'admin'
WHERE tenant_id = ?1 AND user_id = ?2 AND role = 'owner'
  AND EXISTS (SELECT 1 FROM members WHERE tenant_id = ?1 AND user_id = ?3 AND role = 'admin');
UPDATE members SET role = 'owner'
WHERE tenant_id = ?1 AND user_id = ?3 AND role = 'admin'
  AND NOT EXISTS (SELECT 1 FROM members WHERE tenant_id = ?1 AND role = 'owner');

Both statements change one row, or neither does (the target is not an admin): then the request returns 400 invalid_request (“the new owner must be an admin of this workspace”) and the workspace still has its owner. The batch also writes the audit row member.ownership_transfer and two member.role_changed events. Stripe’s customer email does not change; the new owner can update it in the Customer Portal. After the batch commits, the handler sends the account email (Account emails).

Identity signing keys

The identity page /console/inboxes/{idn} has a Signing keys section for the identity’s agent signing keys (Agent signing keys). It calls the same service functions as the API’s identity-key routes:

ActionWhoSame as
List every key (kid, status, created_at, verify_until, retired_at) and show the JWKS link https://{PM_API_HOST}/.well-known/jwks/{identity_id}.jsonAll roles (identities:read)GET /v1/identities/{identity_id}/keys
Create the first keyOwner, admin (identities:write); sensitivePOST /v1/identities/{identity_id}/keys
Rotate: a new active key, the previous one retiring until verify_untilOwner, admin; sensitivePOST /v1/identities/{identity_id}/keys/rotate
Revoke one key at onceOwner, admin; sensitivePOST /v1/identities/{identity_id}/keys/{kid}/revoke
  • Each change needs a recent sign-in (Re-authentication), writes the audit row identity_key.create, identity_key.rotate or identity_key.revoke (the actions the API writes) and emits the matching identity.key_* event. A create that finds an active key, or a revoke of a key that is already retired, changes nothing and writes no audit row or event.
  • Key management stays available while the identity is paused, so a suspected leak can be handled before it resumes; the page says that a paused identity’s JWKS is withdrawn and that it cannot sign.
  • The page shows public data only. No private key is ever displayed, and the console has no form that mints an assertion or a signed request: agents sign through the API and MCP.

Notifications

People get email about their workspace: usage alerts, new mail in inboxes they follow, the daily list of things that need a person, and account emails (Notifications). The console owns the preferences, the unsubscribe links and the account emails its own handlers trigger.

Notification settings

/console/settings/notifications sets the signed-in person’s own preferences for the active workspace, one notification_prefs row per kind (Notifications §2). Every role can open it. Nobody can change another person’s preferences, and there is no API for them: API keys are not people.

KindChoices on the pageDefault (no row)
usageoff or instantinstant for owner and admin; off for member and viewer
new_mailoff, instant, hourly or daily; the inboxes to follow (every inbox, or chosen ones); the filter (all, or only messages triage marks needs_reply)off
needs_personoff or dailydaily for owner and admin; off for member and viewer
accountAlways on, shown read-onlyOn
  • Saving is a POST with the CSRF token; it is not a sensitive action. It upserts the row for (user_id, tenant_id, kind) with the user and workspace taken from the session, never from the form (W18). A mode the kind does not accept, or an inbox ID outside the workspace, is refused like any invalid or foreign value; the system identity is never offered. A kind with no row follows the default of the person’s current role.
  • Bounce banner. A hard bounce or complaint on a notification sets paused_reason on every preference of the person in every workspace (O17). Every console page then shows a banner, and this page explains it with a Confirm my address button. Confirming needs the session but not a recent sign-in: re-authentication codes, like every message from the system identity, are not delivered to the suppressed address. One D1 batch clears paused_reason on all of the person’s rows, removes the system identity’s suppression of their address (the default tenant’s suppressions row for it) and writes the audit row user.notifications_resume.
  • Cap notice. When the person’s daily cap (50) or the workspace’s (200) has been reached, the page says so: further notifications that day go into the next daily digest (O24).
  • When notifications are off. With PM_NOTIFICATIONS=off, or while the workspace is suspended, the page says that only account emails are sent (O26). With PM_BILLING=off, it says that usage covers only features with an operator quota in tenant policy (O23).

Every usage, new_mail and needs_person email carries List-Unsubscribe: <https://{PM_CONSOLE_HOST}/console/notifications/unsubscribe?t={token}> and List-Unsubscribe-Post: List-Unsubscribe=One-Click (RFC 8058). The token (a MAC under the current link key with its kid, binding the person, the workspace and the kind, valid 90 days) is defined in Notifications §5.

RouteDoes
GET /console/notifications/unsubscribe?t=…Changes nothing. With a valid token it shows the workspace and the kind with one Unsubscribe button, a form that POSTs to the same URL. A mail scanner that opens the link unsubscribes no one
POST /console/notifications/unsubscribe?t=…Verifies the token and sets that kind to off for that person and workspace (an upsert of the notification_prefs row), then shows a confirmation page with a link to the settings page. This is the request a mail provider sends for a one-click unsubscribe
  • No session is needed and none is created. Both routes are exempt from the CSRF token and Origin check (CSRF), and both are served even with PM_CONSOLE=off.
  • An expired, altered or foreign token (another person’s, another workspace’s, or one whose link key has left its 7-day verify window after a rotation) changes nothing and gets the same page, linking to /console/settings/notifications, whichever check failed (O18).
  • It is not a sensitive action and writes no audit row: it only turns a notification off, as the person asked. account emails have no unsubscribe header.

Account emails

account emails cannot be turned off (Notifications §1). The console handlers that perform one of these actions call NotifierRequest::Account { user_id, event } on the Notifier of the person’s active workspace after their D1 batch commits:

EventHandlerSent to
Two-step verification turned off/console/settings/security (Cloud sign-up §5)The person
A new sign-in method linkedThe Google or GitHub callback, when it links a provider identity to an existing person (Cloud sign-up §4)The person
Ownership transferredMembersThe previous owner and the new owner

The fourth account event, a failed payment, belongs to Billing and goes to the owner. account emails are sent with PM_NOTIFICATIONS=off, to a suspended workspace, past the daily caps and while a person’s other preferences are paused.

Screens

PathScreenWho
/console/sign-inEmail form, plus “Continue with Google” and “Continue with GitHub” where enabled; then the “check your email” page with the code formAnyone
/console/sign-in/linkConfirm sign-in from the email linkAnyone with a link
/console/sign-upSign-up with Google, GitHub or an email address, and the terms checkbox (PM_SIGNUP=open; Cloud sign-up §6.2)Anyone
/console/waitlistJoin the waitlist, with double opt-in (PM_SIGNUP=waitlist; Cloud sign-up §6.1)Anyone
/console/oauth/{provider}/start, /console/oauth/{provider}/callbackRedirects to and from Google or GitHub; no page of their own (Cloud sign-up §4)Anyone
/console/invitations/acceptAccept an invitationAnyone with a link
/console/notifications/unsubscribeConfirm and apply a one-click unsubscribe from a notification kind (Unsubscribe links)Anyone with a link; no session
/console/reauthConfirm it is you, with a code (and a two-step code when enrolled)Signed in
/console/workspacesWorkspace picker and switcherSigned in
/console/workspaces/newCreate your workspace: name, address suffix, time zone (Cloud sign-up §6.2)Signed in, with no workspace or pending invitation, when sign-up is open
/consoleOverview, the workspace home: banners, the first-run checklist, “Needs a person”, usage meters, inboxes and recent activity (Cloud sign-up §8)All roles (viewers without action buttons)
/console/connectConnect your agent: the claude mcp add line, .mcp.json, a curl request and pmail login, with a key ID filled in, never a secretOwner, admin
/console/inboxes, /console/inboxes/{idn}Identities with their addresses and status; one identity’s threads with triage, and its signing keys with the JWKS link (Identity signing keys)All roles. Create, rotate and revoke signing keys: owner, admin
/console/inboxes/{idn}/threads/{thr}A thread; each message in text view, HTML on request (Showing untrusted mail)All roles
/console/searchSearch one identity or the whole workspace, with facets; agentic answers with citationsAll roles (agentic: not viewers)
/console/quarantineQuarantined mail with reasons; releaseOwner, admin, member
/console/keysKeys with scope and last use; create (the secret is shown once); revokeOwner, admin
/console/domains, /console/domains/{dom}Domains, health and issues, DNS records read from the provider API; add, verify, removeView: all roles. Change: owner, admin
/console/webhooksEndpoints, recent deliveries, rotate secret, replayOwner, admin
/console/membersMembers, pending invitations, seats used; invite, resend, revoke, change role, remove, transfer ownership, leaveView: all roles. Change: owner, admin. Transfer: owner
/console/planPlan, a meter per allowance, upgrade, top-ups, manage billing (Billing)View: all roles. Buy: owner
/console/plan/returnReturn from Stripe Checkout: confirms the plan once the webhook has applied it (Cloud sign-up §9)Owner
/console/auditAudit log with filtersOwner, admin
/console/settingsYour name and sessions; the terms version you accepted and when (users.terms_version, terms_accepted_at; “not recorded” for people who joined by invitation before sign-up opened); delete your account; workspace name, time zone and require_two_factor (owner only, through the console-only owner handler; never the platform-only tenant fields) and the effective policy (read-only)All roles
/console/settings/securityTwo-step verification: enrol with a QR code, recovery codes, turn off (re-authentication needed) (Cloud sign-up §5)Signed in
/console/settings/notificationsYour notification preferences for the active workspace: kinds, modes, followed inboxes and the needs_reply filter; the bounce banner and Confirm my address; the daily-cap notice (Notification settings)All roles, each for themselves

With PM_BILLING=off, /console/plan shows usage only, with no plans or buttons.

Tables

The schema is in Data model. How this design uses each table:

TableUse
usersOne row per person, keyed by sign-in address. status = 'disabled' blocks sign-in. Created by owner on POST /v1/tenants, by pmail setup --owner-email, or when an invitation is accepted
membersWho is in which workspace, with which role. members_one_owner enforces one owner
invitationsPending, accepted, revoked or expired invitations. invitations_pending allows one pending invitation per address and workspace
login_tokensOne row per sign-in or re-authentication request, holding both the link and the code hashes and the attempt count. Deleted 24 hours after expiry
sessionsConsole sessions with their CSRF secret and the time of the last sign-in. Deleted 30 days after expiry or revocation
oauth_identities, oauth_states, waitlistGoogle and GitHub links, OAuth flows in progress, and the waitlist (Cloud sign-up §11)
notification_prefsOne row per person, workspace and kind that has been saved or paused; a missing row means the default for the person’s role. Written by the settings page and by unsubscribe; paused_reason is set by a notification bounce or complaint and cleared by Confirm my address. Deleted for that workspace when a member is removed or leaves, for every workspace when a person deletes their account, and with the workspace by tenant erasure
identity_keysRead for the identity page’s key list; written only through the identity-key service functions that the API uses

Tokens, codes, cookie values and OAuth state values in these tables are always keyed hashes under a link key, with its kid, never the value itself. Values the Worker must use again (TOTP secrets, recovery-code hashes, PKCE verifiers) are sealed under PM_MASTER_KEY instead (Security › Encryption envelope).

Audit and events

Every sensitive action writes an audit_log row in the same D1 batch as the change. For console actions actor_key_id is NULL, actor_user_id holds the person (usr_…), and details_json holds "via": "console".

Audit actionWhenWebhook event
member.invite, member.invite_resendInvitation created or re-sentmember.invited (invitation_id, masked email_hint, role)
member.invite_revokeInvitation revoked–
member.joinInvitation acceptedmember.joined (user_id, role)
member.role_changeRole changedmember.role_changed (user_id, from, to)
member.remove, member.leaveMember removed, or leftmember.removed (user_id)
member.ownership_transferOwnership transferredTwo member.role_changed events
user.deleteA person deleted their account (Privacy › People)–
user.two_factor_enable, user.two_factor_disableTwo-step verification turned on or off (Cloud sign-up §5)–
user.notifications_resumeA person confirmed their address after a notification bounce or complaint (Notification settings); tenant_id is NULL, because it clears the pause in every workspace–
identity_key.create, identity_key.rotate, identity_key.revokeAn identity’s signing key created, rotated or revoked on the identity page (the API writes the same actions)identity.key_created, identity.key_rotated, identity.key_revoked
waitlist.inviteThe operator invited a batch from the waitlist (Cloud sign-up §6.1)–

The other sensitive actions use the same audit actions as the API (for example key.create, quarantine.release), so the log reads the same whichever way the action was taken. Billing actions are listed in Billing.

member.* events have no owner Durable Object. They are written to event_index as platform events with the workspace’s tenant_id and delivered like webhook.disabled (Webhooks › Platform events).

Open points

  1. RL_SIGNIN. Closed: 10 requests per 60 seconds per client IP, keyed by CF-Connecting-IP (Sign-in, Cloud sign-up §10).
  2. System mail sender. Closed: PM_SYSTEM_FROM is the address of the system identity that sends sign-in, invitation and notification mail (Identities and domains › The system identity) (Cloud sign-up §10, §12).
  3. Actor of console actions. Closed: audit_log.actor_user_id records the person (Data model), and message.released carries released_by_user_id for a console release (Webhook events).
  4. Key-based quarantine release (FR-CON-6). Closed: PM_QUARANTINE_KEY_RELEASE (Configuration) is on by default for self-hosting, and Pylota Mail Cloud sets it to off, so only a signed-in person can release there.
  5. Erasure of console data. Closed: tenant erasure deletes the workspace’s members, invitations and sessions, and deletes every person it leaves with no workspace; deleting a person removes their oauth_identities and any waitlist row (Cloud sign-up §11, Privacy › Tenant scope, Privacy › People).
  6. Sign-up on Pylota Mail Cloud. Closed: designed in Cloud sign-up, sign-in and first run.

Tests

TestProvesCovers
it::members::w8_seat_limitAn invitation with no seat left gets 402 with feature: seats; pending invitations count as seats; a re-sent invitation takes no new seatW8, FR-CON-4
it::members::w9_remove_revokes_sessionsAfter removal the member’s next request redirects to sign-in, including a request made with a session created before the removalW9, FR-CON-4
it::members::w10_owner_requiredRemoving, demoting or leaving as the owner gets 409 owner_required; a transfer to a non-admin changes nothingW10, FR-CON-2
it::console::w15_signin_limitsA fourth request in 10 minutes is refused; a token burns after 10 failed codes; an 11th request from one client IP within 60 seconds is refused by RL_SIGNIN; responses for known and unknown addresses are byte-identical apart from the request ID, and no email goes to an unknown addressW15, FR-CON-3
it::console::w16_csrfA POST without the token, with another session’s token, without Origin, with Origin: null or a foreign origin gets 403; the cookie has __Host-, Secure, HttpOnly and SameSite=Lax; the unsubscribe POST alone is accepted without a session, token or OriginW16, FR-CON-1
it::console::w17_hostile_htmlThe hostile-HTML corpus renders in a sandboxed srcdoc frame under the CSP: no script runs, no remote request is made, no form postsW17, FR-CON-6
it::console::w18_role_and_scope (table test)Each role against each route of Roles gets exactly the allowed outcome; IDs from another workspace give 404; a tenant_id in a form is ignoredW18, FR-CON-2
it::console::signin_link_and_codeThe link GET does not consume the token; link and code are single use and burn each other; expired tokens failFR-CON-3
it::console::session_lifetime7-day rolling and 30-day absolute expiry with a fake clock; sign-out and sign-out-everywhereFR-CON-3
it::console::reauth_sensitiveEach sensitive action redirects to re-authentication after 10 minutes, writes an audit row, and the session is rotatedFR-CON-5
it::members::invitation_lifecycleAccept, re-send, revoke and expire, with the seat count after eachFR-CON-4
it::members::ownership_transferExactly one owner before and after; concurrent transfers leave one ownerFR-CON-2
it::console::quarantine_releaseA member releases with re-authentication and an audit row; with key release off, an API key cannot releaseFR-CON-6
it::console::disabledPM_CONSOLE=off removes every /console route except the invitation-accept and unsubscribe pairs; the members API still worksFR-CON-7
it::console::notification_settingsEach role sees its defaults; saving writes rows for the session’s person and workspace only; a mode a kind does not accept and an inbox from another workspace are refused; account cannot be turned off; the cap notice appears after the 50th email of the dayFR-CON-14, FR-CON-15
it::console::identity_keys_pageEvery role sees the key list and the JWKS link; only owner and admin can create, rotate and revoke, each after re-authentication with an identity_key.* audit row and event; a paused identity’s keys can still be revokedFR-IDN-6, W18
it::console::account_emailsTurning two-step verification off, linking a sign-in method and transferring ownership each send one account email after the batch commits, also with PM_NOTIFICATIONS=offFR-CON-15
it::notify::one_click_unsubscribe, it::notify::bounce_pauses_prefs, it::notify::member_removed_drops_pending (Notifications §10)Unsubscribe without a session; the bounce banner and Confirm my address; member removal deletes preferencesO17–O19
cli::setup::owner_email (CLI and setup) plus it::console::first_owner_signinpmail setup --owner-email creates the default tenant’s owner, who receives a link and can sign inFR-CON-7
Playwright console_no_jsEvery console route works with javaScriptEnabled: false; an axe scan finds no serious violationFR-CON-1, M21
it::console::render_budgetServer render time p95 ≤ 300 ms on the fixture workspaceNFR-CON-1