Skip to content
Esc
↑↓navigate↵open⌘Jpreview
On this page

Use a member's email

Read the signed-in person's verified email in your App's server code to match them to your own records or send them mail, without asking them to type it.

Read the address in server code

When a signed-in person calls one of your App’s operations, the handler reads their verified email as env.user.email. Use it to find the person in a member list your App keeps, or to address mail to them. They signed in once, so they never type the address again.

The address reaches server code only. window.charming.user in the browser carries id, handle, name, and image, and never email, so your UI cannot read it and the page never holds it. If the UI needs to show it, return it from an operation.

What env.user holds

A route handler receives (input, { env, ctx, request }). env.user depends on who is behind the call:

Caller env.user
A signed-in person with a verified email, calling from the App’s page on Charming or on its custom domain, with a personal access token or bearer token, or through query_app or mutate_app { id, handle?, name?, image?, email, emailVerified: true }
The same callers when the email is not verified { id, handle?, name?, image?, emailVerified: false }, with no email
A person named only by a user-bound render token: an App opened from a URL an agent handed out, a chat host’s embed, or an owner or collaborator on the App’s custom domain in a browser without Fetch Metadata { id, handle?, name?, image?, emailVerified: false }, with no email, even when their address is verified, because anyone holding the token can replay it
A signed-out visitor null
A Routine tick or a webhook delivery null
A call with an App token, or with the App page’s shared render token null
A request the browser makes by opening an operation’s URL, or that another site sends null

email is never an unverified string: when emailVerified is false, the field is absent. A new App account made through an App’s sign-in popup proved its address with a code, so its email is verified. The popup also offers sign-in with a Charming password, Google, or GitHub, which follow the rules below. A Google sign-in is verified when Google marks the address verified, and a GitHub sign-in when GitHub lists it as a verified address on the account. A person who signed up with a password verifies their address with the code Charming emails them.

When the identity reaches a handler

A person who owns the App, holds a share of it, belongs to its team, or can run it through its signed-in access setting reaches every handler as themselves. A signed-in person with none of those, such as someone visiting a public App, reaches a handler as themselves only when the App has at least one write route with callers: 'signed-in' or callers: 'anyone'. Without such a route, their calls run as an anonymous visitor: env.user is null in every handler, even though window.charming.user names them. Marking a route readOnlyHint: true does not change this, because a read-only route is open to everyone who can view the App and never counts as opened beyond members.

A request the browser makes by opening an operation’s URL, or that another site sends, reaches every handler with env.user as null. Only the call your App’s own page makes carries the person. A browser without Fetch Metadata (Safari before 16.4, Chrome before 76, Firefox before 90) sends calls the same way, so a person on one of those reaches handlers with env.user as null. On a custom domain, a browser without Fetch Metadata names owners and collaborators only by the page’s render token, so they see email_needs_sign_in. A custom-domain page that opened signed-out reaches handlers as the person right after they sign in, with no reload.

Inside a chat host’s embed, a visitor’s calls reach the App without their account: a 'signed-in' route answers sign_in_required, and a handler that does run sees env.user as null. Offer the operation on the App’s own page.

Guard an operation that needs the address

If an operation cannot work without the address, start its handler with two lines:

if (!env.user) throw new CharmingHandlerError({ kind: 'forbidden', status: 403 });
if (!env.user.email) throw new CharmingHandlerError({ kind: 'email_not_verified', status: 403 });

The first line refuses a caller with no account. forbidden is the kind the browser runtime treats as “signing in would fix this”: when charming.user is null, window.charming.withUser(action) opens the sign-in popup and runs the action again. A guard that tested only env.user?.email would tell a signed-out visitor “The user needs to verify their email.” and never offer sign-in. A person who is already signed in gets no retry, so forbidden for them means their account did not reach the handler (see above).

The second line answers a caller with no address. The App API answers 403 with { ok: false, error: { kind: 'email_not_verified', message: 'The user needs to verify their email.' } }. Charming sets that status and message whatever the handler passes. MCP query_app and mutate_app return the same kind as error_kind.

A caller named only by a user-bound render token (the third row above) gets a different answer to the same guard: 403 email_needs_sign_in with “Open this App in Charming while signed in to use your email.” Their address may be verified. The token just cannot prove it, so telling them to verify would be wrong.

In the browser, the call rejects with a CharmingOperationError carrying that kind. Wrap the call in withUser so a signed-out visitor is asked to sign in, and tell the person what to do for each kind:

try {
  const membership = await charming.withUser(() => api.myMembership());
  if (membership !== null) render(membership);
} catch (err) {
  if (err.name !== 'CharmingOperationError') throw err;
  if (err.kind === 'email_not_verified') {
    status.textContent = 'Verify your email in Charming to use this feature.';
  } else if (err.kind === 'email_needs_sign_in') {
    status.textContent = 'Open this App in Charming while signed in to use this feature.';
  } else {
    throw err;
  }
}

A route with callers: 'signed-in' answers a signed-out visitor sign_in_required before the handler runs, and withUser handles that kind too. The forbidden line matters for a read-only operation, which is open to everyone who can view the App, and for a route with callers: 'anyone'. See Gate an action behind sign-in for the full pattern.

Match a member to your own records

This App keeps its own member list in storage and shows each signed-in person their own row. The people on the list are not members of the App, only visitors who sign in, so the lookup must be a write route with callers: 'signed-in': that is what makes Charming carry their account to the handler (see When the identity reaches a handler). The route is not marked readOnlyHint: true, because a read-only route would never carry their account unless another route of the App did. Make the App public, or open it to signed-in visitors in its Access settings, so a signed-in visitor can view it and call the route. Compare addresses without case, because a list typed by hand may not match the case of the signed-in address.

{
  "name": "member-desk",
  "version": "1.0.0",
  "type": "module",
  "dependencies": {},
  "charming": { "name": "Member desk" }
}
import { kv } from 'charming:storage/[email protected]';

export const routes = [
  {
    op: 'myMembership',
    callers: 'signed-in',
    handler: async (_input, { env }) => {
      if (!env.user) throw new CharmingHandlerError({ kind: 'forbidden', status: 403 });
      if (!env.user.email) throw new CharmingHandlerError({ kind: 'email_not_verified', status: 403 });
      const email = env.user.email.toLowerCase();
      const members = (await kv.get('members')) ?? [];
      const member = members.find((row) => row.email.toLowerCase() === email);
      return member ? { member: true, plan: member.plan, since: member.since } : { member: false };
    },
  },
];

Return only the caller’s own row. Every signed-in visitor reaches this operation, so a handler that returned the whole list would show every member’s address to anyone who signed in.

Send the signed-in member an email

With the Gmail connector, a handler can mail the person who called it. Declare GMAIL_SEND_EMAIL in package.json and pass env.user.email as the recipient. This operation writes, so only the App’s members can call it:

{
  "name": "member-desk",
  "version": "1.0.0",
  "type": "module",
  "dependencies": {},
  "charming": {
    "name": "Member desk",
    "permissions": { "server": { "connectors": { "gmail": ["GMAIL_SEND_EMAIL"] } } }
  }
}
import { connectors } from 'charming:[email protected]';
import { kv } from 'charming:storage/[email protected]';

export const routes = [
  {
    op: 'emailMyReceipt',
    annotations: { readOnlyHint: false, destructiveHint: false },
    handler: async (_input, { env }) => {
      if (!env.user) throw new CharmingHandlerError({ kind: 'forbidden', status: 403 });
      if (!env.user.email) throw new CharmingHandlerError({ kind: 'email_not_verified', status: 403 });
      const receipt = await kv.get(`receipt:${env.user.id}`);
      if (!receipt) return { sent: false };
      const recipient = env.user.email;
      await connectors.call('gmail', 'GMAIL_SEND_EMAIL', sendEmailArguments(recipient, receipt));
      return { sent: true };
    },
  },
];

sendEmailArguments is yours to write. It returns the arguments GMAIL_SEND_EMAIL takes, which the Gmail toolkit page lists, with recipient in the argument that names the recipient. A call whose arguments the service rejects throws connector_unavailable.

Charming picks whose Gmail sends the message from who is calling: the caller’s own Gmail if they approved the App, or the account the owner shares. To send every message from your own Gmail, open App settings → Connectors, turn on Let others use your Gmail here with write for the roles you need, and turn off People can use their own Gmail. Connector calls need a person calling on Charming or through their agent, so in an App opened from a URL an agent handed out, the guard answers email_needs_sign_in before the call runs. Connectors lists every error kind a call can throw.

Privacy

An App’s server code can read the verified email of any signed-in person whose account reaches the handler, with no extra prompt. Charming does not ask the person to agree first. Many callers never see the App’s sign-in popup: people already signed in to Charming, agents calling through query_app or mutate_app, and people on a custom domain. Where the popup does appear, it names the App only when the App is public or open to signed-in visitors, and says “this app” otherwise. Tell people in your App when you use their address, and write handlers that work when email is absent, because an account whose address is not verified has none.

Treat the address like any personal data your App stores: keep only what the operation needs, and do not return other people’s addresses to a caller.

Technical

Copy this prompt for your agent

In my Charming app, read the signed-in person's verified email in
server code as env.user.email and use it for <what you need>. Read
https://charm.ing/docs/guides/use-a-members-email.md first, and guard
the operation so an unverified caller gets email_not_verified.

The contract

  • env.user is { id, handle?, name?, image?, email?, emailVerified } or null. email is present only when emailVerified is true.
  • window.charming.user never carries email or emailVerified.
  • A handler that throws CharmingHandlerError with kind: 'email_not_verified' answers 403 with the message “The user needs to verify their email.”, whatever status and message it passed. When a user-bound render token named the caller, the answer is 403 email_needs_sign_in with “Open this App in Charming while signed in to use your email.” instead.
  • Under charming dev, put email in the --user file to give env.user a verified address, and add "emailVerified": false to try the unverified case.

Was this page helpful?