Skip to main content
skill CX Codex

extension-email-verification — Claude Code skill: click-to-verify

Adds email address verification to a Caffeine AI app: the recipient clicks a link and the address is marked as verified.

Install into Claude Code · Skills CLI (Vercel) All methods and platforms ↓
npx skills add caffeinelabs/skills --skill extension-email-verification -a claude-code
Open on GitHub

What extension-email-verification does

The skill covers the whole verification path. The backend mints a token, stores it in verificationTokens and sends an email pointing at the app's own /verify-email page. When the recipient presses the button, _caffeineEmailConfirmVerification(token) runs and the address lands in verifiedEmails. To check status, call contains from verifiedEmails.mo — do not track verification against the user profile yourself.

Setup has five mandatory steps: add caffeineai-email-verification and caffeineai-email with mops add, declare two state fields in the migration chain head, include MixinEmailVerification(verifiedEmails, verificationTokens) in main.mo, send through EmailVerification.sendVerificationEmail(...), and expose a public /verify-email route on the frontend with the @caffeineai/email-verification npm package. The email body must contain {{VERIFICATION_URL}}, otherwise the function returns #err.

Tokens are single use and expire after 24 hours. Anyone holding a valid token can confirm the address, anonymous principals included, so the recipient needs no account. Mail goes out through the mail gateway using the settings the platform injects on every deploy (INTEGRATIONS_GATEWAY_URL and INTEGRATIONS_GATEWAY_API_KEY); without them the send returns #err. Skip the frontend route and the recipient hits a missing page, which means the address never gets verified.

What it helps with

  • prove a user owns an email address before creating an account
  • send a verification link that lands on the app's own frontend
  • check verification status without a separate profile field
  • issue a single-use token without hand-rolling storage
  • let an anonymous recipient confirm an address without signing in

Example prompts for the agent

  • Add email verification to this project with the extension-email-verification skill
  • Wire up MixinEmailVerification and send a verification email pointing at /verify-email
  • Create a public /verify-email route with a button that calls confirm()

How to install extension-email-verification

1Skills CLI (Vercel)

npx skills add caffeinelabs/skills --skill extension-email-verification -a claude-code

2Manually: put the files in place

~/.claude/skills/extension-email-verification/SKILL.md

Copy the whole skill folder; for one project use .claude/skills/extension-email-verification/

Step by step, with commands for each platform: How to install skills in Claude Code: five ways

Questions about extension-email-verification

How do I install extension-email-verification in Claude?

Run npx skills add caffeinelabs/skills --skill extension-email-verification -a claude-code. The skill lands in ~/.claude/skills/extension-email-verification/SKILL.md.

What does extension-email-verification do?

It adds click-to-verify email confirmation: the backend mints a token and sends the email, and the frontend confirms the address from the /verify-email page.

What has to be in place before using the skill?

Two mops dependencies, caffeineai-email-verification and caffeineai-email, plus the @caffeineai/email-verification npm package and a public /verify-email route. The email body must include the {{VERIFICATION_URL}} placeholder.

Source text

SKILL.md · Apache-2.0
---
name: extension-email-verification
description: Support for sending an email with a link the recipient can click to prove they own the email address.
version: 0.3.0
compatibility:
  mops:
    caffeineai-email-verification: "~0.3.0"
    caffeineai-email: "~0.4.0"
  npm:
    "@caffeineai/email-verification": "~0.3.0"
caffeineai-subscription: [plus, pro]
---

# Email — Verification
Email verification extension for [Caffeine AI](https://caffeine.ai?utm_source=caffeine-skill&utm_medium=referral).

## Overview

This skill adds email address verification via a click-to-verify link that lands on the app's own frontend. The backend mints and stores the link token (`verificationTokens`) and confirms it through `MixinEmailVerification`; `verifiedEmails` tracks verified addresses. The frontend renders the `/verify-email` page, where the recipient clicks a button to confirm.

## Required Setup Checklist

All five steps are mandatory.

1. **mops dependencies** — `mops add caffeineai-email-verification` and `mops add caffeineai-email`.
2. **Two stable state fields** — `verifiedEmails : VerifiedEmails.State` and `verificationTokens : VerificationTokens.State`, initialised in the migration chain head with `VerifiedEmails.new()` and `VerificationTokens.new()`.
3. **Mixin invocation** — `include MixinEmailVerification(verifiedEmails, verificationTokens)` in `main.mo`.
4. **Sending** — `EmailVerification.sendVerificationEmail(verificationTokens, fromUsername, recipients, subject, htmlBody)`; the body MUST contain `{{VERIFICATION_URL}}`.
5. **Frontend npm package and route** — `@caffeineai/email-verification` installed, and a public route `/verify-email` (reachable without signing in) that renders a button calling `confirm()`.

CRITICAL: the link in the email is `https://<app domain>/verify-email?token=…`. Without the frontend route the recipient lands on a missing page and the address is never verified.

# Backend

## This component is for sending an email to users with a verification link which the user can click to prove they own the email address.

### To check if an email address has been verified

Use the prefabricated module `mo:caffeineai-email-verification/verifiedEmails.mo` which cannot be modified.

```mo:caffeineai-email-verification/verifiedEmails.mo
module {
  public type State = {
    verifiedEmails : Set.Set<Text>;
  };

  public func new() : State;

  public func contains(state : State, email : Text) : Bool;

  public func iter(state : State) : Iter.Iter<Text>;

  public func size(state : State) : Nat;
};
```

To check whether an email is verified use the `contains` function. Do NOT try to track the email verification status independently by storing it against the user profile.

### Pending verification links

Use the prefabricated module `mo:caffeineai-email-verification/verificationTokens.mo` which cannot be modified. It holds the tokens of links sent but not yet clicked; a token is single use and expires after 24 hours. The app only declares the state and passes it around.

```mo:caffeineai-email-verification/verificationTokens.mo
module {
  public type State = {
    tokens : Map.Map<Text, { email : Text; createdAt : Time.Time }>;
  };

  public func new() : State;

  public func size(state : State) : Nat;
};
```

### To handle the verification link

Use the prefabricated module `mo:caffeineai-email-verification/verificationMixin.mo` which cannot be modified.

`MixinEmailVerification` takes both states. It adds `_caffeineEmailConfirmVerification(token)`, which the app's frontend calls from the verification page: it marks the address the token was sent to as verified and returns it. Anyone holding a valid token can call it, the anonymous principal included, so the recipient needs no account.

```mo:caffeineai-email-verification/verificationMixin.mo
import MixinEmailVerification "mo:caffeineai-email-verification/verificationMixin";

mixin (verifiedEmails : VerifiedEmails.State, verificationTokens : VerificationTokens.State) {
  public shared func _caffeineEmailConfirmVerification(token : Text) : async { #ok : Text; #err : Text };
};
```

### For sending users a verification email

- Use the `sendVerificationEmail` function of the prefabricated module `mo:caffeineai-email-verification/verification.mo` which cannot be modified. Do NOT call `EmailClient.broadcast` from app code: this module prepares the tokens and the link template it sends.
- It returns a SendResult which is #ok if the email is sent successfully otherwise #err(error) with the error text.
- Each recipient receives an individual email with a specific verification link for them, pointing at the app's `/verify-email` page.
- The htmlBody MUST contain the placeholder text {{VERIFICATION_URL}}; the function returns #err when it is missing.
- The mail goes through the mail gateway with the settings the platform injects on every deploy (`INTEGRATIONS_GATEWAY_URL` / `INTEGRATIONS_GATEWAY_API_KEY`); without them the function returns `#err`.

```mo:caffeineai-email-verification/verification.mo
module {
  public func sendVerificationEmail(
    tokens : VerificationTokens.State,
    fromUsername : Text,
    recipients : [Text],
    subject : Text,
    htmlBody : Text,
  ) : async { #ok; #err : Text };
};
```

### Example usage with endpoints for registering a user and for checking whether a user is verified.

```motoko filepath=src/backend/main.mo
import Map "mo:core/Map";
import Runtime "mo:core/Runtime";
import Principal "mo:core/Principal";
import Text "mo:core/Text";
import EmailVerification "mo:caffeineai-email-verification/verification";
import MixinEmailVerification "mo:caffeineai-email-verification/verificationMixin";
import VerificationTokens "mo:caffeineai-email-verification/verificationTokens";
import VerifiedEmails "mo:caffeineai-email-verification/verifiedEmails";

actor {
  // Stores which emails are verified
  let verifiedEmails : VerifiedEmails.State;

  // Pending verification links, until clicked or expired
  let verificationTokens : VerificationTokens.State;

  // User profiles storage
  let users : Map.Map<Principal, User>;

  // Email to principal mapping for uniqueness check
  let emailToPrincipal : Map.Map<Text, Principal>;

  // Handles the verification link and updates the verifiedEmails store
  include MixinEmailVerification(verifiedEmails, verificationTokens);

  type User = {
    name : Text;
    email : Text;
  };

  public shared ({ caller }) func registerUser(email : Text, name : Text) : async () {
    if (users.containsKey(caller)) {
      Runtime.trap("User already registered");
    };
    if (emailToPrincipal.containsKey(email)) {
      Runtime.trap("Email already registered");
    };

    let user : User = {
      name;
      email;
    };
    users.add(caller, user);
    emailToPrincipal.add(email, caller);
    let result = await EmailVerification.sendVerificationEmail(
      verificationTokens,
      "no-reply",
      [email],
      "Welcome to Our Service",
      "Hello " # name # ",<br><br>Thank you for registering with our service. Please <a href=\"{{VERIFICATION_URL}}\">click here</a> to verify your email address<br><br>Best regards,<br>The Team",
    );

    switch (result) {
      case (#ok) {};
      case (#err(error)) {
        Runtime.trap("Couldn't send verification email: " # error);
      };
    };
  };

  public shared ({ caller }) func isEmailVerified() : async Bool {
    let user = users.get(caller) ?? Runtime.trap("User not registered");
    VerifiedEmails.contains(verifiedEmails, user.email);
  };
};
```

The migration chain head:

```motoko filepath=src/backend/migrations/00000000_000000.mo
import Map "mo:core/Map";
import VerificationTokens "mo:caffeineai-email-verification/verificationTokens";
import VerifiedEmails "mo:caffeineai-email-verification/verifiedEmails";

module {
  type User = {
    name : Text;
    email : Text;
  };

  type NewActor = {
    verifiedEmails : VerifiedEmails.State;
    verificationTokens : VerificationTokens.State;
    users : Map.Map<Principal, User>;
    emailToPrincipal : Map.Map<Text, Principal>;
  };

  public func migration(_old : {}) : NewActor {
    {
      verifiedEmails = VerifiedEmails.new();
      verificationTokens = VerificationTokens.new();
      users = Map.empty<Principal, User>();
      emailToPrincipal = Map.empty<Text, Principal>();
    };
  };
};
```

## Upgrading from the previous version

An app built with `caffeineai-email-verification` 0.2.x re-pins with `mops add [email protected]` and `mops add [email protected]`, and bumps `@caffeineai/email-verification` to 0.3.0. Nothing in the app code changes. The mixin no longer has the `_caffeineEmailVerify` callback of the transport canister: verification mail now always goes through the mail gateway, and the links in mail the transport sent before 0.2.0 stop working.

An app built with `caffeineai-email-verification` 0.1.x and `caffeineai-email` 0.2.x needs all of these, then the re-pin above:

1. Re-pin: `mops add [email protected]` and `mops add [email protected]`.
2. Declare the new stable field `verificationTokens : VerificationTokens.State` and add it to the migration chain (a new migration file initialising it with `VerificationTokens.new()`; `verifiedEmails` keeps its shape and its data).
3. Change `include MixinEmailVerification(verifiedEmails)` to `include MixinEmailVerification(verifiedEmails, verificationTokens)`.
4. Replace `EmailClient.sendVerificationEmail(fromUsername, recipients, subject, htmlBody)` with `EmailVerification.sendVerificationEmail(verificationTokens, fromUsername, recipients, subject, htmlBody)` (import `mo:caffeineai-email-verification/verification`).
5. Add the frontend package and the `/verify-email` route described below.

# Frontend

Install `@caffeineai/email-verification`. It exports the prefabricated hook `useEmailVerification`, which cannot be modified:

```typescript filepath=@caffeineai/email-verification/hooks/useEmailVerification.ts
export interface EmailVerificationActor {
  _caffeineEmailConfirmVerification(token: string): Promise<{ ok: string } | { err: string }>;
}

// ready: a token is in the URL and nothing was sent yet
// pending: the backend call is in flight
// done: the address is verified; `email` holds it
// invalid: the URL has no token, or the backend rejected it
// error: the backend call failed; `error` holds the message
export type EmailVerificationStatus = 'ready' | 'pending' | 'done' | 'invalid' | 'error';

export declare function useEmailVerification(actor: EmailVerificationActor | null | undefined): {
  status: EmailVerificationStatus;
  email: string | null;
  error: string | null;
  hasToken: boolean;
  // True once the actor is available, a token is present and nothing was sent yet
  isReady: boolean;
  // Confirms the address through the backend. Call it from a button click only.
  confirm: () => Promise<void>;
};
```

Rules for the verification page:

- Add a route at `/verify-email` that is reachable WITHOUT signing in: recipients open it from their mail client and have no session. The backend method takes the token as its only credential, so the anonymous actor from `useActor` is enough.
- Read the token with the hook (it takes `?token=` from the URL itself) and render a button that calls `confirm()`. NEVER call `confirm()` on page load or in an effect: mail clients, link previewers and scanners fetch the URL without a person behind it, and a fetch must verify nothing.
- Show the outcome from `status`: `done` with the verified `email`, `invalid` for a missing, used or expired link (offer to request a new email), `error` with the message.

Example page:

```typescript filepath=src/frontend/src/pages/VerifyEmailPage.tsx
import { useEmailVerification } from '@caffeineai/email-verification';
import { useActor } from '@caffeineai/core-infrastructure';
import { createActor } from '@/backend';

export function VerifyEmailPage() {
  const { actor } = useActor(createActor);
  const { status, email, error, isReady, confirm } = useEmailVerification(actor);

  if (status === 'done') return <p>{email} is verified. You can close this page.</p>;
  if (status === 'invalid') return <p>{error ?? 'This verification link is invalid or has expired.'}</p>;
  if (status === 'error') return <p>Something went wrong: {error}</p>;

  return (
    <button onClick={() => void confirm()} disabled={!isReady}>
      {status === 'pending' ? 'Verifying…' : 'Verify my email address'}
    </button>
  );
}
```

Register it with the app's TanStack Router alongside the other routes, as a child of the root route with `path: '/verify-email'`, outside any authenticated layout.

If there is a UI for the admin to enter the content of a verification email then indicate that the placeholder text {{VERIFICATION_URL}} must be present in the email body.

© caffeinelabs · Apache-2.0 · caffeinelabs/skills

Similar skills

All skills for Backend and APIs →

From the same set27

extension-invite-links Adds invite links and RSVP collection to your project. Guests submit answers using unique codes without logging in, while admins view all responses after sign-in. CX Codex +1 28,100installs extension-http-outcalls A skill for HTTP requests from a backend canister to external APIs. It keeps every request bounded on the provider side. CX Codex +1 28,100installs extension-email Enables agents to send transactional and service emails from Motoko backend canisters. Useful for notifications and order receipts. CX Codex +1 28,100installs extension-object-storage This skill adds file storage to your project: content lives off-chain while the backend keeps only references. Useful for image galleries, video and documents. CX Codex +1 28,100installs extension-oql Exposes Motoko canister data to Caffeine Data Intelligence agents via schema() and execute() endpoints. Useful for natural language queries across Maps and record arrays. CX Codex +1 27,700installs connector-googlemail Guides agents on sending emails through a user's own Gmail account inside Caffeine canisters. Enforces mops packages instead of raw HTTP requests. CX Codex +1 25,000installs extension-querying-oql Quick reference for querying an OQL backend canister via the icp CLI. Guides the agent through fetching schemas, structuring JSON queries, and reading Candid rows. CX Codex +1 24,700installs connector-googlecalendar A skill that pins down the one supported way to wire Google Calendar into a Caffeine build: the googlecalendar-client and google-oauth packages, not hand-rolled HTTP calls. CX Codex +1 21,900installs

Project sponsors

Companies that keep this analytics open
Gold sponsor

Impressions, clicks and code copies — open statistics. We keep the numbers public for the same reason NeBlask itself is public. Become a sponsor