跳转到内容
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 端点,涵盖查询、文本处理、图片生成和开发者工具。免费套餐,无需信用卡。