跳转到内容
Integration

使用一个中间件文件阻止 Next.js 中的一次性电子邮件

| 6 min read

一个 40 行的 Next.js 中间件,调用 botoi API 来拒绝来自临时电子邮件地址的注册。 复制、粘贴、部署。

Email inbox interface on a laptop
Photo by Stephen Phillips on Unsplash

用户注册 test92847@mailinator.com,在您的免费试用期结束后消失。 他们明天回来 test92848@mailinator.com 然后再做一次。 您的支持队列充满了虚拟帐户。 你的分析显示用户数量膨胀,毫无意义。 您的滥用检测触发得太晚,因为该帐户已经消耗了资源。

解决方法:在注册请求到达您的数据库之前,在门口阻止一次性电子邮件。 本指南展示了如何在 Next.js 中使用单个中间件文件和除 fetch 调用之外的零依赖项来完成此操作。

你将构建什么

Next.js 中间件,拦截对您的注册端点的 POST 请求, 从请求正文中提取电子邮件,并根据 botoi 一次性电子邮件 API, 如果电子邮件属于一次性服务,则返回 422 响应。 整个文件不到 50 行。

中间件

创造 middleware.ts 在您的项目根目录(或 src/middleware.ts 如果你使用 src 目录):

import { NextRequest, NextResponse } from 'next/server';

const BOTOI_URL = 'https://api.botoi.com/v1/disposable-email/check';

export async function middleware(req: NextRequest) {
  // Only intercept POST requests to the signup route
  if (req.method !== 'POST') {
    return NextResponse.next();
  }

  let body: { email?: string };
  try {
    body = await req.json();
  } catch {
    return NextResponse.json(
      { error: 'Invalid request body' },
      { status: 400 }
    );
  }

  const email = body.email?.trim().toLowerCase();
  if (!email) {
    return NextResponse.next();
  }

  try {
    const res = await fetch(BOTOI_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email }),
    });

    const data = await res.json();

    if (data.success && data.data.is_disposable) {
      return NextResponse.json(
        { error: 'Disposable email addresses are not allowed. Please use a permanent email.' },
        { status: 422 }
      );
    }
  } catch {
    // API unreachable; fail open so real users aren't blocked
    console.warn('botoi disposable-email check failed, allowing request through');
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/api/auth/signup', '/api/register'],
};

它是如何运作的

路线匹配

这 config.matcher 数组告诉 Next.js 哪些路由触发此中间件。 更改这些路径以匹配您的注册端点。 在路由处理程序执行之前,中间件在边缘运行, 因此被拒绝的请求永远不会触及您的数据库或身份验证提供商。

电子邮件提取

中间件读取请求正文 req.json() 并拉动 email 场地。 如果解析失败或不存在电子邮件,则请求将不受影响地通过。 这使得中间件对非注册路由不可见。

API 调用

单个 POST 至 https://api.botoi.com/v1/disposable-email/check 正文中包含电子邮件。 响应内容包括:

{
  "success": true,
  "data": {
    "email": "throwaway@mailinator.com",
    "domain": "mailinator.com",
    "is_disposable": true,
    "is_free": false,
    "provider": "Mailinator"
  }
}

这 is_disposable 旗帜是门。 当它是 true,中间件返回 422 并带有明确的消息。 当它是 false, NextResponse.next() 让请求继续发送到您的注册处理程序。

故障开放设计

这 catch 阻止 fetch 调用意味着网络故障、超时或 API 停机不会中断注册。 中间件记录警告并让请求通过。 您的用户永远不会看到由第三方中断引起的错误。

处理边缘情况

超时

由于 botoi API 使用内存中的域列表,因此它对大多数请求的响应时间都在 50 毫秒以内。 如果你想要一个硬超时,请将 fetch 包裹起来 AbortSignal.timeout():

const res = await fetch(BOTOI_URL, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email }),
  signal: AbortSignal.timeout(3000), // 3 second timeout
});

速率限制

免费套餐每分钟允许 5 个请求。 如果您的应用程序处理的注册数量多于此, 获取 API 密钥 并将其作为不记名令牌传递:

const res = await fetch(BOTOI_URL, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': \`Bearer \${process.env.BOTOI_API_KEY}\`,
  },
  body: JSON.stringify({ email }),
});

店铺 BOTOI_API_KEY 在你的 .env.local 文件。 切勿将其提交给版本控制。

重复检查

如果同一封电子邮件连续两次到达您的注册端点(双击,重试逻辑), 您将对同一域进行两次 API 调用。 对于大多数应用程序来说这很好。 如果重要的话,添加一个短期缓存(如下所述)。

生产强化

添加内存缓存

将每个域的一次性检查结果缓存 5 分钟。 这减少了 API 调用并加快了对同一域的重复检查:

const cache = new Map<string, { isDisposable: boolean; expires: number }>();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes

async function isDisposableEmail(email: string): Promise<boolean> {
  const domain = email.split('@')[1];
  const cached = cache.get(domain);

  if (cached && cached.expires > Date.now()) {
    return cached.isDisposable;
  }

  try {
    const res = await fetch(BOTOI_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email }),
      signal: AbortSignal.timeout(3000),
    });

    const data = await res.json();
    const isDisposable = data.success && data.data.is_disposable;

    cache.set(domain, {
      isDisposable,
      expires: Date.now() + CACHE_TTL,
    });

    return isDisposable;
  } catch {
    return false; // fail open
  }
}

这种基于映射的缓存适用于无服务器和边缘运行时。 对于多实例部署,请将其替换为 Redis 或 Upstash:

import { Redis } from '@upstash/redis';

const redis = Redis.fromEnv();

async function isDisposableEmail(email: string): Promise<boolean> {
  const domain = email.split('@')[1];
  const cached = await redis.get<boolean>(\`disposable:\${domain}\`);

  if (cached !== null) {
    return cached;
  }

  try {
    const res = await fetch(BOTOI_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email }),
      signal: AbortSignal.timeout(3000),
    });

    const data = await res.json();
    const isDisposable = data.success && data.data.is_disposable;

    await redis.set(\`disposable:\${domain}\`, isDisposable, { ex: 300 });

    return isDisposable;
  } catch {
    return false;
  }
}

将公司域列入白名单

有些公司使用您永远不想阻止的自定义域,即使它们与可疑模式匹配。 添加允许名单:

const ALLOWED_DOMAINS = new Set([
  'yourcompany.com',
  'partner-corp.io',
  'bigclient.co',
]);

async function isDisposableEmail(email: string): Promise<boolean> {
  const domain = email.split('@')[1];

  if (ALLOWED_DOMAINS.has(domain)) {
    return false;
  }

  // ... rest of the check logic
}

记录被阻止的尝试

跟踪哪些域被拒绝,以便您可以监控滥用模式并调整您的策略:

if (data.success && data.data.is_disposable) {
  console.log(
    JSON.stringify({
      event: 'disposable_email_blocked',
      domain: data.data.domain,
      provider: data.data.provider,
      timestamp: new Date().toISOString(),
    })
  );

  return NextResponse.json(
    { error: 'Disposable email addresses are not allowed.' },
    { status: 422 }
  );
}

完整的中间件,全部硬化

这是包含缓存、白名单、超时和结构化日志记录的完整文件:

import { NextRequest, NextResponse } from 'next/server';

const BOTOI_URL = 'https://api.botoi.com/v1/disposable-email/check';
const CACHE_TTL = 5 * 60 * 1000;

const ALLOWED_DOMAINS = new Set([
  // Add your corporate or partner domains here
]);

const cache = new Map<string, { isDisposable: boolean; expires: number }>();

async function checkDisposable(email: string): Promise<{
  isDisposable: boolean;
  domain: string;
  provider: string | null;
}> {
  const domain = email.split('@')[1];

  if (ALLOWED_DOMAINS.has(domain)) {
    return { isDisposable: false, domain, provider: null };
  }

  const cached = cache.get(domain);
  if (cached && cached.expires > Date.now()) {
    return { isDisposable: cached.isDisposable, domain, provider: null };
  }

  try {
    const res = await fetch(BOTOI_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email }),
      signal: AbortSignal.timeout(3000),
    });

    const data = await res.json();
    const isDisposable = data.success && data.data.is_disposable;
    const provider = data.data?.provider ?? null;

    cache.set(domain, { isDisposable, expires: Date.now() + CACHE_TTL });

    return { isDisposable, domain, provider };
  } catch {
    return { isDisposable: false, domain, provider: null };
  }
}

export async function middleware(req: NextRequest) {
  if (req.method !== 'POST') {
    return NextResponse.next();
  }

  let body: { email?: string };
  try {
    body = await req.json();
  } catch {
    return NextResponse.json(
      { error: 'Invalid request body' },
      { status: 400 }
    );
  }

  const email = body.email?.trim().toLowerCase();
  if (!email || !email.includes('@')) {
    return NextResponse.next();
  }

  const result = await checkDisposable(email);

  if (result.isDisposable) {
    console.log(
      JSON.stringify({
        event: 'disposable_email_blocked',
        domain: result.domain,
        provider: result.provider,
        timestamp: new Date().toISOString(),
      })
    );

    return NextResponse.json(
      { error: 'Disposable email addresses are not allowed. Please use a permanent email.' },
      { status: 422 }
    );
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/api/auth/signup', '/api/register'],
};

本地测试

启动 Next.js 开发服务器并使用已知的一次性电子邮件发出请求:

curl -X POST http://localhost:3000/api/auth/signup \\
  -H "Content-Type: application/json" \\
  -d '{"email": "test@mailinator.com", "password": "hunter2"}'

预期回应:

{
  "error": "Disposable email addresses are not allowed. Please use a permanent email."
}

尝试发送合法电子邮件以确认其通过:

curl -X POST http://localhost:3000/api/auth/signup \\
  -H "Content-Type: application/json" \\
  -d '{"email": "dev@acme-corp.com", "password": "hunter2"}'

此请求照常到达您的注册处理程序。

何时也检查客户端

中间件捕获服务器上的一次性电子邮件。 但您也可以从注册表单中调用相同的 API 在用户提交*之前*显示内联错误。 电子邮件字段失去焦点后的快速客户端检查 节省用户的往返时间并提供更快的反馈:

async function validateEmail(email: string): Promise<string | null> {
  const res = await fetch('https://api.botoi.com/v1/disposable-email/check', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email }),
  });

  const data = await res.json();

  if (data.success && data.data.is_disposable) {
    return 'Please use a permanent email address.';
  }

  return null; // no error
}

中间件仍然充当权威门户。 客户端检查是一种用户体验改进,而不是一种安全措施。

FAQ

这可以与 Next.js App Router 一起使用吗?
是的。 Next.js 中间件在任何路由处理程序之前运行,无论您使用的是 App Router 还是 Pages Router。 middleware.ts 文件位于项目根目录(如果使用 src 目录,则位于 src/ 内),它会在到达 API 路由或服务器操作之前拦截对匹配路径的请求。
我需要 API 密钥来进行 botoi 一次性电子邮件检查吗?
不需要。免费套餐每分钟允许 5 个请求,无需 API 密钥。 对于处理更多注册的生产应用程序,请从 botoi API 文档页面获取密钥以解锁更高的速率限制。
如果 botoi API 关闭会发生什么?
中间件捕获网络错误并让请求通过。 这种故障开放方法意味着临时 API 中断永远不会阻止合法用户注册。 您可以添加日志记录以跟踪回退行为何时启动。
我可以将其与 Remix 或 SvelteKit 等其他框架一起使用吗?
API 调用本身可以在任何服务器端环境中工作。 这里显示的中间件模式是 Next.js 特定的,但核心逻辑(POST 到端点,检查响应中的 is_disposable)直接转换为 Remix 加载器、SvelteKit 挂钩或 Express 中间件。
一次性电子邮件检测的准确度如何?
端点检查 700 多个已知一次性域的列表,并使用模式匹配来捕获变化。 它还可以将免费电子邮件提供商(Gmail、Outlook、Yahoo)与一次性电子邮件提供商分开识别,以便您可以区分个人 Gmail 和一次性 Mailinator 地址。

开始使用 botoi 构建

150+ 个 API 端点,涵盖查询、文本处理、图片生成和开发者工具。免费套餐,无需信用卡。