Telnet 手动发邮件实战:SMTP 协议排障与 MIME 附件调试指南

发布时间:2026/9/19 3:09:00
Telnet 手动发邮件实战:SMTP 协议排障与 MIME 附件调试指南 1. 为什么还要用 telnet 手动发邮件很多人第一次听到用 telnet 发邮件的反应是这年头谁还干这个Outlook、Foxmail、各种客户端不香吗但如果你真在邮件系统这条线上待过一段时间就会明白 telnet 手动发信这件事的价值根本不在日常使用而在于排障、验证和教学三个场景。先说排障。邮件发不出去到底是网络不通、端口被封、认证失败、还是对方拒收客户端只会给你一句发送失败请检查设置信息量几乎为零。而 telnet 能让你直接和 SMTP 服务器对话服务器返回的每一个三位数字状态码都是明明白白的证据。550 是拒收535 是认证失败421 是服务不可用你一眼就能定位问题出在哪一层。再说验证。新搭的邮件服务器、新配的 SMTP 中继、新申请的邮箱账号怎么确认它真的能收能发写代码测试太重装客户端太慢telnet 敲几行命令几十秒就能跑通一条完整链路。尤其是需要验证 MIME 编码、附件封装、内嵌图片这些高级功能时telnet 能让你精确控制每一个字节看到服务器对每一行的真实反应。最后是教学。SMTP 协议本身其实非常朴素就是客户端说一句、服务器答一句的文本对话。用 telnet 走一遍你会对EHLO、MAIL FROM、RCPT TO、DATA这些命令建立肌肉记忆之后再去看任何邮件库的源码都会觉得豁然开朗。这篇内容我会把整条链路拆开讲从 telnet 环境准备到纯文本邮件再到 MIME 内嵌图片和附件最后是几个我实际踩过的坑。适合有一定命令行基础、想真正搞懂 SMTP 底层、或者正在被邮件问题折磨的运维和开发同学。全程不需要写一行代码但需要你对协议就是文本这件事有耐心。提示本文所有操作仅用于本地或自有服务器的协议调试与学习请勿用于任何未经授权的邮件发送行为。2. telnet 环境准备与连通性判断2.1 各系统安装 telnet 客户端的差异telnet 客户端现在默认都不装了因为它的明文传输特性不适合做远程登录。但作为协议调试工具它依然无可替代。不同系统的安装方式差别挺大我按实际用过的顺序列一下。Windows 上Win10 之后默认是关闭的。打开方式有两种图形界面进控制面板 → 程序 → 启用或关闭 Windows 功能 → 勾选 Telnet 客户端或者用管理员权限的 PowerShell 执行dism /online /Enable-Feature /FeatureName:TelnetClient这条命令比图形界面快而且可以写进批处理脚本里批量部署。我见过有同事在几十台机器上手动点其实一行命令就搞定。macOS 上系统自带的 telnet 在新版本里被移除了需要装。如果你有 Homebrewbrew install telnet没有 Homebrew 的话其实 macOS 自带的ncnetcat也能干同样的事后面我会讲怎么用 nc 替代。Ubuntu/Debian 系sudo apt update sudo apt install telnetCentOS/RHEL 系sudo yum install telnet装完之后telnet直接回车看到telnet提示符就说明客户端可用了。2.2 用 telnet 判断端口通不通的正确姿势这是 telnet 最高频的用法也是热词里反复出现的telnet ip 端口 命令怎么看通不通。命令格式很简单telnet smtp.example.com 25但关键在于怎么解读结果这里坑很多。如果屏幕显示Connected to smtp.example.com.然后出现服务器的欢迎横幅比如220 mail.example.com ESMTP说明端口通、服务在、可以继续对话。如果卡住很久最后显示Connection timed out通常是防火墙丢包端口被拦了。如果立刻显示Connection refused说明网络能到主机但那个端口没有服务在监听或者服务没起来。如果显示Could not resolve host那是 DNS 问题跟端口无关。我踩过的一个坑有些云厂商的安全组只放行了 465 和 58725 端口默认封禁。你在本地 telnet 25 一直超时会误以为是服务器问题其实是云平台层面的限制。这时候换 587 端口试一下如果通了问题就定位了。注意判断端口连通性时Connection refused和Connection timed out的含义完全不同前者是到了但没人接后者是根本没到。排障时先分清这两者能省掉一半时间。2.3 没有 telnet 时用 nc 和 PowerShell 替代有些生产环境不允许装 telnet这时候可以用替代方案。Linux/macOS 用 ncnc -v smtp.example.com 25-v会输出详细连接信息。连上之后直接敲 SMTP 命令效果和 telnet 一样。Windows PowerShell 用 Test-NetConnectionTest-NetConnection smtp.example.com -Port 25这个只测连通性不能交互对话。如果要交互可以用 .NET 的 TcpClient但那就复杂了不如直接装 telnet。还有一个纯 bash 的技巧用/dev/tcptimeout 5 bash -c cat /dev/null /dev/tcp/smtp.example.com/25 echo 端口通 || echo 端口不通这个在写批量检测脚本时特别好用不需要任何额外工具。3. 纯文本邮件的完整 SMTP 对话拆解3.1 从 EHLO 到 QUIT 的逐行解析假设你已经 telnet 上了smtp.example.com的 25 端口服务器返回了220欢迎语。接下来就是一场标准的文本对话。我把每一步的命令、预期响应和背后的含义都列出来。第一步打招呼EHLO myclient.local服务器会返回一串250-开头的多行响应最后一行是250。这些行里包含了服务器支持的所有扩展功能比如STARTTLS、AUTH、SIZE、8BITMIME等。这一步非常关键因为后面能不能发附件、能不能认证全看这里服务器宣告了什么能力。为什么用EHLO而不是老的HELO因为HELO只返回一行不告诉你扩展能力。现代邮件服务器一律用EHLO。第二步声明发件人MAIL FROM:senderexample.com预期返回250 OK。注意尖括号不能省虽然有些服务器宽容但标准要求必须带。第三步声明收件人RCPT TO:receiverexample.com预期返回250 OK或251 User not local; will forward。如果返回550说明对方拒收这个地址可能是地址不存在或反垃圾策略拦截。第四步进入数据模式DATA服务器返回354 Start mail input; end with CRLF.CRLF。这句话的意思是你开始写吧写完了用一个单独的点号结束。第五步写邮件内容。这里要严格遵守 RFC 5322 的格式From: senderexample.com To: receiverexample.com Subject: Test Email This is a test email body. .注意最后那个单独一行的.就是结束标志。邮件头和邮件体之间必须有一个空行这是新手最容易漏的地方。漏了空行整个正文会被当成邮件头解析收件人看到的可能是一片乱码或者空白。第六步退出QUIT服务器返回221 Bye连接关闭。3.2 状态码速查与常见报错含义SMTP 的状态码是三位数字第一位表示大类后两位是细分。我整理了一张实际排障中最常遇到的表状态码含义常见触发场景220服务就绪连接成功后的欢迎语250请求完成EHLO、MAIL、RCPT 成功354开始输入DATA 命令后的响应421服务不可用连接数超限、被限流450邮箱不可用临时对方服务器繁忙稍后重试451处理错误临时本地处理失败452存储空间不足对方邮箱满了500语法错误命令拼写错误501参数错误地址格式不对502命令未实现服务器不支持该命令503命令顺序错误没 EHLO 就 MAIL FROM530需要认证未认证就想发信535认证失败用户名密码错误550邮箱不可用永久地址不存在或被拒552存储超限邮件太大554事务失败被反垃圾策略拦截这张表我建议直接存下来。实际排障时看到 5xx 基本是永久性错误改配置或换地址看到 4xx 是临时性错误等一会儿重试可能就好了。3.3 认证环节AUTH LOGIN 与 AUTH PLAIN 的区别现在几乎没有服务器允许匿名发信了必须认证。认证方式主要有两种。AUTH LOGIN是交互式的分两步AUTH LOGIN服务器返回334 VXNlcm5hbWU6这串 Base64 解码后是Username:。你输入用户名的 Base64dXNlckBleGFtcGxlLmNvbQ服务器再返回334 UGFzc3dvcmQ6Password:你输入密码的 Base64cGFzc3dvcmQxMjM返回235 Authentication successful就成功了。AUTH PLAIN是一次性的把用户名和密码拼在一起AUTH PLAIN AHVzZXJAZXhhbXBsZS5jb20AcGFzc3dvcmQxMjM那串 Base64 解码后是\0userexample.com\0password123用空字节分隔。怎么生成 Base64Linux/macOS 用echo -n userexample.com | base64Windows PowerShell 用[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes(userexample.com))注意echo一定要加-n否则会把换行符也编码进去导致认证失败。这个坑我见过太多人踩明明密码对就是 535。4. MIME 协议让邮件带上图片和附件4.1 为什么纯文本发不了附件SMTP 协议诞生于 1982 年那时候的邮件就是纯 ASCII 文本。但图片、PDF、压缩包这些是二进制数据里面充满了各种控制字符直接塞进 SMTP 对话里会出大问题——比如二进制里恰好出现一个单独的点号加换行服务器会误以为邮件结束了。MIMEMultipurpose Internet Mail Extensions就是来解决这个问题的。它的核心思路是把二进制数据用 Base64 编码成纯 ASCII 文本再用一套头部字段告诉收件方这段文本解码后是什么。理解 MIME 的关键是三个概念Content-Type说明内容类型Content-Transfer-Encoding说明编码方式boundary说明多段内容的分隔符。把这三点搞懂附件和内嵌图片就都不难了。4.2 multipart/mixed 与 multipart/related 的嵌套逻辑一封带正文、内嵌图片和附件的邮件结构是嵌套的。我用一个简化的结构图说明这里用文字描述不用图表最外层是multipart/mixed它包含两部分第一部分是multipart/related第二部分是附件。multipart/related里面又包含第一部分是text/html正文第二部分是内嵌图片。为什么要这样嵌套因为multipart/related表示这些内容是相互关联的应该一起显示适合正文和它引用的图片。而multipart/mixed表示这些内容是独立的各自处理适合正文和附件。附件和正文没有引用关系所以放在 mixed 层。如果只有附件没有内嵌图片那直接一层multipart/mixed就够了。如果只有内嵌图片没有附件一层multipart/related也够了。嵌套是为了同时满足两种需求。4.3 boundary 的选取与 Content-ID 的引用机制boundary是一个分隔符字符串用来标记每一段内容的开始和结束。它的选取有个原则必须保证在邮件内容里绝对不会出现。所以通常用一长串随机字符比如----_Part_0_1234567890.1234567890123每一段的开头是--加 boundary最后一段结束后是--加 boundary 再加--。这个格式必须精确多一个少一个连字符都会导致解析失败。内嵌图片的引用靠Content-ID。你在 HTML 正文里写img srccid:image001然后在图片那一段的头部写Content-ID: image001收件方的邮件客户端看到cid:image001就会去邮件里找Content-ID为image001的那一段把它作为图片显示出来。这个机制叫 CID 引用是内嵌图片的核心。提示Content-ID的值必须用尖括号包起来而 HTML 里的cid:后面不要加尖括号。这个细节错了图片就显示不出来。5. 手搓一封带内嵌图片和附件的邮件5.1 准备素材并生成 Base64 编码先准备一张小图片和一个附件。图片建议用 PNG体积小、编码后不会太长。附件随便一个 PDF 或 txt 都行。生成 Base64 编码Linux/macOSbase64 -i image.png -o image.b64或者直接输出到终端base64 image.png注意Base64 编码默认会换行每行 76 个字符。SMTP 对单行长度有限制不能超过 998 字符所以这个换行是必要的不要手动去掉。Windows 上可以用 PowerShell[Convert]::ToBase64String([IO.File]::ReadAllBytes(image.png))但这个输出是一整行需要自己按 76 字符切分。所以我更推荐在 Linux 或 macOS 上做这件事或者用 Git Bash。5.2 组装 MIME 头部与各段内容下面是一封完整的邮件内容模板。假设图片 Content-ID 是img001附件文件名是report.pdf。From: senderexample.com To: receiverexample.com Subject: MIME Test with Image and Attachment MIME-Version: 1.0 Content-Type: multipart/mixed; boundary----_Outer_Boundary ------_Outer_Boundary Content-Type: multipart/related; boundary----_Inner_Boundary ------_Inner_Boundary Content-Type: text/html; charsetUTF-8 Content-Transfer-Encoding: 7bit htmlbody pHello, this is an inline image test./p img srccid:img001 alttest image /body/html ------_Inner_Boundary Content-Type: image/png; nameimage.png Content-Transfer-Encoding: base64 Content-ID: img001 Content-Disposition: inline; filenameimage.png iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk ...这里是完整的 Base64 编码每行 76 字符... ------_Inner_Boundary-- ------_Outer_Boundary Content-Type: application/pdf; namereport.pdf Content-Transfer-Encoding: base64 Content-Disposition: attachment; filenamereport.pdf JVBERi0xLjQKJcfsj6IKNSAwIG9iago8PC9MZW5ndGgg... ...附件 Base64 编码... ------_Outer_Boundary--几个关键点必须强调第一MIME-Version: 1.0这行不能少否则客户端可能不按 MIME 解析。第二boundary 的值在Content-Type里不带--但在实际分隔行里要带--。这是最容易搞混的地方。第三内层 boundary 结束后是------_Inner_Boundary--外层结束后是------_Outer_Boundary--。结束标志比开始标志多两个连字符。第四每一段头部和内容之间必须有一个空行。5.3 通过 telnet 逐行粘贴的实操流程现在把上面的内容通过 telnet 发出去。完整流程telnet smtp.example.com 587连上后EHLO myclient.local STARTTLS如果服务器支持 STARTTLS会返回220 Ready to start TLS。但 telnet 本身不支持 TLS 握手所以这一步之后就没法继续了。这是 telnet 的一个硬限制它只能用于明文端口25或者已经建立好的明文会话。如果服务器在 25 端口允许明文认证很多内网服务器可以那就直接AUTH LOGIN 输入用户名 Base64 输入密码 Base64 MAIL FROM:senderexample.com RCPT TO:receiverexample.com DATA然后开始粘贴邮件内容。这里有个技巧不要一行一行手敲先把完整邮件内容写到一个文本文件里然后用终端的粘贴功能一次性贴进去。粘贴完成后敲一个单独的点号加回车.服务器返回250 OK: queued as ABC123就说明发送成功了。注意粘贴大段 Base64 时有些终端会做自动换行或字符转换导致内容损坏。建议用stty -icanon关闭规范模式或者干脆用脚本方式发送。如果发现收到的图片打不开八成是粘贴过程中字符被改了。6. 那些让我熬夜的坑与排查思路6.1 点号单独成行导致的提前结束这是最经典的坑。邮件正文里如果有一行内容恰好是一个点号SMTP 会认为邮件结束了。比如你写了一段代码示例里面有一行就是.那后面的内容全丢了。解决办法是点号填充dot-stuffing正文里任何以点号开头的行都在前面再加一个点号。服务器收到后会去掉第一个点号还原成原始内容。比如正文里是.hidden你发送时写成..hidden。这个规则在 RFC 5321 里有明确定义但很多人不知道。如果你用 telnet 手发邮件正文里有点号开头的行一定要记得处理。6.2 Base64 换行与行长限制的冲突SMTP 规定单行不能超过 998 个字符含 CRLF。Base64 编码如果不换行一张稍大的图片编码后可能几万字符一行直接违反规定。所以 Base64 必须按 76 字符换行。但换行本身也有讲究必须用 CRLF\r\n不能只用 LF\n。在 Windows 上编辑的文本文件默认是 CRLF没问题在 Linux 上默认是 LF直接粘贴可能导致服务器解析异常。我遇到过的情况是在 Linux 终端里用cat输出 Base64 然后粘贴结果因为只有 LF服务器把多行当成一行处理最后报500 Line too long。解决办法是用unix2dos转换或者用sed手动加\rsed s/$/\r/ mail.txt mail_crlf.txt6.3 认证通过了但依然 550 被拒有时候 AUTH 返回 235 成功但 RCPT TO 却返回 550。这种情况通常是发件人地址和认证账号不匹配。很多服务器要求MAIL FROM的地址必须是认证账号本身或者其授权的别名否则视为伪造发件人直接拒收。排查方法把MAIL FROM改成和认证用户名完全一致的地址再试。如果通了说明就是这个问题。这时候要么改发件人地址要么在服务器端配置授权别名。另一个可能是对方服务器的反垃圾策略。比如你发的邮件里包含某些敏感词、或者链接过多、或者图片占比过高都可能触发 550。这种只能通过调整内容或申请白名单解决。6.4 内嵌图片显示为附件的常见原因明明用了Content-Disposition: inline图片却还是显示成附件通常有三个原因。第一Content-ID和 HTML 里的cid:引用不匹配。检查两处的值是否完全一致包括大小写。第二multipart/related的 boundary 写错了导致客户端没把图片和正文关联起来。检查 boundary 字符串是否前后一致。第三HTML 正文的Content-Type不是text/html而是text/plain。如果客户端按纯文本解析img标签就不会被渲染图片自然只能作为附件显示。我建议的排查顺序是先确认 Content-Type 是 text/html再确认 cid 引用一致最后检查 boundary。按这个顺序九成问题都能定位。7. 从 telnet 到脚本什么时候该换工具telnet 手发邮件适合验证和排障但绝不适合批量或日常使用。当你需要重复发送、需要 TLS 加密、需要处理复杂 MIME 结构时就该换工具了。判断标准很简单如果你发现自己在重复敲同样的命令或者需要发送超过一封邮件就停下来写脚本。Python 的smtplib和email库能覆盖本文所有场景而且自动处理 Base64 编码、boundary 生成、点号填充这些细节。但即使你用脚本本文讲的这些底层知识依然有用。因为脚本报错时错误信息往往就是 SMTP 状态码你得知道 535 和 550 的区别才能快速定位。而且当脚本行为异常时用 telnet 手动走一遍同样的流程能立刻判断是脚本的问题还是服务器的问题。我个人在实际操作中的体会是telnet 就像听诊器平时用不上但真出问题时它是最直接、最不会骗你的诊断工具。把 SMTP 对话的每一步都搞清楚你对邮件系统的理解会上一个台阶之后再遇到任何邮件问题心里都有底。最后分享一个小技巧把常用的 SMTP 对话流程写成一个文本模板排障时直接复制粘贴改几个参数就能用。我自己的模板里包含了 EHLO、AUTH、MAIL FROM、RCPT TO、DATA 的完整框架还有一段测试用的 HTML 正文和一张 1x1 像素的 Base64 图片。这样每次排障从连上服务器到发出测试邮件不超过两分钟。