UTF8 email with DMA: DragonFly Mail Agent

Lobsters Hottest Tools

Summary

A technical guide for fixing UTF-8 email encoding issues from cron on OpenBSD by creating a sendmail wrapper that encodes non-ASCII headers and adds Content-Type declarations before handing messages to dma.

<p><a href="https://lobste.rs/s/acsqgj/utf8_email_with_dma_dragonfly_mail_agent">Comments</a></p>
Original Article
View Cached Full Text

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\.

Similar Articles

Show HN: E2a – Open-source email gateway for AI agents

Hacker News Top

E2a is an open-source email gateway that enables AI agents to securely send and receive emails via webhooks, WebSockets, or HTTP APIs, featuring SPF/DKIM verification, TypeScript and Python SDKs, and optional human-in-the-loop approval.

How do you sieve/filter/manage your internet mail?

Lobsters Hottest

A discussion on lobste.rs asking for advice on managing email, filtering, and tooling, with a focus on FOSS solutions and workflows for handling high volumes of mailing lists and patches.

mailX by mailwarm (YC S20)

Product Hunt

mailX is an email deliverability toolkit designed for both humans and AI agents, launched by mailwarm (YC S20).