Codex 实战 Skills:用 HTML 模板自动填充客户名称并发送带附件报表的邮件技能
1. 客户报表邮件为什么总在最后一步翻车做 B2B 运营的朋友大概率都经历过这种场景月底要给几十上百个客户发月度报表HTML 模板早就设计好了数据也跑出来了附件 Excel 也生成了结果卡在“把客户名称填进去、把附件挂上去、点发送”这个环节。手动操作的话一封封改称呼、一封封传附件发到第十封就开始怀疑人生发到第三十封必然出现张冠李戴——把 A 公司的报表发给了 B 公司的联系人。这个问题的本质不是“发邮件”难而是个性化内容 附件 批量发送这三件事叠在一起之后人工操作的出错概率呈指数上升。你需要的是一个可复用的技能Skill输入客户列表和报表文件输出一批已经发出去的、称呼正确、附件正确的邮件。Codex Skills 的价值就在这里。它不是一个孤立的 API 调用而是一段可以被 Agent 反复调用的封装逻辑。你可以把它理解成一个“邮件工厂”原料是 HTML 模板和客户数据产线是模板渲染和 MIME 组装出货口是 SMTP 发送。本文会给出一个可以直接复制运行的 Skill 配置骨架包含模板占位符设计、附件路径处理、收件人字段映射以及一次本地验证动作帮你把从模板到发信的完整链路跑通。适合谁看需要定期给客户发报表的运营开发、做 CRM 自动化的后端同学、以及想把重复劳动封装成 Skill 的 Agent 开发者。下面所有代码和配置都可以在本地直接跑不需要复杂的部署环境。2. 前置准备用 TaoToken 拿到可调用的模型能力在写邮件 Skill 之前先解决“谁来驱动这个 Skill”的问题。如果你打算让 Codex 或类似的编码 Agent 帮你生成、调试、迭代这段邮件发送逻辑你需要一个稳定的模型调用入口。我试过用 TaoToken 来统一管理这类调用它的定位是给开发者和 Agent 工作流提供模型接入能力官网地址是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 入口是 https://taotoken.net/api 。具体操作分三步。第一步打开控制台创建 API Key地址是 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 在 API Keys 页面生成一个密钥复制保存好后面配置环境变量要用。第二步如果你只是想让模型帮你审查邮件模板的 HTML 结构或者生成 Jinja2 占位符可以直接用模型对话页面 https://taotoken.net/model-chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite 快速验证。第三步如果你打算长期用 Codex 做编码和 Agent 任务建议看一下 Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite 它更适合高频的代码生成和调试场景。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面有完整的鉴权和请求示例。如果你用的是 Claude Code 这类工具可以参考 https://taotoken.net/claude-code-anthropic?utm_sourcetaotoken_aicg_blog_endutm_contentclaude_codeutm_campaignrewrite 的配置说明。把 Key 配到环境变量里后面 Python 脚本直接读取export TAOTOKEN_API_KEY你的密钥 export SMTP_HOSTsmtp.example.com export SMTP_PORT587 export SENDER_EMAILreportyourcompany.com export SENDER_PASSWORD你的邮箱授权码注意SMTP 密码不要硬编码在代码里用环境变量或密钥管理服务。邮箱授权码和登录密码通常不是同一个东西去邮箱设置里单独生成。3. 可复制的 Skill 配置骨架模板占位符 附件路径 收件人字段这一节是核心。我会把整个 Skill 拆成三个可独立替换的模块HTML 模板、客户数据映射、发送逻辑。你只需要改模板里的占位符和 CSV 的列名就能套用到自己的业务场景。3.1 HTML 模板用占位符标记客户名称和报表字段邮件客户端的 CSS 支持很有限别用 Flexbox 和 Grid老老实实用 table 布局样式尽量内联。下面是一个最小可用的报表邮件模板保存为email_template.html!DOCTYPE html html langzh-CN head meta charsetUTF-8 title月度业务报告/title /head body stylefont-family: Arial, sans-serif; background:#f4f4f4; margin:0; padding:0; div stylemax-width:600px; margin:20px auto; background:#ffffff; border-radius:8px; overflow:hidden; div stylebackground:#2c3e50; color:#ffffff; padding:20px; text-align:center; h1 stylemargin:0; font-size:20px;{{ company_name }} 月度业务简报/h1 /div div stylepadding:20px; color:#333333; line-height:1.6; p尊敬的 strong{{ customer_name }}/strong/p p您好以下是您在 {{ month }} 的业务数据汇总/p table stylewidth:100%; border-collapse:collapse; margin-top:15px; tr th styleborder:1px solid #ddd; padding:8px; background:#f2f2f2;指标/th th styleborder:1px solid #ddd; padding:8px; background:#f2f2f2;数值/th /tr tr td styleborder:1px solid #ddd; padding:8px;总销售额/td td styleborder:1px solid #ddd; padding:8px;¥ {{ total_sales }}/td /tr tr td styleborder:1px solid #ddd; padding:8px;订单数量/td td styleborder:1px solid #ddd; padding:8px;{{ order_count }}/td /tr /table p详细报表请查阅随信附件。/p p祝商祺br{{ sender_name }}/p /div div stylebackground:#eeeeee; padding:15px; text-align:center; font-size:12px; color:#777; 此邮件由系统自动发送请勿直接回复。 /div /div /body /html模板里的{{ customer_name }}、{{ company_name }}、{{ total_sales }}就是占位符Jinja2 渲染时会替换成客户数据里的实际值。注意{{ customer_name }}是客户联系人姓名{{ company_name }}是客户公司名这两个别搞混否则邮件开头会变成“尊敬的某某科技有限公司”读起来很怪。3.2 客户数据映射CSV 列名与模板变量的对应关系准备一个customers.csv列名和模板变量一一对应customer_name,company_name,email,total_sales,order_count,month 李明,创新科技有限公司,limingexample.com,150000.00,12,2024-06 王芳,未来贸易集团,wangfangexample.com,89000.50,8,2024-06 张伟,恒达物流,zhangweiexample.com,230000.00,25,2024-06这里的关键是email列它决定了收件人字段。发送逻辑里会读取这一列作为To地址。附件路径不放在 CSV 里而是作为 Skill 的全局参数传入因为同一批客户的报表文件通常是同一个或者按客户 ID 动态生成。3.3 发送逻辑渲染模板、挂载附件、调用 SMTP下面是完整的 Python Skill 骨架保存为email_skill.py。代码里包含了模板渲染、附件挂载、SMTP 发送和错误处理import os import csv import smtplib import logging from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.mime.base import MIMEBase from email import encoders from jinja2 import Environment, FileSystemLoader from datetime import datetime logging.basicConfig( levellogging.INFO, format%(asctime)s - %(levelname)s - %(message)s, handlers[logging.FileHandler(email_skill.log, encodingutf-8), logging.StreamHandler()] ) logger logging.getLogger(__name__) class ReportEmailSkill: def __init__(self, smtp_host, smtp_port, sender_email, sender_password, template_dir): self.smtp_host smtp_host self.smtp_port smtp_port self.sender_email sender_email self.sender_password sender_password self.jinja_env Environment(loaderFileSystemLoader(template_dir), autoescapeTrue) def render_template(self, template_name, context): template self.jinja_env.get_template(template_name) return template.render(**context) def build_message(self, to_email, subject, html_content, attachment_pathNone): msg MIMEMultipart() msg[From] self.sender_email msg[To] to_email msg[Subject] subject msg.attach(MIMEText(html_content, html, utf-8)) if attachment_path and os.path.exists(attachment_path): with open(attachment_path, rb) as f: part MIMEBase(application, octet-stream) part.set_payload(f.read()) encoders.encode_base64(part) filename os.path.basename(attachment_path) part.add_header(Content-Disposition, fattachment; filename{filename}) msg.attach(part) logger.info(f附件已挂载: {filename}) return msg def send_one(self, to_email, subject, html_content, attachment_pathNone): try: msg self.build_message(to_email, subject, html_content, attachment_path) server smtplib.SMTP(self.smtp_host, self.smtp_port) server.ehlo() server.starttls() server.ehlo() server.login(self.sender_email, self.sender_password) server.sendmail(self.sender_email, [to_email], msg.as_string()) server.quit() logger.info(f发送成功: {to_email}) return True except smtplib.SMTPAuthenticationError: logger.error(f认证失败检查邮箱授权码: {to_email}) return False except Exception as e: logger.error(f发送异常: {e}, 收件人: {to_email}) return False def run_bulk(self, csv_path, template_name, attachment_pathNone): success, failed 0, 0 with open(csv_path, newline, encodingutf-8) as f: reader csv.DictReader(f) for row in reader: context { customer_name: row.get(customer_name, 客户), company_name: row.get(company_name, ), total_sales: row.get(total_sales, 0), order_count: row.get(order_count, 0), month: row.get(month, datetime.now().strftime(%Y-%m)), sender_name: 客户成功团队 } html_content self.render_template(template_name, context) subject f{context[company_name]} - {context[month]} 业务报告 ok self.send_one(row[email], subject, html_content, attachment_path) if ok: success 1 else: failed 1 logger.info(f批量完成。成功: {success}, 失败: {failed}) return {success: success, failed: failed} if __name__ __main__: skill ReportEmailSkill( smtp_hostos.getenv(SMTP_HOST), smtp_portint(os.getenv(SMTP_PORT, 587)), sender_emailos.getenv(SENDER_EMAIL), sender_passwordos.getenv(SENDER_PASSWORD), template_dir. ) result skill.run_bulk( csv_pathcustomers.csv, template_nameemail_template.html, attachment_pathmonthly_report.xlsx ) print(result)这段代码里几个容易踩坑的地方我单独说一下。autoescapeTrue是防止客户名称里带特殊字符导致 HTML 结构错乱比如客户公司名里有个符号不开转义的话邮件正文可能直接崩掉。附件文件名用双引号包起来filename{filename}有些邮件客户端对中文文件名处理不好建议附件名用英文加日期比如report_202406.xlsx。starttls()之后要再调一次ehlo()这是 SMTP 协议的要求漏掉的话某些服务器会拒绝登录。4. 本地验证跑一次发送并确认结果代码写好了别急着批量发。先做一次本地验证确认模板渲染、附件挂载、SMTP 连接三个环节都正常。第一步把customers.csv里只留一行收件人改成你自己的邮箱。这样即使出问题也不会骚扰到客户。第二步准备一个测试附件随便建一个文件echo test report content monthly_report.xlsx第三步运行脚本python email_skill.py如果一切正常你会在终端看到类似输出2024-06-15 10:30:01 - INFO - 附件已挂载: monthly_report.xlsx 2024-06-15 10:30:03 - INFO - 发送成功: your_emailexample.com 2024-06-15 10:30:03 - INFO - 批量完成。成功: 1, 失败: 0 {success: 1, failed: 0}然后去邮箱里检查三件事邮件正文里的客户名称是否替换成了 CSV 里的值附件是否正常显示且能打开邮件主题是否包含了公司名和月份。这三项都对了再把 CSV 恢复成完整客户列表把附件换成真实报表文件正式跑批量。如果你想在发送前先预览渲染后的 HTML可以在run_bulk里加一行print(html_content)或者把渲染结果写到一个.html文件里用浏览器打开看效果。这个习惯能帮你提前发现模板里的样式问题比发出去之后再后悔强。5. 本篇常见错排查5.1 报错 SMTPAuthenticationError: 535这是最常见的错误原因通常是用了邮箱登录密码而不是授权码。QQ 邮箱、163 邮箱、Gmail 都需要在设置里单独开启 SMTP 服务并生成授权码。另外检查SENDER_EMAIL是否和登录账号一致有些企业邮箱要求发件人地址必须和认证账号相同。5.2 附件收到后变成 .bin 文件或无法打开检查MIMEBase的第二个参数。上面代码用的是application, octet-stream这是通用二进制流。如果你发的是 Excel可以改成application, vnd.openxmlformats-officedocument.spreadsheetml.sheet邮件客户端识别率更高。但即使不改大多数客户端也能根据文件名后缀正确识别。5.3 邮件正文里客户名称显示为空白说明 Jinja2 渲染时customer_name变量没取到值。检查 CSV 的列名是否和代码里row.get(customer_name)一致注意大小写和空格。CSV 文件保存时用 UTF-8 编码Excel 另存为 CSV 有时会变成 GBK导致中文列名乱码。5.4 发送成功但收件人没收到先查垃圾邮件文件夹。如果批量发送频率太高邮件服务商会限流。建议每发 20 封暂停 5 秒在循环里加time.sleep(5)。另外确认发件人域名是否配置了 SPF 记录没有 SPF 的域名发出的邮件很容易被判定为垃圾邮件。5.5 模板渲染报 UndefinedErrorJinja2 默认对未定义的变量会报错。如果你在模板里用了{{ growth_rate }}但 CSV 里没有这一列就会触发。解决办法是在context字典里给所有模板变量提供默认值或者用{{ growth_rate | default(0) }}过滤器。6. 把 Skill 接进你的 Agent 工作流邮件发送跑通之后下一步是让它变成 Agent 可以自动调用的技能。你可以把ReportEmailSkill封装成一个函数注册到 Codex 的工具列表里这样当 Agent 判断“需要给客户发报表”时就能自动传入 CSV 路径和附件路径完成整个链路。如果你在调试 Agent 调用逻辑时需要快速验证模型输出可以用模型对话页面 https://taotoken.net/model-chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite 做交互测试。长期做编码和 Agent 开发的话Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite 的额度更适合高频调用。API Key 在控制台 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 管理接入细节看文档 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。最后分享一个实用技巧把每次发送的success和failed计数写到一个日志文件里按日期归档。这样月底对账时能快速定位哪些客户没收到报表比翻邮箱发件箱高效得多。邮件 Skill 的价值不在于“能发”而在于“发得准、可追溯、能复用”。