惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

M
MIT News - Artificial intelligence
D
Darknet – Hacking Tools, Hacker News & Cyber Security
SecWiki News
SecWiki News
Latest news
Latest news
A
Arctic Wolf
Know Your Adversary
Know Your Adversary
G
GRAHAM CLULEY
L
Lohrmann on Cybersecurity
T
Tor Project blog
T
Threatpost
S
Schneier on Security
P
Palo Alto Networks Blog
C
Cyber Attacks, Cyber Crime and Cyber Security
Cyberwarzone
Cyberwarzone
C
Cybersecurity and Infrastructure Security Agency CISA
C
CERT Recently Published Vulnerability Notes
博客园_首页
P
Privacy & Cybersecurity Law Blog
I
Intezer
PCI Perspectives
PCI Perspectives
Spread Privacy
Spread Privacy
G
Google Developers Blog
H
Help Net Security
WordPress大学
WordPress大学
aimingoo的专栏
aimingoo的专栏
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
V
Visual Studio Blog
U
Unit 42
Application and Cybersecurity Blog
Application and Cybersecurity Blog
W
WeLiveSecurity
D
DataBreaches.Net
N
News and Events Feed by Topic
AI
AI
The Register - Security
The Register - Security
云风的 BLOG
云风的 BLOG
The GitHub Blog
The GitHub Blog
Help Net Security
Help Net Security
K
Kaspersky official blog
Recent Announcements
Recent Announcements
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
S
Security @ Cisco Blogs
H
Hacker News: Front Page
Jina AI
Jina AI
S
Secure Thoughts
Project Zero
Project Zero
T
The Exploit Database - CXSecurity.com
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
B
Blog RSS Feed
爱范儿
爱范儿

Lobsters

CIFSwitch: a non-universal Linux local root vulnerability RIPE NCC session fixation: poaching logins with an Atlas probe GNOME 2.20 but its Web Components Agentic Search for Context Engineering – Leonie Monigatti Garnix is shutting down [not OC] akashina.tngl.sh/jjc Concerning Emacs (and Jazz) Nitpicking the shell history scene in ‘Tron: Legacy’ What's cooking on SourceHut? Q2 2026 The tenth OpenPGP email summit Package managers that package package managers Clojure on Fennel part three: parsing WordPress at 23 Finding Miscompiles for Fun, Not Profit GitHub - creusot-rs/creusot: Creusot helps you prove your Rust code is correct. Announcing Rust 1.96.0 | Rust Blog A Love Letter to Neovim sqlite AGENTS.md Am I a Bad Friend? CSS vs. JavaScript • Josh W. Comeau Erlang Ecosystem Foundation - Supporting the BEAM community A brief note about slot access cost in Common Lisp Keyboard latency probe Rethinking the GNOME clipboard issues Back to the Building Blocks’ Building Blocks Tech Notes: Theseus: translating win32 to wasm Fast is better than slow Content-addressed Rust builds (or, what kache actually caches) Intent to Prototype: Embedding API Canada’s Bill C-22 and the security cost of collecting more data 5 PostgreSQL locking behaviors that trip people up okmij.org Stop advertising in your commits! | AksDev GitHub - mplsllc/macsurf: A modern web browser for Classic Mac OS 9 PowerPC. Real CSS3, ES5 JavaScript, native HTTPS — built with CodeWarrior on the Carbon API. Introducing DoomBench - Can Your Data Stack Run DOOM? What are some of your favourite developer tools? Building a Scalable Ingestion Pipeline with Temporal (Part 1) Converting shallow Git bundles into normal repositories Are you a member of any professional associations? What is a harmonic? An interactive comic about additive synthesis How Virtual Tables Work in the Itanium C++ ABI Using SwiftUI to Build a Mac-assed App in 2026 Rust (and Slint) on a jailbroken Kindle. ~jack/lambda-on-lambda - Serverless Haskell on AWS - sourcehut git Human proof for FOSS contributions Extremely simple internet radio controlled via IRC Announcing BABLR Splitting Konsole views from Helix to run tools | AksDev GitHub - yugr/rust-slides Serving files over HTTP three ways: synchronous, epoll, and io_uring update docs with information about building with build.py (#979) · astral-sh/python-build-standalone@c9c40c5 A Simple Makefile Tutorial On C extensions, portability, and alternative compilers Switching to Colemak | Pedro Alves Just How Bad Was The Intel IAPX432? Nix's Substituter List Is Not a Routing Table Accelerating copy_if using SIMD Lambda on Lambda: Serverless Haskell on AWS | Blog Announcing feed-repeat v1.0 Scaling Akvorado BMP RIB with sharding EYG news: A host of CLI improvements, new guides and new effects The social contract of writing JS Crossword C array types are weird; and related topics Flatpak will depend on systemd – OSnews Migrating from Go to Rust | corrode Rust Consulting A portentous reunion Vivado Licensing Options How my minimal, memory-safe Go rsync steers clear of vulnerabilities the entropy layer of a wavelet codec, on its own GitHub - nferhat/fht-compositor: A dynamic tiling Wayland compositor. Debian SE Linux and PinTheft Does bulk memmove speed up std::remove_if? (No.) 声明式部分更新 | Blog | Chrome for Developers Fully in-browser container builds Dianne Skoll's Web Site - Remind The Architecture of Open Source Applications (Volume 1)Berkeley DB Pardon MIE? - ironPeak Blog “Long-Term Support” doesn’t mean what you think Jira IS Turing-Complete May I recommend thinking of Emacs as your Fortress of Solitude hershey Floodgap Gopher-HTTP gateway gopher://thelambdalab.xyz/1cuneiforth/ HP QuickWeb, Singular And Pointless That one time I used Go panics for flow control A new suite of modern tools coming for editing and publishing RFCs From the Tabletop… The Digital Antiquarian Building a Host-Tuned GCC to Make GCC Compile Faster Are we self-sovereign PKI yet? Claw Patrol: an open-source security firewall for agents | Deno Revised^7 Report on Scheme, Large: Procedural Fascicle Draft is now public A Network Allow-List Won't Stop Exfiltration — André Graf From AFSK to Goertzel – µArt.cz Software For My New Home Server Introducing Neptune: Direct3D virtualization for QEMU AI Agent Bankrupted Their Operator While Trying to Scan DN42 - Lan Tian @ Blog mimalloc: A new, high-performance, scalable memory allocator for the modern era Making wl_shm fast The Soul of Maintaining a New Machine - Third Draft | Books in Progress What is Git made of?
Vincent's blog
vincentdelft · 2026-06-01 · via Lobsters

Fixing UTF-8 Emails from Cron on OpenBSD 7.8 with a sendmail Wrapper

If you run cron jobs on OpenBSD and have scripts that produce UTF-8 output — accented characters, special symbols, anything outside plain ASCII — you have probably noticed that the emails you receive are garbled. Subject lines with accents become a soup of M-CM-9 sequences, and the body may display correctly or not depending on whether your MUA bothers to guess the encoding. This article walks through how I solved this cleanly by writing a small sh wrapper that sits between cron and dma, OpenBSD's lightweight mail agent.

The Problem

OpenBSD's cron daemon hands outgoing mail to whatever binary is configured as sendmail, typically /usr/sbin/sendmail which on a base OpenBSD install is symlinked to dma. When a cron job produces output, cron constructs a minimal RFC 2822 email — headers like From:, To:, Subject:, a set of X-Cron-Env: lines — and pipes the whole thing into that binary.

The problem is twofold. First, dma does not encode non-ASCII header values. If your script name or its output contains é, ù, à or any other character outside the 7-bit ASCII range, the Subject line goes out raw and most mail servers or clients will either mangle it or reject it outright. Second, there is no Content-Type: text/plain; charset=utf-8 header, so even if the body arrives intact, the recipient's mail client has no declaration to go on.

The correct fix for non-ASCII headers is RFC 2047 encoded words. A Subject like Rapport système should become:

Subject: =?utf-8?B?UmFwcG9ydCBzeXN0w6htZQ==?=

That base64 blob tells any conformant mail client that the value is UTF-8 encoded.

The Approach

Rather than patching dma — which is a bad idea on a system you want to keep auditable — the solution is a small POSIX sh script installed as the sendmail binary. It intercepts the email stream, fixes the headers, injects a Content-Type declaration if needed, and then hands the cleaned-up message to the real dma binary.

The script has to handle several real-world complications that are not obvious until you actually run it in production and read the logs carefully:

  • Cron sometimes pipes raw text with no headers at all. The entire input is body.
  • Cron passes a full set of sendmail-compatible flags: -FCronDaemon -odi -oem -oi -t. The -t flag means "read recipients from the To: header". dma does not understand any of these flags and needs a plain recipient address as a positional argument.
  • RFC 2822 allows headers to be folded across multiple lines, with continuation lines starting with whitespace.
  • POSIX sh has no local variables. A shell function that assigns line="$1" silently clobbers any outer variable named line — a trap that is particularly vicious inside a while IFS= read -r line loop.
  • OpenBSD's b64encode wraps its output with a begin-base64 644 - header line and a ==== trailer. Both must be stripped before the base64 data can be embedded in an RFC 2047 encoded word.
  • OpenBSD's logger(1) does not accept -- to end option parsing, and it treats any message string starting with - as an unknown option. All log messages must be prefixed with a space or another non-dash character.

The Script

Install the script as /usr/local/sbin/dma_utf8 (or any path you prefer), make it executable, and point sendmail at it. The real dma binary should remain at /usr/local/sbin/dma.

#!/bin/sh
#
# sendmail wrapper for dma on OpenBSD 7.8.
# Encodes non-ASCII header values as RFC 2047 base64 encoded words and
# ensures a UTF-8 Content-Type declaration is present in every outgoing email.
#
# Install: chmod 755 /usr/local/sbin/dma_utf8
# Then set the path in /etc/mailer.conf (see article).
#
# Requires: awk, b64encode, sed, grep, dma at /usr/local/sbin/dma

# Do NOT add set -e here. grep -q returns exit code 1 when it finds no match,
# which is completely normal behaviour. set -e would treat that as a fatal
# error and silently kill the script before dma is ever called.

dbg() {
    # OpenBSD logger treats any message starting with '-' as an option flag.
    # Prefix with a space to avoid this.
    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

# Split the incoming email into headers and body.
# Everything before the first blank line goes to TMP_HDR; the rest to TMP_BDY.
awk '
    BEGIN { in_body = 0 }
    /^$/ && !in_body { in_body = 1; next }
    { if (in_body) print > "'"$TMP_BDY"'"; else print > "'"$TMP_HDR"'" }
'

# When cron produces no output but still mails (or when a script just prints
# plain text without constructing headers), the first line will not match the
# RFC 2822 "Field: value" pattern. Treat everything as body in that case.
first_line=$(head -1 "$TMP_HDR")
if ! printf '%s' "$first_line" | grep -qE '^[A-Za-z-]+:'; then
    dbg "No headers detected, treating entire input as body"
    cat "$TMP_HDR" "$TMP_BDY" > "${TMP_BDY}.all"
    mv "${TMP_BDY}.all" "$TMP_BDY"
    : > "$TMP_HDR"
fi

# Process headers. Non-ASCII values in Subject, From, and To are encoded as
# RFC 2047 base64 encoded words. A UTF-8 charset is appended to Content-Type
# if it is missing. If no Content-Type header is present at all, one is added.
#
# Variables inside flush_line are prefixed _fl_ to avoid colliding with the
# outer while loop. POSIX sh has no local variables: a bare assignment inside
# a function modifies the global scope, which would silently corrupt $line in
# the calling loop.

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
}

# Unfold RFC 2822 continuation lines (lines starting with whitespace are
# continuations of the previous header) before passing them to flush_line.
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"

# Inject Content-Type if no such header was present in the original email.
if [ ! -s "$TMP_FLG" ]; then
    printf 'Content-Type: text/plain; charset=utf-8\n' >> "$TMP_NEW"
fi

# Resolve recipients for dma.
# Cron calls sendmail with: -FCronDaemon -odi -oem -oi -t
# dma understands none of these flags. -t means "read recipients from To:".
# We scan the argument list: collect any plain addresses, note if -t was passed,
# and drop all other sendmail-style flags. If -t was present or no plain address
# was found, we extract the To: value from the processed headers.
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 "Calling dma with recipients: $recipients"

{
    cat "$TMP_NEW"
    printf '\n'
    cat "$TMP_BDY"
} | /usr/local/sbin/dma $recipients

Installation

Copy the script to /usr/local/sbin/dma_utf8 and make it executable:

install -o root -g wheel -m 755 dma_utf8 /usr/local/sbin/dma_utf8

Then tell the system to use it instead of the stock sendmail path. On OpenBSD, the canonical way is /etc/mailer.conf:

sendmail        /usr/local/sbin/dma_utf8
send-mail       /usr/local/sbin/dma_utf8
...

Using it from the Command Line

The wrapper is fully transparent. Any tool that sends mail through the sendmail interface — mail(1), printf piped directly, application code calling /usr/sbin/sendmail — will go through it automatically once mailer.conf is in place. You do not need to call /usr/local/sbin/dma_utf8 directly.

The most natural way to send a quick UTF-8 email from the shell is with mail(1):

echo "C'est vraiment l'été !!" | mail -s "T'es où" root

The -s flag sets the Subject. The body comes from stdin. The wrapper will detect the non-ASCII characters in the Subject, encode it as an RFC 2047 word, add a Content-Type: text/plain; charset=utf-8 header, and pass the cleaned message to dma. The recipient root is resolved through your aliases as usual.

You can also pipe a fully constructed RFC 2822 message if you need more control over the headers:

printf 'To: root\nSubject: résumé du système\n\nTout va bien ce soir.\n' | sendmail -t

The -t flag tells the wrapper to read the recipient from the To: header, which is exactly what cron does. Both forms work identically.

For a quick one-liner test with an accented body and subject:

echo "répertoire /var/log analysé" | mail -s "rapport été 2025" root

Cron: Nothing to Change

One of the nice things about this approach is that your crontab requires zero modifications. Cron has always called sendmail — it does not know or care what is behind that path. Once mailer.conf points to the wrapper, every cron job on the system benefits automatically, whether the job is owned by root, a service account, or a regular user.

A typical cron job that produces UTF-8 output looks like this:

PATH=/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/sbin
MAILTO=you@example.com
*/5 * * * * /usr/local/bin/python3 /home/user/rapport.py

When rapport.py prints accented text to stdout, cron captures it, constructs the email with a Subject line containing the job description (which may itself contain non-ASCII if your script path or cron comment does), and pipes it to sendmail. The wrapper intercepts it, encodes what needs encoding, and delivers it through dma with a proper Content-Type. You receive a readable email with no further configuration.

Debugging

The script uses logger(1) to write to /var/log/messages. To watch it live during a test:

tail -f /var/log/messages | grep sendmail-wrapper

You will see each stage: the raw header split, the processed TMP_NEW content, the resolved recipient, and the final call to dma. This makes it straightforward to diagnose any issue with a specific script or character set.

Once everything is working to your satisfaction, the dbg calls can be removed or commented out for a cleaner production script.

Lessons Learned

The most dangerous bug in this script is invisible until you look carefully at the logs. POSIX sh functions do not have local scope. When flush_line assigned line="$1", it silently overwrote the outer loop variable $line. The result was that every header after the first was output as a copy of From: root (Cron Daemon). The To: header vanished, recipient extraction always returned empty, and dma reported "no recipients" with no further explanation. Renaming all internal variables with a _fl_ prefix was the fix.

The second subtle issue is set -e combined with grep -q. A grep that finds no match exits with code 1. That is not an error — it is the documented behaviour for "no match found". But set -e does not distinguish between "command failed" and "condition was false". Removing set -e entirely and relying on explicit if statements is the right approach for any script that uses grep or test as conditional tools.

Finally, OpenBSD's logger(1) has a quirk worth knowing: it parses the message string for option flags, so a message beginning with - triggers a usage error. Prefixing every log message with a space is enough to work around it.