使用DMA的UTF-8电子邮件:DragonFly Mail Agent

Lobsters Hottest 工具

摘要

一份技术指南,介绍如何通过创建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\)` 有一个值得一提的怪癖:它会解析消息字符串以查找选项标志,因此以 `\-` 开头的消息会触发用法错误。在每个日志消息前面加上一个空格足以解决此问题。

相似文章

OpenSMTPD 是未来的邮件服务器

Lobsters Hottest

Peter N. M. Hansteen 描述了他转向 OpenSMTPD 的过程,因为 OpenBSD 7.9 放弃了 exim,他认为 OpenSMTPD 是 21 世纪的邮件服务器。

Show HN: E2a – 面向 AI 智能体的开源邮件网关

Hacker News Top

E2a 是一款开源邮件网关,支持 AI 智能体通过 webhook、WebSocket 或 HTTP API 安全地收发邮件。它具备 SPF/DKIM 验证功能,提供 TypeScript 和 Python SDK,并支持可选的人工介入审批。

mailX by mailwarm (YC S20)

Product Hunt

mailX 是一款面向人类和AI代理的邮件送达率工具包,由 mailwarm(YC S20)推出。