Account

Account and preferences

Change the email you sign in with, connect or disconnect Google and GitHub, hide emails on your own dashboard, choose which emails Envless sends you, and delete your account.

Your account is one record that sits outside every workspace: a name, an avatar, one email address, and the sign-in providers linked to it. It lives on two dashboard pages, Account and Preferences. Nothing on either page touches encryption. No variable is read or written here, so no passphrase and no workspace key is involved.

Both pages save on an explicit Save button that stays disabled until something actually changed, and pressing Enter in the Name or Email field does the same thing as clicking it. Name and avatar go to one endpoint that reads only name and avatar; the email address is handled by a separate two-step flow and can never be written by a plain save.

What is on each page

PageSectionWhat it is
AccountAvatarUpload or remove the image shown across the dashboard. Removing it falls back to an avatar generated from your account id
AccountNameYour display name, required, 3 to 28 characters
AccountEmailThe address you sign in with. Changing it is the two-step flow below, and the field is read only while a provider is connected
AccountConnected AccountsGoogle and GitHub, each with a Connect or Disconnect button
AccountTwo-Factor AuthenticationEnrol an authenticator app, regenerate recovery codes, or turn the factor off. Off by default. See Two factor authentication
AccountDelete AccountPermanent deletion, gated on account age and a typed confirmation
PreferencesHide emailsOne switch that hides email addresses on your own dashboard
PreferencesNotificationsSix switches, one per family of notification email, all on by default. See Notification emails

The account actions ask for a code

Seven actions on the Account page are guarded by step-up verification, and they are the only ones in Envless that carry an account scope rather than a workspace one:

  • Requesting an email change
  • Connecting a provider
  • Disconnecting a provider
  • Turning two factor authentication on
  • Turning it off
  • Regenerating your recovery codes
  • Deleting the account

The account scope matters in one direction. An account elevation also satisfies any workspace-scoped check, so verifying here covers a key rotation five minutes later. A workspace elevation does not work the other way, so nothing you verified on the Keys page will get you through the Danger zone. Editing your name or avatar, uploading an image and every switch on Preferences are all unguarded.

You are asked at most once per hour across all seven. The dialog opens, takes the code, then reruns the action you clicked. With two factor authentication on it asks for your authenticator code and mails nothing.

Changing the email you sign in with

The account email is the address that receives your sign-in code, so it is never changed by a single click. Typing a new address and saving opens a request: Envless sends a verification link to the new address, and only when that link is opened does the address actually move. The old address is then sent a notice that it happened.

Request the change

Saving a changed address posts it to internal/users/request-email-change, trimmed and lower-cased. The endpoint is step-up guarded, so unless you verified in the last hour the dashboard asks for a code first. The address then has to be a valid one of at most 255 characters, different from your current one, and unclaimed both in the Envless database and in the authentication service. Any request you already had open is expired first, so there is only ever one live link.

Envless emails the new address

A token of 32 random bytes is generated, hex encoded, and only its SHA-256 hash is stored, so the link in the mail is the only copy of it. The mail goes to the new address with the subject Confirm your new email - Envless, and the link is the dashboard root with ?email-change-token=<token> on it. It expires 1 hour after it was minted.

Open the link

The dashboard reads that query parameter on whatever page it lands on, strips it back out of the URL immediately, and posts the token to internal/users/confirm-email-change. That endpoint carries no auth middleware: the token is the authority, so the link works in any browser, whether or not anyone is signed in there.

The swap, then the notice

Confirming claims the request (pending becomes applying), re-runs the provider and availability checks, writes the new address to the authentication service, and then to the database. If the database write does not land, the authentication service is put back to the old address and nothing moves. When both agree, the old address is emailed Your email address was changed - Envless.

The hint under the field, Pending. Confirm the link we sent to <address>, is held in memory only. Reloading the page clears it while the request is still perfectly live, so treat the mail in the new inbox as the record, not the hint.

What the request refuses

SituationStatusMessage
Missing, not a string, or blank after trimming400Please enter a valid email address!
Not a valid address400Please enter a valid email address.
Longer than 255 characters400Email address should be less than 255 characters long.
Already your address400That is already your account email!
Google or GitHub connected409Disconnect Google and GitHub before changing your email!
Another Envless account, or another authentication record, holds it409That email is not available. Please use a different one!
A request is somehow already in flight409An email change is already in progress. Please check your inbox!
The authentication service could not be reached503We could not reach the authentication service. Please try again!
The verification email could not be sent500We could not send the verification email. Please try again!

The in-progress 409 is a race, not the normal path: a new request expires your previous one before it inserts, and a unique index over active, undeleted requests per user is what answers if two requests land at the same instant. A request whose email fails to send is marked failed rather than left open.

What the confirmation refuses

SituationStatusMessage
The token is missing or not a string400That link is invalid or has already been used.
The token matches no request404That link is invalid or has already been used.
The request already completed200Your email has already been changed.
Another confirmation is mid-flight on the same request409That link is already being used.
Past its hour, or no longer pending400That link has expired. Please request a new one.
A provider was connected since the request409Disconnect Google and GitHub before changing your email!
The address was taken since the request409That email is not available. Please use a different one!

An already-completed request answers 200, not an error, so opening the same link twice is harmless. Every confirmation that gets as far as claiming the request increments a try counter on it, and that counter is what caps how many times the reconciliation cron below will hand the link back to you.

What follows the address

Once the swap lands, four things are updated with it: pending workspace invites addressed to the old address are re-pointed at the new one, the Polar billing customer's email, the Resend contact (its id is written back if it changed), and the cached user record. If any of that fails, the email change still stands. The request is marked propagated = false instead, and a cron retries it.

The reconciliation cron behind it

Two scheduled jobs stand behind the request table. reconcile-email-changes runs every 5 minutes and is the self-healing sweep; cleanup-email-change-requests runs hourly and is only garbage collection. The reconciler makes three passes, and the two that read rows take at most 50 of them per run:

Expire

Any request still pending past its expiresAt is moved to expired and deactivated. This is bookkeeping rather than enforcement: the confirm endpoint re-checks the expiry itself and only claims a request that is still pending and still in date, so a missed run cannot revive a dead link.

Rescue

A request stuck in applying for more than 5 minutes means a confirmation started and did not finish, which is the one state a user cannot get out of on their own. The sweep reads the real address from the authentication service and decides from that. If the service already holds the new address, it finishes the swap in the database, runs the propagation, marks the request completed and sends the notice to the old address. If the service still holds the old address, it puts the request back to pending so your original link works again. If it holds neither, the mismatch is logged and the request fails.

Re-propagate

Completed requests with propagated = false get the invite, Polar, Resend and cache fan-out retried, up to 5 attempts and only within 24 hours of the request settling. A request whose user has since moved to a different address again is marked propagated without doing any work, because it has been superseded.

The rescue pass refuses to hand a link back in four cases, and fails the request instead: it is already past its hour (marked expired), it has been tried 5 times, a Google or GitHub account has appeared on the record since the request was opened, or someone else has taken the new address in the meantime.

The two passes that touch a single account, the rescue and the re-propagation, take the same per-user advisory lock the confirm endpoint takes, so a duplicated or overlapping tick cannot apply the same change twice, and a locked row is skipped and picked up on the next run rather than forced. The expire pass needs no lock, because it is one conditional update that can only move a row that is still pending. The hourly cleanup soft-deletes completed, failed and expired requests once they are older than 30 days, which costs disk if it is missed and nothing else.

Email changes are blocked while a provider is connected

You cannot change your email while Google or GitHub is connected. The dashboard shows this by making the field read only and switching the section description to Disconnect Google and GitHub to change your email., and the rule is enforced three more times behind that: the request refuses with 409, the confirmation re-checks it at the moment of the swap, and the reconciler fails a rescued request outright if a provider appeared while the link was in flight.

The reason is the matching rule on the other side. A connected provider is only accepted when the provider account's email equals your Envless email, so allowing the two to drift apart would leave a linked account that can no longer be verified. The order to work in is therefore: disconnect both providers, change the email and confirm it, then connect the provider whose account uses the new address.

Connected accounts

Two providers exist, Google and GitHub, and neither is required. Signing in with an emailed code always works, which is why disconnecting both is allowed and why the disconnect dialog says so: You will no longer be able to sign in with <provider>, but you can still sign in with your email (<address>).

Connect opens a browser popup to link the provider, then posts to internal/users/connect-provider, which is step-up guarded. The server re-reads your providers from the authentication service and checks two things: that the link actually took, and that the provider account's email matches your Envless address. A mismatch is unlinked again server side and answered with 409 and Connect the <provider> account that uses <your address>. If the API refuses after the popup succeeded, the dashboard unlinks the provider again as well, so a failed connect does not leave a half-linked account behind.

What happenedWhat you see
The provider account is linked to another Envless userThat account is already linked to another Envless user.
The popup was blockedYour browser blocked the popup. Please allow popups and try again.
Your session is too old for the linkPlease sign in again before connecting an account.
The provider is already linked in the authentication serviceThat account is already linked here. Reload the page and try again.
You closed the popup yourselfNothing, the action is silently abandoned
The provider account uses a different addressConnect the <provider> account that uses <your address>.

Disconnect asks for a plain confirmation (no typing) and a verification code, then posts to internal/users/disconnect-provider, which unlinks the provider in the authentication service and rewrites your address as verified so email sign-in keeps working. A provider that is already gone upstream is not an error: the local flag is healed and the response is 200 with <provider> is not connected. An authentication service that cannot be reached answers 503 and nothing is changed locally.

Hide emails

The first switch on Preferences is Hide emails, described in the dashboard as hiding email addresses across the dashboard. Saving it posts { "hideEmails": true | false } to internal/users/preferences, the same endpoint the notification switches use. Setting it to the value it already has answers 200 with No changes detected. rather than writing anything.

internal/users/preferences reads two keys, hideEmails and notifications, and only refuses with 400 and Invalid preferences payload. when neither is usable. A payload whose hideEmails is not a boolean is not an error on its own: the key is ignored and a valid notifications object alongside it still saves. Unknown keys inside notifications, and values that are not booleans, are dropped the same way.

It is stored on your user record, so it is not scoped to a workspace: it follows you into every workspace you open, and it is yours alone. Two surfaces read it today:

  • The account button at the bottom of the sidebar drops the address line under your name.
  • The Email field on the Account page renders 28 bullet characters instead of the address, and only while a provider is connected, which is the state where that field is read only anyway. The copy button beside it still copies the real address.

This is a display preference on your own screen, not access control. It does not mask addresses on the Team page, in invites, in emails, or in any API response, and it changes nothing for anyone else, because every account carries its own switch. It hides your address from someone reading over your shoulder, not from your workspace.

Deleting your account

Account deletion is dashboard-only and has no public API route. API overview lists it as deliberately dashboard-only and it is: the public API-key surface has exactly one account endpoint, GET /me, which returns your id, name, email and avatar alongside the key's workspace id and metadata, and writes nothing. Deletion lives on an internal endpoint, and that surface rejects an ev_sk_ key outright with 401 and error.code auth.unauthorized, whose message starts auth.api_key_not_accepted:. There is no public delete, no CLI command, and no SDK method. The button on the Account page is the way to trigger it.

Three gates stand in front of the button:

GateBehavior
Account ageThe account has to be older than 7 days. Below that the button is disabled with the tooltip Account must be older than 7 days to delete., and the endpoint refuses anyway with 400 and You cannot delete your account within 7 days of creating it.
Typed confirmationThe dialog asks you to type your account email exactly. It is trimmed and compared case-sensitively, and the Confirm button stays inert until it matches
Step-up verificationDeletion is an account-scoped step-up guarded action. Without a live account elevation the endpoint answers 403 with auth.step_up_required and This action needs a verification code to continue., and the dashboard opens the code dialog, then retries the deletion once you verify. The gate applies whether you hold workspaces or none, and an elevation earned inside a workspace does not satisfy it

Confirming runs one pass and does all of this:

TargetWhat happens
Your user record, provider record and user data recordMarked deleted
Sign-in codes issued to your addressMarked deleted
Your avatar fileDeleted from the CDN bucket
Workspaces you ownDeleted whole. Products, projects, environments, variables, product access grants, memberships, roles, API keys, CLI tokens, webhook endpoints, pending invites and the subscription record are all marked deleted, and any custom domain is moved to a removing state
Workspaces you only joinedOnly your membership is removed, the workspace itself is untouched
Your Resend contact and your authentication-service accountDeleted
Your Polar customer recordDeleted, when the account has one

Then the dashboard signs you out. Rows are marked deleted rather than dropped from the database, but nothing in the product reverses that: there is no restore, no grace period after the click, and no self-serve undo. The 7 day rule is the only delay anywhere in the flow.

There is no ownership transfer in Envless. A workspace you own is deleted along with your account, and everyone else on it loses access to every product, project and variable in it. If a teammate should keep the work, move it into a workspace they own before you delete, because nothing on this page hands a workspace over.

Deleting the account skips the guards that protect a workspace delete. Deleting a workspace on its own is refused while it still holds projects (Delete every project in this workspace before deleting it.), while it is younger than 7 days (You cannot delete a workspace within 7 days of creating it.), and when it is your last one, but account deletion calls the same delete directly, without those checks. Billing is closed either way: a workspace delete cancels that workspace's subscription, and an account delete removes your Polar customer record, which takes every subscription on it. Neither is refunded. See Plans and billing.