🚀 手搓一个会“读心术”的邮件机器人:当 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 (飞毛腿)
这是重头戏。注意,我们允许传入 text 或 html。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 将模型与工具绑定,并实现了一个流式处理的循环。
这个循环的逻辑非常性感:
- 用户提问 -> 发送给 LLM。
- LLM 思考 -> “嗯,用户想发邮件,但我没邮箱,我得先调用
query_user”。 - 返回 Tool Call -> 服务端捕获,执行
query_user。 - 回填结果 -> 把查询到的邮箱告诉 LLM。
- LLM 再次思考 -> “好嘞,现在有邮箱了,调用
send_mail”。 - 执行发送 -> 邮件发出。
- 最终回答 -> 告诉用户“搞定啦”。
// 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 并发封邮件测试一下”,见证奇迹的时刻!
(本文纯属技术分享,如有雷同,那是你也想到了这么好的架构。)