🚀 手搓一个会“读心术”的邮件机器人:当 NestJS 遇上 LangChain,SMTP 不再冷冰冰

3 阅读5分钟

🚀 手搓一个会“读心术”的邮件机器人:当 NestJS 遇上 LangChain,SMTP 不再冷冰冰

摘要:还在苦哈哈地写 nodemailer 的配置?还在为忘记用户邮箱而头秃?今天,我们不写死板的 CRUD,我们来“手搓”一个拥有大脑的邮件助手。它不仅能查用户信息,还能在你一声令下(甚至是一个模糊的暗示)自动发送邮件。准备好了吗?让我们把 SMTP 协议玩出花来!

🎭 序幕:为什么我们要“手搓”?

在这个 AI 泛滥的年代,如果你的后端服务还只会机械地接收 POST /send-email 然后返回 200 OK,那未免太无趣了。

想象一下这样的场景:

用户:“嘿,帮我把上周的报表发给张三,顺便告诉他老板心情不错。” 传统后端:“错误 400:请提供张三的邮箱地址、报表文件路径及具体文本内容。” 我们的 AI 后端:“收到!已查询到张三邮箱,报表已附,并加了一句‘老板今天心情大好,放心享用’。发送成功!”

这就是 Function Calling (工具调用) 的魅力。今天,我们就利用 NestJS 的优雅架构和 LangChain 的大脑,手搓两个核心 Tool:query_user (查户口) 和 send_mail (送信),让代码活起来。

🛠️ 第一关:搭建舞台 (NestJS + Mailer)

首先,我们需要一个靠谱的邮差。虽然题目里提到了 HTTP 和 Nginx,但发邮件这事儿,还得靠老派的 SMTP 协议(特别是 QQ 邮箱这种老牌服务商)。别被“HTTP 不能使用 SMTP”吓到,我们的 NestJS 应用是运行在 HTTP 服务器上的,但它内部可以通过 TCP 连接去呼叫 SMTP 服务器。

1. 依赖安装

正如江湖传言,NestJS 和 @nestjs-modules/mailer 是天生一对:

pnpm i @nestjs-modules/mailer nodemailer

2. 配置邮差 (AppModule)

我们在 AppModule 中注入配置。这里有个小坑:端口

  • 465: 隐式 SSL (QQ 邮箱常用)。
  • 587: 显式 TLS (StartTLS)。

看我们的代码,如何优雅地从 .env 读取秘密情报:

// app.module.ts 片段
MailerModule.forRootAsync({
  inject: [ConfigService],
  useFactory: (configService: ConfigService) => ({
    transport: {
      host: configService.get<string>('MAILER_HOST'), // 比如 smtp.qq.com
      port: Number(configService.get<string>('MAILER_PORT')) || 465,
      secure: true, // 真加密,不玩虚的
      auth: {
        user: configService.get<string>('MAILER_USER'),
        pass: configService.get<string>('MAILER_PASS'), // 这里是授权码,不是登录密码哦!
      },
    },
    defaults: {
      from: `"AI 小秘书" <${configService.get<string>('MAILER_FROM')}>`,
    },
  }),
}),

💡 小贴士:如果你用的是 QQ 邮箱,记得去设置里开启 SMTP 服务,并获取那个长长的“授权码”。别把登录密码填进去,否则腾讯的安全中心会以为你被盗号了,直接把你封禁。

🧠 第二关:注入灵魂 (LangChain Tools)

光有邮差不够,我们得给 AI 装上“手”和“眼”。在 AiModule 中,我们通过 useFactory 动态创建两个强大的工具。

🔍 工具一:query_user (千里眼)

这个工具负责在不泄露密码的前提下,根据 ID 找到用户的邮箱。

// ai.module.ts 片段
{
  provide: QUERY_USER_SERVER,
  useFactory: (userServer: UserServer) => {
    const queryUserArgsSchema = z.object({
      userId: z.string().describe('用户ID,别搞错了,不然查不到人'),
    });
    return tool(
      async ({ userId }) => {
        const user = userServer.findOne(userId);
        if (!user) {
          // 幽默感时刻:不仅报错,还告诉你能查谁
          const availableIds = userServer.findAllUsers().map((u) => u.id).join(', ');
          return `用户 ${userId} 不存在。本系统只认识这些大佬: ${availableIds}`;
        }
        // 安全脱敏:密码绝对不能见光
        const { password: _p, ...safe } = user;
        return `用户 ${user.name} 的信息:${JSON.stringify(safe)}`;
      },
      {
        name: 'query_user',
        description: '根据用户ID查询用户信息(姓名、邮箱),不含密码。想发邮件前先用这个!',
        schema: queryUserArgsSchema,
      },
    );
  },
  inject: [UserServer],
},

📩 工具二:send_mail (飞毛腿)

这是重头戏。注意,我们允许传入 texthtml。AI 可以根据上下文决定是发纯文本还是富文本(比如带个表情符号 🎉)。

// ai.module.ts 片段
{
  provide: 'SEND_MAIL_TOOL',
  useFactory: (mailService: MailerService) => {
    const sendMailArgsSchema = z.object({
      to: z.string().describe('收件人邮箱,格式要对,不然退信很尴尬'),
      subject: z.string().describe('邮件主题,要吸引人'),
      text: z.string().optional().describe('邮件内容 纯文本版'),
      html: z.string().optional().describe('邮件内容 富文本版,可以加粗变色'),
    });
    return tool(
      async ({ to, subject, text, html }) => {
        await mailService.sendMail({
          to,
          subject,
          text: text ?? '',
          html: html ?? '',
        });
        return `邮件发送成功!已飞向 ${to} 🚀`;
      },
      {
        name: 'send_mail',
        description: '发送邮件:需提供收件人邮箱与主题。如果不知道邮箱,请先调用 query_user。',
        schema: sendMailArgsSchema,
      },
    );
  },
  inject: [MailerService],
},

⚡ 第三关:大脑回路 (AiService 流式处理)

有了工具,怎么让它们协同工作?这就轮到 AiService 登场了。我们使用 bindTools 将模型与工具绑定,并实现了一个流式处理的循环。

这个循环的逻辑非常性感:

  1. 用户提问 -> 发送给 LLM。
  2. LLM 思考 -> “嗯,用户想发邮件,但我没邮箱,我得先调用 query_user”。
  3. 返回 Tool Call -> 服务端捕获,执行 query_user
  4. 回填结果 -> 把查询到的邮箱告诉 LLM。
  5. LLM 再次思考 -> “好嘞,现在有邮箱了,调用 send_mail”。
  6. 执行发送 -> 邮件发出。
  7. 最终回答 -> 告诉用户“搞定啦”。
// ai.service.ts 核心逻辑
async *runChainStream(query: string): AsyncIterable<string> {
  const messages: BaseMessage[] = [
    new SystemMessage(`你是一个智能助手... 可在需要时调用工具 query_user、send_mail...`),
    new HumanMessage(query),
  ];

  while (true) {
    // 1. 让模型思考并可能产生工具调用
    const stream = await this.modelWithTools.stream(messages);
    let fullAIMessage: AIMessageChunk | null = null;
    
    // 流式输出文本给用户(如果是直接回答)
    for await (const chunk of stream as AsyncIterable<AIMessageChunk>) {
      fullAIMessage = fullAIMessage ? fullAIMessage.concat(chunk) : chunk;
      const hasToolCallChunk = !!fullAIMessage.tool_call_chunks?.length;
      
      // 如果没有工具调用,直接吐出文字
      if (!hasToolCallChunk && chunk.content) {
        yield chunk.content as string;
      }
    }

    if (!fullAIMessage) return;
    messages.push(fullAIMessage);

    // 2. 检查是否有工具需要执行
    const toolCalls = fullAIMessage.tool_calls ?? [];
    if (!toolCalls.length) return; // 没有工具调用,对话结束

    // 3. 执行工具并记录结果
    for (const toolCall of toolCalls) {
      const toolName = toolCall.name;
      let result;
      
      if (toolName === 'query_user') {
        result = await this.queryUserTool.invoke(toolCall.args);
      } else if (toolName === 'send_mail') {
        result = await this.sendMailTool.invoke(toolCall.args);
      }

      // 将工具执行结果作为“系统观察”喂回给模型
      messages.push(
        new ToolMessage({
          content: typeof result === 'string' ? result : String(result),
          name: toolName,
          tool_call_id: toolCall.id || '',
        }),
      );
    }
    // 循环继续,模型将根据工具结果进行下一步行动
  }
}

🌐 架构全景:当 Nginx 遇见 3000 端口

你可能注意到了需求里提到的 Nginx (80)Node (3000)

  • Nginx 是我们的门面担当,处理静态资源(ServeStaticModule 配置的 public 目录)、反向代理和 SSL 终结。
  • NestJS (3000) 是幕后黑手,专心处理业务逻辑、AI 推理和 SMTP 连接。
  • 数据库 (3306) 静静地在角落里存储用户数据,等着 query_user 来翻牌子。

这种分离让架构既稳健又灵活。即使 AI 发疯一直在调用工具,Nginx 依然能稳稳地 serving 你的 HTML 页面。

🎉 结语:从“手搓”到“自动驾驶”

通过这段代码,我们不仅仅是在发邮件。我们构建了一个基于意图的行动系统

  • 用户说:“给 ID 为 1001 的人发个问候。” -> AI 自动查库 -> 自动发送。
  • 用户说:“给 Alice 发个报表。” -> AI 发现不知道 Alice 是谁 -> 询问用户或报错(取决于你的 Prompt 工程)。

这就是现代后端开发的乐趣:不再是简单的 CRUD 搬运工,而是智能体的编排者。

下次当你看到 SMTP 408 或者连接超时时,别慌,那是你的 AI 正在努力穿越防火墙,只为把那句“老板心情不错”准时送到收件箱里。


👨‍💻 动手试试: 克隆代码,配置好你的 .env (别忘了 QQ 邮箱授权码),运行 pnpm start:dev,然后对着你的 API 说一句:“帮我查一下用户 1 并发封邮件测试一下”,见证奇迹的时刻!

(本文纯属技术分享,如有雷同,那是你也想到了这么好的架构。)