---
title: "Use a member's email"
description: "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."
sidebar:
  label: "Use a member's email"
  order: 11
---

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

Sign-in on a custom domain follows the person's Charming session: it renews while they use the App there, and ends when they sign out of Charming.

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:

```js
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:

```js
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](/docs/guides/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](#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.

```json
{
  "name": "member-desk",
  "version": "1.0.0",
  "type": "module",
  "dependencies": {},
  "charming": { "name": "Member desk" }
}
```

```js
import { kv } from 'charming:storage/kv@1.0';

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](/docs/capabilities/connectors), 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:

```json
{
  "name": "member-desk",
  "version": "1.0.0",
  "type": "module",
  "dependencies": {},
  "charming": {
    "name": "Member desk",
    "permissions": { "server": { "connectors": { "gmail": ["GMAIL_SEND_EMAIL"] } } }
  }
}
```

```js
import { connectors } from 'charming:connectors@1.0';
import { kv } from 'charming:storage/kv@1.0';

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](https://docs.composio.dev/toolkits/gmail) 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](/docs/capabilities/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

```text
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.

## Related

- [Gate an action behind sign-in](/docs/guides/gate-an-action-behind-sign-in): ask a visitor to sign in only when an action needs it
- [Let visitors call an operation](/docs/guides/let-visitors-call-an-operation): `callers` and who can reach an operation
- [Connectors](/docs/capabilities/connectors): calling Gmail and other services from server code

Found a bug or need a feature? [Tell us](/docs/capabilities/feedback) with `submit_feedback` or `POST /app/{id}/feedback`. Your feedback shapes what we build next.
