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.
npx skills add caffeinelabs/skills --skill extension-email-verification -a claude-codeWhat 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-code2Manually: put the files in place
~/.claude/skills/extension-email-verification/SKILL.mdCopy the whole skill folder; for one project use .claude/skills/extension-email-verification/
1Skills CLI (Vercel)
npx skills add caffeinelabs/skills --skill extension-email-verification -a codex2Manually: put the files in place
.agents/skills/extension-email-verification/SKILL.mdFor all projects use ~/.agents/skills/
1Skills CLI (Vercel)
npx skills add caffeinelabs/skills --skill extension-email-verification -a cursor2Manually: put the files in place
.cursor/skills/extension-email-verification/SKILL.mdCursor also reads .agents/skills/ and .claude/skills/
1GitHub CLI
gh skill install caffeinelabs/skills extension-email-verification2Manually: put the files in place
.github/skills/extension-email-verification/SKILL.md1Skills CLI (Vercel)
npx skills add caffeinelabs/skills --skill extension-email-verification -a gemini-cli2Platform CLI
gemini skills install https://github.com/caffeinelabs/skills --path skills/extension-email-verificationManually: put the files in place
.devin/skills/extension-email-verification/SKILL.mdThe legacy .windsurf/skills/ path still works
Skills CLI (Vercel)
npx skills add caffeinelabs/skills --skill extension-email-verification -a opencodeClaude.ai
Zip the folder and upload it in Settings → Capabilities → Skills
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