SMTP代理并不需要完整实现邮件服务器,而是工作在会话中间。它对外表现为SMTP服务器,对内则作为客户端连接真正的邮件服务器。每当客户端发起连接,代理先完成EHLO/HELO握手,接着接收MAIL FROM和RCPT TO,在进入DATA阶段前就可以根据信封信息决定是否拒绝。进入DATA后,代理收集整封邮件,进行过滤和重写,再转发给上游。若上游不可达,代理还可以返回临时失败让客户端稍后重试。这个结构非常适合在不动现有邮件系统的情况下加入审计、合规和路由策略。

一、SMTP代理的协议基础与运行模型
SMTP会话由一系列以CRLF结尾的文本命令组成。典型流程为:客户端发送 EHLO ipipp.com,服务器回复250多行能力列表;随后是 MAIL FROM:<sender@ipipp.com>,服务器回复250 OK;接着 RCPT TO:<rcpt@example.net>,服务器同样回复250 OK;客户端发送 DATA 后,服务器先返回354,客户端开始发送邮件内容,并以单独一行的点号表示结束。代理需要同时处理两段会话,而且两端的状态不一定完全同步。
代理收到DATA阶段结束的点号后,才把完整邮件交给过滤逻辑;转发给上游时也要重新执行一次完整的SMTP对话。另一个容易被忽略的是点填充规则。客户端发送正文时,遇到行首的点会再加一个点,代理在存储时需要还原这些内容,转发时则要再次填充。若只做简单字符串透传,点填充处理错误会直接导致邮件正文损坏。实现时可以用状态标记行首点,不要粗暴替换所有连续点号。
从状态机角度看,代理至少包含五种状态:等待命令、信封收集中、DATA接收中、内容处理中、上游投递中。用Ruby实现时,可以把这个状态放在每个连接的实例变量中,避免全局锁。每个连接使用独立线程处理,连接之间互不干扰,这也方便后续加入按IP限流和连接超时。
二、用Ruby搭建最小SMTP代理框架
最小代理可以完全基于Ruby标准库构建。TCPServer 监听本地端口,TCPSocket 连接上游MTA。读取命令时要注意SMTP使用CRLF,客户端如果只发LF,直接断开即可。下面给出核心类结构,start 方法负责监听和启动线程,handle_client 处理单个会话。
require 'socket'
class SmtpProxy
def initialize(listen_host: '0.0.0.0', listen_port: 2525,
upstream_host: '127.0.0.1', upstream_port: 25)
@listen_host = listen_host
@listen_port = listen_port
@upstream_host = upstream_host
@upstream_port = upstream_port
end
def start
server = TCPServer.new(@listen_host, @listen_port)
puts "SMTP proxy listening on #{@listen_host}:#{@listen_port}"
loop do
client = server.accept
Thread.new(client) do |socket|
handle_client(socket)
rescue => e
puts "connection error: #{e.message}"
ensure
socket.close rescue nil
end
end
end
def handle_client(socket)
socket.write "220 proxy.ipipp.com ESMTP Ruby Proxy\r\n"
from = nil
recipients = []
loop do
line = read_line(socket)
command, args = line.split(/\s+/, 2)
case command.upcase
when 'EHLO', 'HELO'
socket.write "250-proxy.ipipp.com\r\n250 8BITMIME\r\n"
when 'MAIL'
from = args[/<(.*?)>/m, 1]
socket.write "250 OK\r\n"
when 'RCPT'
rcpt = args[/<(.*?)>/m, 1]
recipients << rcpt
socket.write "250 OK\r\n"
when 'DATA'
socket.write "354 End data with <CR><LF>.<CR><LF>\r\n"
data = read_data(socket)
handle_data(from, recipients, data, socket)
recipients = []
when 'RSET'
from = nil
recipients = []
socket.write "250 OK\r\n"
when 'QUIT'
socket.write "221 Bye\r\n"
break
else
socket.write "500 Unrecognized command\r\n"
end
end
end
def read_line(socket)
line = socket.gets("\n")
raise 'connection closed' unless line
line = line.chomp
line = line[0..-2] if line.end_with?("\r")
line
end
def read_data(socket)
lines = []
while (line = read_line(socket))
break if line == '.'
line = line[1..-1] if line.start_with?('.')
lines << line
end
lines.join("\r\n") + "\r\n"
end
def handle_data(from, recipients, data, socket)
# 后面实现过滤和转发
socket.write "250 OK\r\n"
end
end
上面的代码里,read_line 兼容了不规范的LF结尾,read_data 按行读取并处理点填充。当前 handle_data 直接返回250,并没有真正转发。下一步我们要把过滤、重写和上游投递接入进来。
需要注意,DATA 命令后代理返回354,客户端发送的内容可能包含任意字节,包括空行和点号。按行读取是常见做法,但如果后续要支持大附件,应换成流式读取或限制单行长度,避免内存被单行超大内容耗尽。
三、实现邮件过滤与头部重写
DATA阶段是代理的核心。把客户端发送的原始邮件内容按行存入一个字符串后,就可以交给规则引擎处理。常用的Ruby邮件库是 mail,它能够解析RFC 5322格式的邮件,并支持修改 from、to、subject 等字段后重新序列化。不过 mail gem 在解析MIME和重编码时会调整原始字节布局,如果对字节保真度要求很高,只修改少量头部时建议用字符串替换,保留原有编码和换行。
下面示例同时保留两种策略:简单主题过滤和发件人重写使用字符串操作,复杂头部修改使用 mail gem。过滤规则可以根据主题关键词、附件名、发件人域名等条件拒绝邮件,返回nil表示拦截。
require 'mail'
def filter_and_rewrite(data)
header, body = data.split("\r\n\r\n", 2)
# 简单字符串过滤
if header =~ /^Subject: .*敏感内容/i
return nil
end
# 对发件人地址做统一重写
header = header.gsub(/^From: .*/i, 'From: noreply@ipipp.com')
# 使用mail gem处理更复杂的头部
mail = Mail.new(header + "\r\n\r\n" + body.to_s)
mail.subject = '转发邮件: ' + mail.subject.to_s
mail['X-Proxy-Processed'] = 'ruby-smtp-proxy'
mail.to_s
end
def handle_data(from, recipients, data, socket)
processed = filter_and_rewrite(data)
unless processed
socket.write "550 Message rejected by policy\r\n"
return
end
deliver_to_upstream(from, recipients, processed)
socket.write "250 OK queued\r\n"
end
这种策略的优点是简单直观,但缺点是 mail gem 会规范化邮件,可能破坏已有DKIM签名。如果邮件已由客户端签名,任何头部修改都会使原签名失效,代理应移除原 DKIM-Signature 头,让上游MTA在投递前重新签名。如果只是测试或内部系统,可以直接移除签名头,避免接收方验签失败。
过滤规则建议分层执行:信封层判断放在 RCPT TO 阶段,能尽早拒绝;内容层判断放在DATA收集完成后,基于标题、正文、附件名等。两层结合可以降低无效邮件进入完整解析流程的比例。对于重写操作,建议保留修改前的原始邮件副本,便于审计和排错。
四、投递控制与异常恢复
投递控制不仅指拒绝邮件,还包括根据收件人域名把邮件路由到不同上游。例如内部域名走内网邮件服务器,外部域名走公网中继。可以维护一个路由表,在 RCPT TO 阶段判断收件人域名,如果命中规则则记录目标上游,否则使用默认上游。同一个连接中的多个收件人可能对应不同上游,转发DATA时需要对每个目标分别投递。
ROUTES = {
'internal.ipipp.com' => ['127.0.0.1', 2526],
'_default' => ['smtp.example.net', 25]
}
def route_for(rcpt)
domain = rcpt.split('@').last.downcase
ROUTES.fetch(domain, ROUTES['_default'])
end
def deliver_to_upstream(from, recipients, data)
grouped = recipients.group_by { |r| route_for(r) }
grouped.each do |(host, port), list|
sock = TCPSocket.new(host, port)
sock.write "EHLO proxy.ipipp.com\r\n"
sock.gets("\n")
sock.write "MAIL FROM:<#{from}>\r\n"
sock.gets("\n")
list.each do |rcpt|
sock.write "RCPT TO:<#{rcpt}>\r\n"
sock.gets("\n")
end
sock.write "DATA\r\n"
sock.gets("\n")
data.split("\r\n").each do |line|
line = '.' + line if line.start_with?('.')
sock.write line + "\r\n"
end
sock.write ".\r\n"
sock.gets("\n")
sock.write "QUIT\r\n"
sock.close
end
end
上面的代码按路由分组后对每个上游执行投递。注意DATA内容需要重新做点填充,否则正文中行首的点会被上游误判为结束标记。这个方法只适合简单场景,真实系统中还要处理单个收件人失败时对同一组里其他收件人的影响,以及是否需要生成DSN退回邮件。
异常恢复同样重要。连接上游时要设置连接超时和读写超时,避免某个不可达的上游拖住代理线程。Ruby中可以使用 Socket.tcp(host, port, connect_timeout: 5),然后设置 socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) 或用 IO.select 做读超时。如果上游返回永久失败,代理可以原样反馈客户端;如果临时失败,可以返回451让客户端稍后重试。日志应记录每封邮件的 message-id、发件人、收件人、规则命中和上游响应,但不要记录邮件正文,避免敏感信息泄露。
通过这个精简的Ruby SMTP代理,你可以在不触碰现有邮件系统的情况下加入自定义过滤、重写和路由策略。它适合开发测试环境和中小型内部系统,生产环境则需补充TLS、认证、队列持久化和完善的监控指标。

