使用DMA的UTF-8电子邮件:DragonFly Mail Agent
摘要
一份技术指南,介绍如何通过创建sendmail包装器来处理来自OpenBSD上cron的UTF-8电子邮件编码问题,该包装器在将消息传递给dma之前对非ASCII头进行编码并添加Content-Type声明。
<p><a href="https://lobste.rs/s/acsqgj/utf8_email_with_dma_dragonfly_mail_agent">评论</a></p>
查看缓存全文
缓存时间: 2026/06/01 06:28
# Vincent 的博客
来源:https://www.vincentdelft.be/post/post_20260530
## 在 OpenBSD 7.8 上通过 sendmail 包装器修复来自 cron 的 UTF-8 邮件
如果你在 OpenBSD 上运行 cron 任务,并且脚本会产生 UTF-8 输出——带重音符号的字符、特殊符号、任何超出纯 ASCII 范围的内容——你很可能注意到收到的邮件是乱码的。主题行中的重音符号会变成一堆 `M\-CM\-9` 序列,而正文的显示是否正确则取决于你的 MUA 是否愿意猜测编码。本文介绍了我如何通过编写一个位于 cron 和 OpenBSD 轻量级邮件代理 `dma` 之间的小型 `sh` 包装器,干净地解决了这个问题。
## 问题所在
OpenBSD 的 cron 守护进程将传出邮件交给配置为 `sendmail` 的二进制程序处理,通常是 `/usr/sbin/sendmail`,在基础 OpenBSD 安装中它会被符号链接到 `dma`。当一个 cron 任务产生输出时,cron 会构造一个最小的 RFC 2822 邮件——包含 `From:`、`To:`、`Subject:` 等头部,以及一组 `X\-Cron\-Env:` 行——并将整个内容通过管道传给该二进制程序。
问题有两个方面。首先,`dma` 不会对非 ASCII 头部值进行编码。如果你的脚本名称或其输出中包含 `é`、`ù`、`à` 或任何超出 7 位 ASCII 范围的字符,Subject 行会以原始形式发出,大多数邮件服务器或客户端要么会乱码处理,要么直接拒绝。其次,邮件中缺少 `Content\-Type: text/plain; charset=utf\-8` 头部,因此即使正文完整到达,收件人的邮件客户端也没有声明可以依据。
对非 ASCII 头部的正确修复是 RFC 2047 编码字。例如,Subject 为 `Rapport système` 应变为:
``
Subject: =?utf-8?B?UmFwcG9ydCBzeXN0w6htZQ==?=
``
这个 base64 块告诉所有符合规范的邮件客户端,该值是 UTF-8 编码的。
## 方法
与其修补 `dma`——在一个希望你保持可审计性的系统上这样做并不明智——不如编写一个小的 POSIX `sh` 脚本,将其安装为 `sendmail` 二进制程序。该脚本拦截邮件流,修复头部,在需要时注入 `Content\-Type` 声明,然后将清理后的消息交给真正的 `dma` 二进制程序。
该脚本必须处理几个实际工作中才会注意到的复杂情况,这些情况直到你真正在生产环境中运行并仔细阅读日志时才会显现:
- Cron 有时会通过管道传入没有头部的纯文本。整个输入都是正文。
- Cron 会传递一套完整的 `sendmail` 兼容标志:`\-FCronDaemon \-odi \-oem \-oi \-t`。`\-t` 标志的意思是“从 `To:` 头部读取收件人”。`dma` 不理解这些标志中的任何一个,它需要一个普通的收件人地址作为位置参数。
- RFC 2822 允许头部跨多行折叠,延续行以空白字符开头。
- POSIX `sh` 没有 `local` 变量。一个将 `line="$1"` 赋值的 shell 函数会静默地覆盖任何外部同名的 `line` 变量——这在 `while IFS= read \-r line` 循环内部尤其容易引发问题。
- OpenBSD 的 `b64encode` 会在输出中包含一个以 `begin\-base64 644 \-` 开头的行和一个以 `====` 结尾的尾行。在将 base64 数据嵌入 RFC 2047 编码字之前,必须同时去掉这两行。
- OpenBSD 的 `logger\(1\)` 不接受 `\-\-` 来结束选项解析,并且会将任何以 `\-` 开头的消息字符串视为未知选项。所有日志消息必须以一个空格或其他非短横线字符开头。
## 脚本
将脚本安装为 `/usr/local/sbin/dma\_utf8`(或任何你喜欢的路径),使其可执行,然后将 `sendmail` 指向它。真正的 `dma` 二进制程序应保留在 `/usr/local/sbin/dma`。
``
#!/bin/sh
#
# OpenBSD 7.8 上用于 dma 的 sendmail 包装器。
# 将非 ASCII 头部值编码为 RFC 2047 base64 编码字,
# 并确保每封外发邮件中都包含 UTF-8 Content-Type 声明。
#
# 安装:chmod 755 /usr/local/sbin/dma_utf8
# 然后在 /etc/mailer.conf 中设置路径(参见文章)。
#
# 需要:awk, b64encode, sed, grep, dma(位于 /usr/local/sbin/dma)
# 不要添加 set -e。grep -q 在没有找到匹配时返回退出码 1,
# 这是完全正常的行为。set -e 会将其视为致命错误,
# 并在调用 dma 之前静默杀死脚本。
dbg() {
# OpenBSD 的 logger 会将任何以 '-' 开头的消息视为选项标志。
# 通过添加空格前缀来避免此问题。
logger -t sendmail-wrapper " $1"
}
TMP_HDR=$(mktemp -t email_hdr.XXXXXX)
TMP_BDY=$(mktemp -t email_bdy.XXXXXX)
TMP_NEW=$(mktemp -t email_new.XXXXXX)
TMP_FLG=$(mktemp -t email_flg.XXXXXX)
trap 'rm -f "$TMP_HDR" "$TMP_BDY" "$TMP_NEW" "$TMP_FLG"' EXIT
# 将传入的邮件拆分为头部和正文。
# 第一个空行之前的所有内容进入 TMP_HDR;其余内容进入 TMP_BDY。
awk '
BEGIN { in_body = 0 }
/^$/ && !in_body { in_body = 1; next }
{ if (in_body) print > "'"$TMP_BDY"'"; else print > "'"$TMP_HDR"'" }
'
# 当 cron 没有输出但仍有邮件(或者脚本只是输出纯文本而没有构建头部)时,
# 第一行将不匹配 RFC 2822 的 "字段: 值" 模式。在这种情况下,将整个输入视为正文。
first_line=$(head -1 "$TMP_HDR")
if ! printf '%s' "$first_line" | grep -qE '^[A-Za-z-]+:'; then
dbg "未检测到头部,将整个输入视为正文"
cat "$TMP_HDR" "$TMP_BDY" > "${TMP_BDY}.all"
mv "${TMP_BDY}.all" "$TMP_BDY"
: > "$TMP_HDR"
fi
# 处理头部。Subject、From 和 To 中的非 ASCII 值被编码为
# RFC 2047 base64 编码字。如果 Content-Type 缺少字符集声明,则追加 UTF-8。
# 如果没有任何 Content-Type 头部,则添加一个。
#
# flush_line 内部的变量以 _fl_ 开头,以避免与外部 while 循环冲突。
# POSIX sh 没有局部变量:函数内部直接赋值会修改全局作用域,
# 这会静默地损坏调用循环中的 $line 变量。
flush_line() {
_fl_line="$1"
case "$_fl_line" in
Subject:*|From:*|To:*)
_fl_name="${_fl_line%%:*}"
_fl_val="${_fl_line#*:}"
_fl_val="${_fl_val# }"
if printf '%s' "$_fl_val" | LC_ALL=C grep -q '[^ -~]'; then
_fl_b64=$(printf '%s' "$_fl_val" | b64encode - | sed '1d;/^====/d' | tr -d '\r\n')
printf '%s: =?utf-8?B?%s?=\n' "$_fl_name" "$_fl_b64"
else
printf '%s\n' "$_fl_line"
fi
;;
Content-Type:*)
if printf '%s' "$_fl_line" | grep -qi 'charset'; then
printf '%s\n' "$_fl_line"
else
printf '%s; charset=utf-8\n' "$_fl_line"
fi
printf '1' > "$TMP_FLG"
;;
*)
printf '%s\n' "$_fl_line"
;;
esac
}
# 在将行传递给 flush_line 之前,展开 RFC 2822 连续行(以空白字符开头的行是前一个头部的延续)。
current_line=""
{
while IFS= read -r line; do
case "$line" in
" "*|" "*)
current_line="${current_line} ${line#?}"
;;
*)
if [ -n "$current_line" ]; then
flush_line "$current_line"
fi
current_line="$line"
;;
esac
done < "$TMP_HDR"
[ -n "$current_line" ] && flush_line "$current_line"
} > "$TMP_NEW"
# 如果原始邮件中不存在 Content-Type 头部,则注入一个。
if [ ! -s "$TMP_FLG" ]; then
printf 'Content-Type: text/plain; charset=utf-8\n' >> "$TMP_NEW"
fi
# 为 dma 解析收件人。
# Cron 调用 sendmail 时使用:-FCronDaemon -odi -oem -oi -t
# dma 不理解这些标志中的任何一个。-t 表示“从 To: 读取收件人”。
# 我们扫描参数列表:收集任何普通地址,记录是否传递了 -t,
# 并丢弃所有其他 sendmail 风格标志。如果存在 -t 或没有找到普通地址,
# 则从处理后的头部中提取 To: 值。
has_t=0
plain_recipients=""
for arg in "$@"; do
case "$arg" in
-t) has_t=1 ;;
-*) ;;
*) plain_recipients="$plain_recipients $arg" ;;
esac
done
if [ $has_t -eq 1 ] || [ -z "$plain_recipients" ]; then
recipients=$(grep -i '^To:' "$TMP_NEW" | head -1 | sed 's/^[Tt][Oo]:[[:space:]]*//')
else
recipients="$plain_recipients"
fi
dbg "调用 dma,收件人:$recipients"
{
cat "$TMP_NEW"
printf '\n'
cat "$TMP_BDY"
} | /usr/local/sbin/dma $recipients
``
## 安装
将脚本复制到 `/usr/local/sbin/dma\_utf8` 并使其可执行:
``
install -o root -g wheel -m 755 dma_utf8 /usr/local/sbin/dma_utf8
``
然后告诉系统使用该路径而不是默认的 sendmail。在 OpenBSD 上,标准方法是通过 `/etc/mailer\.conf`:
``
sendmail /usr/local/sbin/dma_utf8
send-mail /usr/local/sbin/dma_utf8
...
``
## 从命令行使用
该包装器是完全透明的。任何通过 `sendmail` 接口发送邮件的工具——`mail\(1\)`、直接通过管道传递的 `printf`、调用 `/usr/sbin/sendmail` 的应用程序代码——在配置好 `mailer\.conf` 后都会自动使用它。你不需要直接调用 `/usr/local/sbin/dma\_utf8`。
从 shell 发送快速 UTF-8 邮件最自然的方式是使用 `mail\(1\)`:
``
echo "C'est vraiment l'été !!" | mail -s "T'es où" root
``
`\-s` 标志设置 Subject。正文来自标准输入。包装器会检测 Subject 中的非 ASCII 字符,将其编码为 RFC 2047 字,添加 `Content\-Type: text/plain; charset=utf\-8` 头部,并将清理后的消息传递给 `dma`。收件人 `root` 会像往常一样通过别名解析。
你也可以通过管道传入完全构造好的 RFC 2822 消息,以便更精细地控制头部:
``
printf 'To: root\nSubject: résumé du système\n\nTout va bien ce soir.\n' | sendmail -t
``
`\-t` 标志告诉包装器从 `To:` 头部读取收件人,这正是 cron 的做法。两种形式效果相同。
一个快速的一行测试,包含带重音符号的正文和主题:
``
echo "répertoire /var/log analysé" | mail -s "rapport été 2025" root
``
## Cron:无需任何改动
此方法的一个好处是你的 crontab 不需要任何修改。Cron 始终调用 `sendmail`——它不知道也不关心该路径背后是什么。一旦 `mailer\.conf` 指向包装器,系统上的每个 cron 任务都会自动受益,无论该任务属于 `root`、服务账户还是普通用户。
一个产生 UTF-8 输出的典型 cron 任务如下所示:
``
PATH=/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/sbin
[email protected]
*/5 * * * * /usr/local/bin/python3 /home/user/rapport.py
``
当 `rapport\.py` 将带重音符号的文本输出到标准输出时,cron 会捕获它,构造一封包含任务描述的 Subject 行(如果脚本路径或 cron 注释包含非 ASCII 字符,该描述本身也可能包含非 ASCII),并将其通过管道传给 `sendmail`。包装器拦截它,对需要编码的内容进行编码,并通过 `dma` 将其送达,同时带有正确的 `Content\-Type`。你无需进一步配置即可收到可读的邮件。
## 调试
该脚本使用 `logger\(1\)` 向 `/var/log/messages` 写入信息。在测试期间实时查看:
``
tail -f /var/log/messages | grep sendmail-wrapper
``
你将看到每个阶段:原始头部分割、处理后的 `TMP\_NEW` 内容、解析出的收件人以及最终对 `dma` 的调用。这可以让你轻松诊断与特定脚本或字符集相关的任何问题。
一旦一切正常,`dbg` 调用可以被移除或注释掉,以获得更简洁的生产脚本。
## 经验教训
这个脚本中最危险的 bug 在你仔细查看日志之前是看不见的。POSIX `sh` 函数没有局部作用域。当 `flush\_line` 赋值 `line="$1"` 时,它会静默地覆盖外层循环变量 `$line`。结果是第一个头部之后的所有头部都被输出为 `From: root \(Cron Daemon\)` 的副本。`To:` 头部消失了,收件人提取总是返回空,而 `dma` 报告“没有收件人”且没有任何进一步解释。将所有内部变量重命名为 `\_fl\_` 前缀是修复方案。
第二个微妙的问题是 `set \-e` 与 `grep \-q` 的组合使用。未找到匹配的 grep 会以退出码 1 退出。这不是一个错误——这是“未找到匹配”的文档化行为。但 `set \-e` 不会区分“命令失败”和“条件为假”。完全移除 `set \-e`,并依赖显式的 `if` 语句,是任何将 `grep` 或 `test` 用作条件工具的脚本的正确做法。
最后,OpenBSD 的 `logger\(1\)` 有一个值得一提的怪癖:它会解析消息字符串以查找选项标志,因此以 `\-` 开头的消息会触发用法错误。在每个日志消息前面加上一个空格足以解决此问题。
相似文章
从自有可路由IPv4块起步的硬核自托管邮件
一份关于使用专属IPv4块自托管邮箱的详细技术指南,涵盖信誉管理、SPF、DKIM、DMARC以及使用Dovecot和Roundcube等工具的服务器设置。
OpenSMTPD 是未来的邮件服务器
Peter N. M. Hansteen 描述了他转向 OpenSMTPD 的过程,因为 OpenBSD 7.9 放弃了 exim,他认为 OpenSMTPD 是 21 世纪的邮件服务器。
Show HN: E2a – 面向 AI 智能体的开源邮件网关
E2a 是一款开源邮件网关,支持 AI 智能体通过 webhook、WebSocket 或 HTTP API 安全地收发邮件。它具备 SPF/DKIM 验证功能,提供 TypeScript 和 Python SDK,并支持可选的人工介入审批。
你如何筛选/过滤/管理你的电子邮件?
在 lobste.rs 上的一场讨论,询问关于管理电子邮件、过滤和工具的建议,重点介绍处理大量邮件列表和补丁的开源解决方案和工作流程。
mailX by mailwarm (YC S20)
mailX 是一款面向人类和AI代理的邮件送达率工具包,由 mailwarm(YC S20)推出。