Cached at:
06/01/26, 06:28 AM
# Vincent's blog
Source: [https://www.vincentdelft.be/post/post_20260530](https://www.vincentdelft.be/post/post_20260530)
## 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
[email protected]
*/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\.