toeverything/AFFiNE · error · EmailTokenNotFound

email_token_not_found

email_token_not_found

Error message

The email token provided is not found.

What it means

POST /api/auth/magic-link parses the JSON body with MagicLinkBodySchema - a strict object { email: string (max 320), token: string (1..512), client_nonce?: string (1..512) }. Any parse failure is deliberately mapped to EmailTokenNotFound (email_token_not_found) instead of a field-level validation error, to avoid leaking which part of the credential is wrong. Because the schema is .strict(), unknown extra fields also fail.

Solutions

  1. Send exactly { email, token, client_nonce? } as JSON with Content-Type: application/json
  2. Extract token from the actual magic-link URL parameter, not the whole URL
  3. Check field sizes: email <= 320 chars, token 1..512 chars
  4. Remove legacy extra fields (verifyToken, challenge) from the body

Example fix

// before
await fetch('/api/auth/magic-link', {
  method: 'POST',
  body: JSON.stringify({ email, code: otp, verifyToken }), // wrong key + legacy field
});

// after
await fetch('/api/auth/magic-link', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ email, token: otp }), // client_nonce only if you passed one at send time
});
Defensive patterns

Strategy: validation

Validate before calling

import { z } from 'zod';
const MagicLinkBody = z.object({
  email: z.string().min(1).max(320),
  token: z.string().min(1).max(512),
  client_nonce: z.string().min(1).max(512).optional(),
}).strict();
// throws locally with a useful message instead of opaque email_token_not_found
const body = MagicLinkBody.parse({ email, token, client_nonce });

Try / catch

try {
  await post('/auth/magic-link', body);
} catch (e) {
  if (isAffineErrorCode(e, 'email_token_not_found')) {
    showFormError('The sign-in code is missing or malformed. Open the newest email.');
  } else throw e;
}

Prevention

When it happens

Trigger: Missing the token field; sending legacy fields like verifyToken or challenge (rejected by .strict()); a token longer than 512 chars or email longer than 320; a Content-Type that does not parse to a JSON object (form-encoded string, plain text); wrong field name such as code instead of token.

Common situations: Older clients still posting captcha fields that were moved to headers; hand-rolled fetch calls with typo'd keys; tests sending urlencoded bodies; copy-pasting a magic-link URL instead of extracting the token parameter.

Related errors


AI-assisted analysis of toeverything/AFFiNE@2af30773ae (2026-08-18). Data as JSON: /api/errors/4b648eb1247baf30. Report an issue: GitHub.

Appendix: source

Thrown at packages/backend/server/src/core/auth/controller.ts:352

    this.assertSessionMutationAuthorized(req, session);
    await this.authSessions.revoke(
      parsedSessionId.data,
      'user_action',
      user.id
    );
    return {};
  }

  @Public()
  @UseNamedGuard('version')
  @Post('/magic-link')
  async magicLinkSignIn(
    @Req() req: Request,
    @Res() res: Response,
    @Body() body?: unknown
  ) {
    const credential = MagicLinkBodySchema.safeParse(body);
    if (!credential.success) throw new EmailTokenNotFound();
    const { email, token: otp, client_nonce: clientNonce } = credential.data;
    if (!email) throw new EmailTokenNotFound();
    validators.assertValidEmail(email);
    const result = await this.magicLink.complete(
      email,
      otp,
      clientNonce,
      this.sessionIssuer.target(req)
    );
    this.sessionIssuer.apply(res, result);
    res.send({ id: result.user.id, exchangeCode: result.exchangeCode });
  }

  @UseNamedGuard('version')
  @Throttle('default', { limit: 1200 })
  @Public()
  @Get('/session')
  @Header('Cache-Control', 'no-store')

View on GitHub (pinned to 2af30773ae)