Thank you for visiting!
My little window on internet allowing me to share several of my passions
Categories:
- OpenBSD
- FreeBSD
- fapws
- Nvim
- Firewall
- got
- PEKwm
- Zsh
- VM
- High Availability
- vdcron
- My Sysupgrade
- Nas
- VPN
- DragonflyBSD
- Alpine Linux
- Openbox
- Desktop
- Security
- yabitrot
- nmctl
- Tint2
- Project Management
- Hifi
- Alarm
Most Popular Articles:
Last Articles:
UTF8 email with DMA: DragonFly Mail Agent
Posted on 2026-05-30 20:40:00 from Vincent in OpenBSD FreeBSD

In the context of the DMA developped in this post, this blog post improve it so to have accents and all nice icons in emails delivered by DMA
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-tflag means "read recipients from theTo:header".dmadoes 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
shhas nolocalvariables. A shell function that assignsline="$1"silently clobbers any outer variable namedline— a trap that is particularly vicious inside awhile IFS= read -r lineloop. - OpenBSD's
b64encodewraps its output with abegin-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.