E‑Mails aus Skripten versenden mit msmtp

Schwarz-weiße, handgezeichnete Skizze eines Schreibtischs: Ein Monitor zeigt ein Terminal mit E-Mail- und MIME-Code, daneben steht ein Raspberry Pi mit blinkenden LEDs. Auf dem Tisch liegen eine Kaffeetasse, Haftnotizen und ein Briefumschlag mit @-Symbol. Ein stilisierter Umschlag fliegt vom Bildschirm in Richtung eines kleinen Mailserver-Icons im Hintergrund.

Auf mei­nem Raspberry lau­fen eini­ge Skripte, die mei­ne Systeme über­wa­chen und bei bestimm­ten Events eine Mail ver­schi­cken sol­len. In mei­nem Blog-Post Postfix Konfiguration für CLI Mail habe ich beschrie­ben, wie ich dazu Postfix als loka­len Mailserver instal­liert und kon­fi­gu­riert habe. Hier möch­te ich eine ein­fa­che­re Möglichkeit vor­stel­len, wenn es nur dar­um geht, von einem Linux- oder Mac-System aus der Kommandozeile E‑Mails zu ver­schi­cken.

Wie funktioniert E‑Mail eigentlich?

Grundsätzlich ist für das Empfangen und Senden einer E‑Mail ein Mailserver not­wen­dig, der natür­lich aus dem Internet erreich­bar und als Mailserver bekannt sein muss. In den meis­ten Fällen wird der hei­mi­sche Rechner nicht direkt als Mailserver fun­gie­ren, son­dern es wird ein E‑Mail-Programm wie Apple Mail oder Thunderbird genutzt, um Mails vom Server zu laden bzw. eine Mail über den Server zu sen­den.

Die Kommunikation zwi­schen dem Programm und dem Server geschieht über zwei Protokolle: SMTP zum Senden und IMAP zum Lesen und Verwalten der Mails auf dem Server. Mit IMAP wird der Inhalt des Servers sozu­sa­gen gespie­gelt, ähn­lich wie bei der Integration von Cloud-Speicher wie Dropbox oder iCloud. Die Daten wer­den auf dem Computer syn­chro­ni­siert: Wird eine Mail in Thunderbird gelöscht, wird sie auch auf dem Server gelöscht. So kann ein Mail-Account mit ver­schie­de­nen Computern ver­bun­den sein, und alle sehen die glei­che Ordnerstruktur und die ent­spre­chen­den Mails. Aber um die­sen Teil der E‑Mail-Experience geht es hier nicht.

Für das Senden einer E‑Mail wird das SMTP-Protokoll ver­wen­det. Wenn eine Mail im E‑Mail-Programm erstellt und der Senden-Button gedrückt wird, wird aus der Eingabe die Mail nach bestimm­ten Regeln auf­ge­baut und dann via SMTP an den jewei­li­gen Mailserver über­tra­gen. Dieser sen­det die E‑Mail dann an den Server des Empfängers wei­ter, der sie ver­ar­bei­tet und im Mail-Account des Empfängers ablegt.

Header, Body und MIME: Der Aufbau einer E‑Mai

Eine E‑Mail besteht aus zwei Teilen: dem Header und dem Body, getrennt durch eine Leerzeile.

Im Header ste­hen die Pflichtfelder From (Absender) und To (Empfänger). Das Datum (Date) ist eben­falls Pflicht und soll­te vom sen­den­den Client gesetzt wer­den. Fehlt es, ergänzt es der Mailserver als Fallback. Optional sind der Betreff (Subject), Kopie- und Blindkopie-Empfänger (CC und BCC) sowie Reply-To, falls Antworten an eine ande­re Adresse gehen sol­len. Die Message-ID soll­te eben­falls gesetzt wer­den, da Mailprogramme sie nut­zen, um Mails einer Konversation zuzu­ord­nen.

Bei Mails mit Anhängen oder gemisch­tem Inhalt muss im Header außer­dem Content-Type mit einer boundary defi­niert wer­den, damit der emp­fan­gen­de Client den Mail-Body kor­rekt zusam­men­set­zen kann. In die­sem Fall muss auch MIME-Version: 1.0 im Header ste­hen.

Die MIME-Struktur bei HTML-Mails

Bei einer rei­nen Text-Mail ist der Body sim­pel. Sobald aber HTML, Bilder oder Anhänge ins Spiel kom­men, wird die Mail in meh­re­re Teile auf­ge­teilt, die wie­der­um Teile ent­hal­ten kön­nen. Das sieht kom­pli­zier­ter aus, als es ist.

Hier ein Beispiel für eine Mail mit HTML-Part, Klartext-Fallback und einem ein­ge­bet­te­ten Bild als Signatur:

From: sender@beispiel.de
To: empfaenger@beispiel.de
Subject: =?utf-8?Q?Sch=C3=B6ner_Betreff_mit_Uml=C3=A4uten?=
Date: Tue, 17 Mar 2026 10:00:00 +0100
Message-ID: <unique-id@beispiel.de>
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="==GRENZE_1=="

--==GRENZE_1==
Content-Type: multipart/related; boundary="==GRENZE_2=="

--==GRENZE_2==
Content-Type: multipart/alternative; boundary="==GRENZE_3=="

--==GRENZE_3==
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: quoted-printable

Hallo! Sch=C3=B6ne Gr=C3=BC=C3=9Fe!

--==GRENZE_3==
Content-Type: text/html; charset="utf-8"
Content-Transfer-Encoding: quoted-printable

<html>
  <body>
    <p>Hallo! Sch=C3=B6ne Gr=C3=BC=C3=9Fe!</p>
    <img src="cid:signatur.png">
  </body>
</html>

--==GRENZE_3==--

--==GRENZE_2==
Content-Type: image/png
Content-Transfer-Encoding: base64
Content-ID: <signatur.png>
Content-Disposition: inline

iVBORw0KGgoAAAANSUhEUgAA...

--==GRENZE_2==--

--==GRENZE_1==--

Die ver­schach­tel­te Struktur macht auf den ers­ten Blick etwas Eindruck, folgt aber einer kla­ren Logik: Der äuße­re Container multipart/mixed ist der Rahmen für die gesam­te Mail und wäre der rich­ti­ge Ort für ech­te Dateianhänge. Darin liegt multipart/related, das HTML und das ein­ge­bet­te­te Bild zusam­men­hält, das im HTML über cid:signatur.png refe­ren­ziert wird und daher kein eigen­stän­di­ger Anhang ist. Innerhalb von related wie­der­um bie­tet multipart/alternative dem Mailclient die Wahl: Er ren­dert ent­we­der den HTML-Part oder, falls er kein HTML unter­stützt, den Klartext-Part.

Zeichenkodierung: Warum quoted-printable?

Das SMTP-Protokoll wur­de zu einer Zeit defi­niert, als 7‑Bit-ASCII der zuver­läs­si­ge Standard für die Übertragung war. In die­sen 127 Zeichen sind Umlaute und ande­re Sonderzeichen nicht defi­niert. Ein ö wird in UTF‑8 als zwei Bytes kodiert, 0xC3 und 0xB6, bei­des Werte über 127. Alte Relay-Server konn­ten sol­che Bytes beschä­di­gen oder abschnei­den.

quoted-printable löst das, indem alle Nicht-ASCII-Zeichen in eine siche­re ASCII-Darstellung umge­wan­delt wer­den. Aus öwird =C3=B6, aus ü wird =C3=BC. Der emp­fan­gen­de Server trans­por­tiert die­se kodier­ten Bytes unver­än­dert, und erst der Mailclient deko­diert sie zurück und inter­pre­tiert sie gemäß der charset=utf-8-Angabe im Content-Type.

Im Header funk­tio­niert das etwas anders: Dort gibt es kein charset-Feld, statt­des­sen wird das Format RFC 2047 Encoded Word ver­wen­det:

Subject: =?utf-8?Q?Sch=C3=B6ner_Betreff_mit_Uml=C3=A4uten?=

Das Format ist dann immer:

=?Zeichensatz?Kodierung?kodierter Text?=

Dabei steht das Q für Quoted-Printable und B für Base64. Der Mailclient deko­diert das auto­ma­tisch und zeigt „Schöner Betreff mit Umlauten” an.

Moderne Mailserver unter­stüt­zen zwar oft die SMTP-Erweiterung SMTPUTF8 (RFC 6531), die rohe UTF-8-Bytes im Transport erlaubt, aber es ist nicht sicher, wel­che Server eine Mail auf ihrem Weg pas­siert. Wer sicher­ge­hen will, kodiert kor­rekt.

msmtp installieren und konfigurieren

msmtp ist ein schlan­ker SMTP-Client. Er ver­schickt E‑Mails über den SMTP-Server dei­nes Providers, ohne selbst ein Mailserver zu sein. Er nimmt die fer­ti­ge Nachricht von der Standardeingabe ent­ge­gen und lei­tet sie wei­ter. Das heißt: Die Nachricht muss bereits kor­rekt auf­ge­baut sein, inklu­si­ve Header, Leerzeile und Inhalt. msmtp baut dar­aus kei­ne HTML-Mail, kei­ne Anhänge und kei­ne Struktur, es ver­schickt nur, was du ihm gibst. Und da eben kein Mailprogramm die Kodierung über­nimmt, muss das, wie im vor­he­ri­gen Abschnitt beschrie­ben, vor­her erle­digt sein.

Hier ein ganz ein­fa­ches Beispiel, mit dem du von der Kommandozeile einen Text ver­schi­cken kannst:

echo -e "Subject: Test\n\nHallo Welt" | msmtp test@example.com

Wichtig ist dabei, dass echo mit der Option -e auf­ge­ru­fen wird, da zwi­schen Subject: Test und dem Inhalt der Mail eine Leerzeile sein muss. Die Option sorgt dafür, dass \n als Zeilenumbruch und nicht als Text über­tra­gen wird.

Installation

Auf einem Linux-System wie Ubuntu gibt es zwei rele­van­te Pakete:

  • msmtp : der eigent­li­che SMTP-Client
  • msmtp-mta : ein Kompatibilitäts-Wrapper, der msmtp als Ersatz für klas­si­sche MTAs wie send­mail oder Postfix ein­trägt

Für das ein­fa­che Versenden über die Kommandozeile reicht das ers­te Paket. Das zwei­te ist nur dann sinn­voll, wenn du ande­re Kommandozeilen-Programme wie mail zum Versenden nut­zen möch­test und noch kei­nen MTA wie send­mail oder Postfix kon­fi­gu­riert hast. Falls du einen Desktop-Mail-Client instal­liert hast, kom­mu­ni­ziert der nor­ma­ler­wei­se direkt mit dei­nem Mailserver, daher hat die­se Installation kei­nen Einfluss auf Senden und Empfangen von Mails mit dem Mail-Client.

Für die Installation gibst du fol­gen­de Befehle ein:

sudo apt update
sudo apt install msmtp

Wenn wäh­rend der Installation vor­ge­schla­gen wird, msmtp-mta zu instal­lie­ren, ein­fach nicht zustim­men, wodurch nichts an bestehen­den Konfigurationen ver­än­dert wird.

Nach dem Download wirst du wahr­schein­lich gefragt, ob du msmtp in dein AppArmor-Profil auf­neh­men möch­test. Dabei han­delt es sich um einen Sicherheitsmechanismus in Ubuntu. In mei­ner Test-Installation habe ich die Option bestä­tigt und bis­her kei­ne Probleme damit gehabt.

Unter macOS kann msmtp mit Homebrew oder einem ande­ren Paketmanager instal­liert wer­den:

brew install msmtp

Konfiguration

Nun muss noch eine Konfigurationsdatei ange­legt wer­den. Du hast die Wahl zwi­schen einer benut­zer­spe­zi­fi­schen Datei unter ~/.msmtprc oder einer glo­ba­len unter /etc/msmtprc. Bei einer glo­ba­len Konfiguration kön­nen alle Benutzer des Systems, inklu­si­ve root, Cronjobs und Systemdienste, msmtp mit den dort hin­ter­leg­ten Mail-Zugängen nut­zen, ohne eine eige­ne Konfiguration zu benö­ti­gen.

In mei­nem Setup ist die glo­ba­le Variante sinn­voll, weil ich der ein­zi­ge inter­ak­ti­ve Benutzer bin und so auch Skripte, die als root lau­fen, pro­blem­los Mails ver­schi­cken kön­nen. Wenn meh­re­re Benutzer jeweils ihren eige­nen Mailaccount nut­zen sol­len, ist die benut­zer­spe­zi­fi­sche ~/.msmtprc die bes­se­re Wahl. Auf dem Mac ist das gene­rell emp­feh­lens­wert, da das mit Homebrew instal­lier­te msmtp die sys­tem­wei­te Konfigurationsdatei an einem ver­si­ons­spe­zi­fi­schen Pfad erwar­tet, der bei jedem Update von msmtp wech­selt. Die benut­zer­spe­zi­fi­sche ~/.msmtprc funk­tio­niert dage­gen auf macOS und Linux gleich und bleibt von Updates unbe­rührt.

defaults
auth           on
tls            on
tls_starttls   on
tls_trust_file /etc/ssl/certs/ca-certificates.crt

# Auf macOS werden die Zertifikate aus der Keychain geladen,
# deshalb muss die Zeile mit tls_trust_file gelöscht oder
# auskommentiert werden.

# Auf Ubuntu ist syslog meist sinnvoller als logfile
syslog LOG_MAIL

# Auf macOS kann das logfile direkt geschrieben werden
# logfile        /Users/MeinUser/Library/Logs/msmtp.log

# Globales logfile, kann wegen der Schreibrechte Probleme machen
# logfile        /var/log/msmtp.log

# User logfile, einfache Lösung wenn die Aufrufe von msmtp immer
# von dem User ausgehen.
# logfile        ~/.msmtp.log

account        default
host           smtp.deinprovider.de
port           587
from           deinuser@domain.de
user           deinuser@domain.de
password       deinpasswort

Du kannst meh­re­re account-Einträge in der Konfiguration anle­gen, um z.B. Mails über ver­schie­de­ne Absenderadressen zu ver­schi­cken. Einer muss dabei den Namen default tra­gen, der dann ver­wen­det wird, wenn msmtp ohne die expli­zi­te Angabe eines Accounts auf­ge­ru­fen wird. Dazu wei­ter unten mehr.

Wenn du die Datei ange­legt und gespei­chert hast, kannst du Mails mit msmtp ver­sen­den.

Erste Tests auf der Kommandozeile

Nun kannst du die ers­te Testmail ver­schi­cken:

echo -e "Subject: Test\n\nHallo Welt" | msmtp test@example.com --debug

Eigentlich wird damit kei­ne voll­stän­di­ge E‑Mail nach Standard erzeugt, da außer dem Subject alle ande­ren Header-Felder feh­len. msmtp erstellt aus der E‑Mail-Adresse im Argument und dem from-Eintrag in der msmtprc einen SMTP-Envelope, in den das Ergebnis von echo -e ein­ge­packt und ver­schickt wird. Die meis­ten moder­nen Mailserver, die eine sol­che Mail emp­fan­gen, ergän­zen die feh­len­den Header-Felder wie FromToDate und Message-ID, sodass in der Mailbox eine voll­stän­di­ge Mail lan­det, aller­dings soll­te man sich nicht dar­auf ver­las­sen.

Ein wich­ti­ger Aspekt fällt dabei viel­leicht nicht sofort auf. Die Option -e stellt sicher, dass \n nicht als Text, son­dern als ech­ter Zeilenumbruch über­tra­gen wird. Wichtig ist außer­dem, dass der Header durch eine Leerzeile vom Body getrennt sein muss. Daher ste­hen an die­ser Stelle zwei \n hin­ter­ein­an­der: Das ers­te been­det die letz­te Header-Zeile, das zwei­te erzeugt die erfor­der­li­che Leerzeile.

Aus die­sem Grund ist der fol­gen­de Aufruf die bes­se­re Wahl. Alle wich­ti­gen Header-Felder wer­den voll­stän­dig auf­ge­baut, bevor die fer­ti­ge Mail an msmtp über­ge­ben wird:

printf '%s\n' \
  "From: Server <server@example.com>" \
  "To: Leif <leif@example.org>" \
  "Subject: Test via msmtp" \
  "Date: $(LC_ALL=C date -R)" \
  "MIME-Version: 1.0" \
  "Content-Type: text/plain; charset=UTF-8" \
  "" \
  "Hallo," \
  "das ist ein Test." \
| msmtp test@example.com

Falls du dich über das \ am Ende jeder Zeile wun­derst: Es ver­hin­dert, dass das Terminal jede Zeile als eige­nes Kommando inter­pre­tiert. Eigentlich müss­te der gesam­te Aufruf in einer ein­zi­gen Zeile ste­hen, was aber schnell unles­bar wird. Auf der ande­ren Seite muss sicher­ge­stellt sein, dass die ein­zel­nen Header-Felder bei der Übertragung durch Zeilenumbrüche getrennt sind. Das über­nimmt der Formatstring '%s\n' des printf-Befehls, der besagt, dass jeder Text zwi­schen "" mit einem Zeilenumbruch abge­schlos­sen wird.

Skript zum Senden von Systeminfos

Nachdem getes­tet wur­de, dass das Versenden einer Mail von der Kommandozeile funk­tio­niert, möch­te ich hier ein Skript als Blaupause vor­stel­len, mit dem eini­ge aktu­el­le Systemparameter als Mail ver­schickt wer­den.

Es wird eine Mail erstellt, in der im Header als Content-Type multipart/alternative defi­niert ist. Das bedeu­tet, dass der äuße­re Container zwei oder mehr Teile beinhal­tet. In die­sem Fall einen HTML-Teil, der die Daten als Tabelle dar­stellt, und einen rei­nen Text-Teil als Fallback für Mailclients ohne HTML-Unterstützung.

Das Skript funk­tio­niert sowohl unter macOS als auch unter Linux.

Es ist in drei Teile auf­ge­baut. Im ers­ten Teil wer­den Variablen mit Werten gefüllt, die für den Aufbau der Mail not­wen­dig sind. Das sind zum einen die Header-Daten, wobei der Betreff aus dem Text „System-Report” und dem aktu­el­len Datum zusam­men­ge­setzt wird. Auch die Variable für die Boundary wird aus dem Text ALT_ und den Sekunden zusam­men­ge­setzt, die seit dem 01.01.1970 ver­gan­gen sind. Das ist das Datum, an dem die Unix-Zeitrechnung beginnt. Mit die­sem Trick wird sicher­ge­stellt, dass der Boundary-Name ein­deu­tig ist, damit kei­ne Kollisionen auf­tre­ten, wenn z.B. eine Mail in einer ande­ren Mail ein­ge­bet­tet ist.

Im zwei­ten Teil wer­den die Systemdaten in Variablen geschrie­ben. Mit HOSTNAME=$(hostname) wird der Befehl hostnameaus­ge­führt und sein Ergebnis in der Variable HOSTNAME gespei­chert. Die Schreibweise $(...) ist dabei das Muster: Was auch immer zwi­schen den Klammern steht, wird als Befehl aus­ge­führt und das Ergebnis direkt ver­wen­det. Das glei­che Muster fin­det sich im Skript noch öfter, zum Beispiel bei $(date) oder $(uname). So kann auch das Ergebnis eines kom­ple­xen Aufrufs an eine Variable über­ge­ben wer­den, wie:

DISK=$(df -h / | awk 'NR==2 {print $3 " von " $2 " belegt (" $5 ")"}')

Hier wird der Befehl df -h / auf­ge­ru­fen, der den Speicherplatz des Wurzelverzeichnisses / anzeigt. Das -h sorgt dafür, dass das Ergebnis „human rea­da­ble” ist, also die Größen in les­ba­ren Einheiten wie GB oder MB aus­ge­ge­ben wer­den statt in Bytes. Die Ausgabe sieht dann unge­fähr so aus:

Filesystem      Size  Used Avail Use% Mounted on
/dev/sda1        30G  8.2G   22G  28% /

Das | lei­tet das Ergebnis an awk wei­ter, ein sehr mäch­ti­ges Werkzeug, das Textmuster erken­nen und ver­ar­bei­ten kann. In die­sem Fall sorgt NR==2 dafür, dass nur die zwei­te Zeile ver­ar­bei­tet wird, also die, in der die Daten ste­hen. $3$2 und $5adres­sie­ren die Werte in den jewei­li­gen Spalten, sodass mit:

{print $3 " von " $2 " belegt (" $5 ")"}

der Text 8.2G von 30G belegt (28%) erzeugt und in die Variable DISK geschrie­ben wird.

Ähnlich ver­hält es sich mit den ande­ren Systemparametern. Das Skript unter­schei­det dabei, ob es unter Linux oder macOS läuft, da RAM und CPU-Informationen auf bei­den Systemen unter­schied­lich abge­fragt wer­den.

Im drit­ten Teil wird der Inhalt der Mail defi­niert. Der rei­ne Textteil wird in die Variable PLAIN geschrie­ben, der HTML-Part in die Variable HTML. Anschließend wird die Mail zusam­men­ge­baut, wobei für jeden Teil, also Header, Plaintext und HTML, ein eige­nes printf-Kommando ver­wen­det wird.

Damit der Plaintext und der HTML-Teil kor­rekt kodiert wer­den, wird ein Python-Einzeiler genutzt, der sowohl unter Linux als auch unter macOS funk­tio­niert:

printf '%s\n' "$PLAIN" | python3 -c "
import sys, quopri
sys.stdout.buffer.write(quopri.encodestring(sys.stdin.buffer.read()))
"

Mit den printf-Kommandos wird die Mail zusam­men­ge­baut und am Ende an msmtp wei­ter­ge­lei­tet, das sie an $TOver­schickt.

#!/bin/bash

TO="empfaenger@example.com"
FROM="sender@example.com"
SUBJECT="System-Report $(date '+%d.%m.%Y')"
BOUNDARY_ALT="==ALT_$(date +%s)=="

# Systemdaten
HOSTNAME=$(hostname)
OS=$(uname)

# Speicherplatz (funktioniert auf beiden Systemen gleich)
DISK=$(df -h / | awk 'NR==2 {print $3 " von " $2 " belegt (" $5 ")"}')

# Uptime (funktioniert auf beiden Systemen)
UPTIME=$(uptime | sed 's/.*up //' | sed 's/,.*//')

# RAM und CPU je nach Betriebssystem
if [ "$OS" = "Darwin" ]; then
    RAM_TOTAL=$(sysctl -n hw.memsize | awk '{printf "%.0f MB", $1/1024/1024}')
    RAM_USED=$(vm_stat | awk '
        /Pages active/  {active=$3}
        /Pages wired/   {wired=$4}
        END {printf "%.0f MB", (active+wired)*4096/1024/1024}')
    RAM="${RAM_USED} belegt von ${RAM_TOTAL}"
    CPU_INFO=$(sysctl -n machdep.cpu.brand_string)
else
    RAM=$(free -m | awk 'NR==2 {printf "%d MB von %d MB belegt", $3, $2}')
    CPU_INFO=$(grep "model name" /proc/cpuinfo | head -1 | cut -d: -f2 | xargs)
fi

# Plaintext-Part
PLAIN="System-Report: $HOSTNAME
$(date)

Betriebssystem : $OS
CPU            : $CPU_INFO
Speicher       : $DISK
RAM            : $RAM
Uptime         : $UPTIME"

# HTML-Part
HTML="<!DOCTYPE html>
<html>
<head><meta charset=\"utf-8\"></head>
<body style=\"font-family: sans-serif; font-size: 14px;\">
<h2>System-Report: $HOSTNAME</h2>
<p style=\"color: #666;\">$(date)</p>
<table style=\"border-collapse: collapse; width: 100%;\">
  <tr style=\"background: #f0f0f0;\">
    <td style=\"padding: 8px; border: 1px solid #ccc;\"><strong>Betriebssystem</strong></td>
    <td style=\"padding: 8px; border: 1px solid #ccc;\">$OS</td>
  </tr>
  <tr>
    <td style=\"padding: 8px; border: 1px solid #ccc;\"><strong>CPU</strong></td>
    <td style=\"padding: 8px; border: 1px solid #ccc;\">$CPU_INFO</td>
  </tr>
  <tr style=\"background: #f0f0f0;\">
    <td style=\"padding: 8px; border: 1px solid #ccc;\"><strong>Speicher</strong></td>
    <td style=\"padding: 8px; border: 1px solid #ccc;\">$DISK</td>
  </tr>
  <tr>
    <td style=\"padding: 8px; border: 1px solid #ccc;\"><strong>RAM</strong></td>
    <td style=\"padding: 8px; border: 1px solid #ccc;\">$RAM</td>
  </tr>
  <tr style=\"background: #f0f0f0;\">
    <td style=\"padding: 8px; border: 1px solid #ccc;\"><strong>Uptime</strong></td>
    <td style=\"padding: 8px; border: 1px solid #ccc;\">$UPTIME</td>
  </tr>
</table>
</body>
</html>"

# Mail zusammenbauen und verschicken

# Header - am Ende eine Leerzeile
{
  printf '%s\n' \
    "From: $FROM" \
    "To: $TO" \
    "Subject: $SUBJECT" \
    "Date: $(LC_ALL=C date -R)" \
    "MIME-Version: 1.0" \
    "Content-Type: multipart/alternative; boundary=\"$BOUNDARY_ALT\"" \
    ""

# Plaintext-Part (beginnt mit Boundary und Leerzeile)
  printf '%s\n' \
    "--$BOUNDARY_ALT" \
    "Content-Type: text/plain; charset=utf-8" \
    "Content-Transfer-Encoding: quoted-printable" \
    ""
  printf '%s\n' "$PLAIN" | python3 -c "
import sys, quopri
sys.stdout.buffer.write(quopri.encodestring(sys.stdin.buffer.read()))
"

# HTML-Part (beginnt mit Boundary und Leerzeile)
  printf '%s\n' \
    "" \
    "--$BOUNDARY_ALT" \
    "Content-Type: text/html; charset=utf-8" \
    "Content-Transfer-Encoding: quoted-printable" \
    ""
  printf '%s\n' "$HTML" | python3 -c "
import sys, quopri
sys.stdout.buffer.write(quopri.encodestring(sys.stdin.buffer.read()))
"

# Abschliessende Boundary
  printf '%s\n' \
    "" \
    "--$BOUNDARY_ALT--"

} | msmtp "$TO"

Dieses Beispiel zeigt anhand eini­ger Systemdaten, wie eine Multipart-Mail zusam­men­ge­baut wird. Im nächs­ten Abschnitt wird das glei­che Prinzip ange­wen­det, aller­dings mit einem exter­nen Wetter-Service.

Mail mit aktuellem Wetter

Open-Meteo ist ein kos­ten­lo­ser Wetterdienst mit einer ein­fa­chen API, also einer Schnittstelle, mit der ein Skript oder Programm Wetterdaten abfra­gen kann. Ein ein­zi­ger curl-Aufruf mit den Geo-Koordinaten und eini­gen wei­te­ren Parametern lie­fert das aktu­el­le Wetter als JSON zurück. Open-Meteo bie­tet sich als Beispiel an, da die Wetterdaten ohne Account oder Zugangsschlüssel abge­ru­fen wer­den kön­nen. Das Skript fragt die aktu­el­len Wetterdaten für den Ort an, des­sen Geo-Koordinaten im Skript ange­ge­ben sind. Achtung: die Koordinaten ver­wen­den einen Dezimalpunkt, kein Komma.

Da der Ortsname neben dem Datum im Mail-Subject ange­zeigt wer­den soll und, wie im Beispiel, Sonderzeichen ent­hal­ten kann, muss in die­sem Skript sicher­ge­stellt wer­den, dass die­se Zeichen kor­rekt kodiert sind.

Im Gegensatz zum Plaintext- und HTML-Part, bei denen Content-Type und Content-Transfer-Encoding dem emp­fan­gen­den Mailprogramm mit­tei­len, wie der fol­gen­de Text kodiert ist, und daher der gesam­te Text mit der Python-Funktion quopri.encodestring kodiert wer­den kann, muss das Subject mit einer eige­nen Funktion behan­delt wer­den.

Dazu wird die Funktion encode_subject defi­niert:

encode_subject() {
    python3 -c "
import sys
subject = sys.argv[1]
if all(ord(c) < 128 for c in subject):
    print(subject)
else:
    encoded = subject.encode('utf-8')
    qp = ''.join(
        '_' if b == 32 else
        '={:02X}'.format(b) if b > 127 or chr(b) in '?=_' else
        chr(b)
        for b in encoded
    )
    print('=?utf-8?Q?' + qp + '?=')
" "$1"
}

Wenn Nicht-ASCII-Zeichen vor­han­den sind, wird der Text in UTF-8-Bytes umge­wan­delt. So wird aus dem ö in Mönsheim in der UTF-8-Darstellung zwei Bytes: 0xC3 und 0xB6. Anschließend wird jedes die­ser Bytes ver­ar­bei­tet. Eine kom­pak­te Schleife ent­schei­det für jedes Byte, wie es im Ergebnisstring dar­ge­stellt wer­den soll. Dabei gel­ten drei Regeln:

  1. Ein Leerzeichen (Byte 32) wird durch einen Unterstrich _ ersetzt, da Leerzeichen in Header-Feldern als Trennzeichen gel­ten.
  2. Bytes über 127 sowie die Sonderzeichen ?= und _ wer­den als =XX dar­ge­stellt, wobei XX der Hexadezimalwert des Bytes ist. Aus 0xC3 wird =C3, aus 0xB6 wird =B6.
  3. Alle ande­ren Bytes, also nor­ma­le ASCII-Zeichen wie Buchstaben oder Zahlen, wer­den direkt über­nom­men.

Die so ent­stan­de­nen Textstücke wer­den mit join zu einem ein­zi­gen String zusam­men­ge­fügt. Das Ergebnis für Mönsheimwäre:

M=C3=B6nsheim

Zum Schluss wird die­ser kodier­te Text in den RFC-2047-Rahmen gesetzt:

=?utf-8?Q?M=C3=B6nsheim?=

Dieser Rahmen teilt dem Mailclient mit, dass der Text UTF-8-kodiert ist und Q‑Encoding ver­wen­det. Der Client wan­delt das beim Anzeigen auto­ma­tisch zurück in „Mönsheim”.

Nachdem das Subject ent­spre­chend defi­niert ist, wird der API-Aufruf zusam­men­ge­baut. Im Skript geschieht das, indem in jeder Zeile ein wei­te­rer Teil zur Variable API_URL hin­zu­ge­fügt wird. Am Ende ent­spricht der Aufruf die­sem Kommando:

curl "https://api.open-meteo.com/v1/forecast?latitude=48.85&longitude=8.93&current=temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code&wind_speed_unit=kmh&timezone=Europe/Berlin"

Das gibt als Ergebnis fol­gen­des JSON zurück:

{
    "latitude": 48.84,
    "longitude": 8.940001,
    "generationtime_ms": 0.10466575622558594,
    "utc_offset_seconds": 3600,
    "timezone": "Europe/Berlin",
    "timezone_abbreviation": "GMT+1",
    "elevation": 410.0,
    "current_units": {
        "time": "iso8601",
        "interval": "seconds",
        "temperature_2m": "°C",
        "relative_humidity_2m": "%",
        "wind_speed_10m": "km/h",
        "weather_code": "wmo code"
    },
    "current": {
        "time": "2026-03-20T01:15",
        "interval": 900,
        "temperature_2m": 5.3,
        "relative_humidity_2m": 57,
        "wind_speed_10m": 1.1,
        "weather_code": 0
    }
}

Als nächs­tes wird der Wert eines ein­zel­nen Feldes aus der JSON-Antwort in die ent­spre­chen­de Variable geschrie­ben, hier am Beispiel der Temperatur:

TEMP=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['current']['temperature_2m'])")

Damit wer­den die JSON-Daten aus der Bash-Variable $RESPONSE an einen Python-Prozess über­ge­ben. Python liest den Wert des Feldes temperature_2m unter dem Knoten current aus und gibt ihn als Text zurück, sodass er in der Bash-Variable TEMP wei­ter­ver­wen­det wer­den kann.

Im glei­chen Muster wer­den dann auch die Werte für Luftfeuchtigkeit, Wind und Wettercode abge­ru­fen. Der Wettercode wird anschlie­ßend noch in einen les­ba­ren Text über­setzt.

Anschließend wer­den die ein­zel­nen Teile, wie im vor­he­ri­gen Beispiel, zu einer kor­rek­ten E‑Mail zusam­men­ge­setzt und an msmtp über­ge­ben.

Hier das gesam­te Skript:

#!/bin/bash

TO="empfaenger@example.org"
FROM="sender@example.com"
BOUNDARY_ALT="==ALT_$(date +%s)=="

# Koordinaten und Ort anpassen
LAT="48.85"
LON="8.93"
ORT="Mönsheim"
TIMEZONE="Europe%2FBerlin"

# Das Subject wird per RFC 2047 kodiert, falls Nicht-ASCII-Zeichen
# enthalten sind (z.B. Umlaute im Ortsnamen). Ohne diese Kodierung
# würden Umlaute in der Mailübersicht des Clients korrumpiert dargestellt.
encode_subject() {
    python3 -c "
import sys
subject = sys.argv[1]
if all(ord(c) < 128 for c in subject):
    print(subject)
else:
    encoded = subject.encode('utf-8')
    qp = ''.join(
        '_' if b == 32 else
        '={:02X}'.format(b) if b > 127 or chr(b) in '?=_' else
        chr(b)
        for b in encoded
    )
    print('=?utf-8?Q?' + qp + '?=')
" "$1"
}

SUBJECT=$(encode_subject "Wetter $ORT $(date '+%d.%m.%Y')")

# Wetterdaten von Open-Meteo holen (kein API-Key nötig)
API_URL="https://api.open-meteo.com/v1/forecast"
API_URL="${API_URL}?latitude=${LAT}&longitude=${LON}"
API_URL="${API_URL}&current=temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code"
API_URL="${API_URL}&wind_speed_unit=kmh&timezone=${TIMEZONE}"

RESPONSE=$(curl -s "$API_URL")

if [ -z "$RESPONSE" ]; then
    echo "Fehler: Keine Antwort von der API." >&2
    exit 1
fi

# Werte aus JSON extrahieren
TEMP=$(echo "$RESPONSE"     | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['current']['temperature_2m'])")
HUMIDITY=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['current']['relative_humidity_2m'])")
WIND=$(echo "$RESPONSE"     | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['current']['wind_speed_10m'])")
WMO=$(echo "$RESPONSE"      | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['current']['weather_code'])")

# WMO-Wettercode in lesbaren Text übersetzen
WETTER=$(python3 -c "
code = int('$WMO')
codes = {
    0:  'Klarer Himmel',
    1:  'Überwiegend klar', 2: 'Teilweise bewölkt', 3: 'Bedeckt',
    45: 'Nebel', 48: 'Raureif',
    51: 'Leichter Nieselregen', 53: 'Nieselregen', 55: 'Starker Nieselregen',
    61: 'Leichter Regen', 63: 'Regen', 65: 'Starker Regen',
    71: 'Leichter Schneefall', 73: 'Schneefall', 75: 'Starker Schneefall',
    80: 'Leichte Schauer', 81: 'Schauer', 82: 'Starke Schauer',
    95: 'Gewitter', 96: 'Gewitter mit Hagel', 99: 'Gewitter mit starkem Hagel',
}
print(codes.get(code, 'Unbekannt (Code: ' + str(code) + ')'))
")

DATUM=$(LC_ALL=de_DE.UTF-8 date '+%A, %d. %B %Y %H:%M Uhr' 2>/dev/null || date)

# Plaintext-Part
PLAIN="Wetterbericht: $ORT
$DATUM

Wetter      : $WETTER
Temperatur  : ${TEMP}°C
Luftfeuchte : ${HUMIDITY}%
Wind        : ${WIND} km/h"

# HTML-Part
HTML="<!DOCTYPE html>
<html>
<head><meta charset=\"utf-8\"></head>
<body style=\"font-family: sans-serif; font-size: 14px; max-width: 500px;\">
<h2>Wetterbericht: $ORT</h2>
<p style=\"color: #666;\">$DATUM</p>
<table style=\"border-collapse: collapse; width: 100%;\">
  <tr style=\"background: #f0f0f0;\">
    <td style=\"padding: 8px; border: 1px solid #ccc;\"><strong>Wetter</strong></td>
    <td style=\"padding: 8px; border: 1px solid #ccc;\">$WETTER</td>
  </tr>
  <tr>
    <td style=\"padding: 8px; border: 1px solid #ccc;\"><strong>Temperatur</strong></td>
    <td style=\"padding: 8px; border: 1px solid #ccc;\">${TEMP}°C</td>
  </tr>
  <tr style=\"background: #f0f0f0;\">
    <td style=\"padding: 8px; border: 1px solid #ccc;\"><strong>Luftfeuchtigkeit</strong></td>
    <td style=\"padding: 8px; border: 1px solid #ccc;\">${HUMIDITY}%</td>
  </tr>
  <tr>
    <td style=\"padding: 8px; border: 1px solid #ccc;\"><strong>Wind</strong></td>
    <td style=\"padding: 8px; border: 1px solid #ccc;\">${WIND} km/h</td>
  </tr>
</table>
<p style=\"font-size: 11px; color: #999; margin-top: 16px;\">
  Wetterdaten: <a href=\"https://open-meteo.com\">Open-Meteo</a>
</p>
</body>
</html>"

# Mail zusammenbauen und verschicken
{
  printf '%s\n' \
    "From: $FROM" \
    "To: $TO" \
    "Subject: $SUBJECT" \
    "Date: $(LC_ALL=C date -R)" \
    "MIME-Version: 1.0" \
    "Content-Type: multipart/alternative; boundary=\"$BOUNDARY_ALT\"" \
    ""

# Plaintext-Part
  printf '%s\n' \
    "--$BOUNDARY_ALT" \
    "Content-Type: text/plain; charset=utf-8" \
    "Content-Transfer-Encoding: quoted-printable" \
    ""
  printf '%s\n' "$PLAIN" | python3 -c "
import sys, quopri
sys.stdout.buffer.write(quopri.encodestring(sys.stdin.buffer.read()))
"

# HTML-Part
  printf '%s\n' \
    "" \
    "--$BOUNDARY_ALT" \
    "Content-Type: text/html; charset=utf-8" \
    "Content-Transfer-Encoding: quoted-printable" \
    ""
  printf '%s\n' "$HTML" | python3 -c "
import sys, quopri
sys.stdout.buffer.write(quopri.encodestring(sys.stdin.buffer.read()))
"

# Abschliessende Boundary
  printf '%s\n' \
    "" \
    "--$BOUNDARY_ALT--"

} | msmtp "$TO"

Die bei­den Beispiele zei­gen das Grundprinzip: Eine Mail ist letzt­lich nichts ande­res als struk­tu­rier­ter Text, den msmtpent­ge­gen­nimmt und wei­ter­lei­tet. Wer das ein­mal ver­stan­den hat, kann die Skripte nicht nur nach­bau­en, son­dern auch leicht anpas­sen. Beim Wetter-Skript bie­tet Open-Meteo allein schon eine Vielzahl wei­te­rer Möglichkeiten: Vorhersagen für die nächs­ten Tage, Niederschlagsmengen, UV-Index oder Sonnenauf- und unter­gang las­sen sich mit einem zusätz­li­chen Parameter im API-Aufruf abfra­gen. Die voll­stän­di­ge Dokumentation dazu fin­det sich direkt auf open-meteo.com.”

Automatisches Versenden mit einem Zeitplaner

Natürlich sind sol­che Skripte erst dann nütz­lich, wenn sie auto­ma­tisch und regel­mä­ßig lau­fen. Auf Linux über­nimmt das tra­di­tio­nell Cron, auf macOS stellt Apple mit launchd einen eige­nen Mechanismus bereit. Dazu soll­te der Computer dann natür­lich 24/7 lau­fen und online sein. 

Cron unter Linux

Cron ist ein Dienst, der im Hintergrund läuft und Aufgaben zu fest­ge­leg­ten Zeitpunkten aus­führt. Die Zeitpläne wer­den in der soge­nann­ten Crontab ein­ge­tra­gen. Mit fol­gen­dem Terminal-Befehl öff­nest du die Crontab des aktu­el­len Benutzers in einem Text-Editor, meist ist der Editor nanovor­ein­ge­stellt:

crontab -e

Die Syntax eines Eintrags besteht aus fünf Zeitfeldern, gefolgt vom aus­zu­füh­ren­den Befehl:

Minute  Stunde  Tag  Monat  Wochentag  Befehl

Ein Stern * in einem Feld steht für „jeden mög­li­chen Wert”. Hier zwei Beispiele:

# System-Report jeden Morgen um 7 Uhr
0 7 * * * /home/leif/scripts/system-report.sh

# Wetterbericht täglich um 9 Uhr
0 9 * * * /home/leif/scripts/wetter-report.sh

Eine prak­ti­sche Hilfe zum Zusammenstellen von Cron-Ausdrücken bie­tet crontab.guru.

Ein wich­ti­ger Hinweis: Cron läuft in einer mini­ma­len Umgebung ohne die übli­chen Shell-Variablen. Insbesondere der PATH ist stark ein­ge­schränkt, sodass Befehle wie curl oder python3 unter Umständen nicht gefun­den wer­den. Eine siche­re Lösung ist, im Skript abso­lu­te Pfade zu ver­wen­den. Welchen Pfad ein Befehl hat, lässt sich mit which her­aus­fin­den:

which curl
which python3
which msmtp

Alternativ kann am Anfang des Skripts die PATH-Variable gesetzt wer­den. Hier ein Beispiel für den macOS:

export PATH="/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:$PATH"

launchd unter macOS

Unter macOS ist launchd der Systemdienst, der sowohl Hintergrundprozesse als auch geplan­te Aufgaben ver­wal­tet. Zeitpläne wer­den als XML-Dateien im soge­nann­ten Property-List-Format (.plist) defi­niert und an einem bestimm­ten Ort abge­legt, damit launchd sie ein­liest.

Bevor es los­geht, noch ein wich­ti­ger Hinweis zum Speicherort der Skripte. macOS schützt bestimm­te Ordner wie den Desktop, Dokumente und Downloads vor dem Zugriff durch auto­ma­ti­sier­te Prozesse. Skripte die von launchd aus­ge­führt wer­den sol­len, gehö­ren daher in einen unge­schütz­ten Ordner, zum Beispiel ~/scripts/. Den kannst du ein­fach anle­gen, falls er noch nicht exis­tiert:

mkdir ~/scripts

Für Aufgaben des aktu­el­len Benutzers ist der Ordner ~/Library/LaunchAgents/ der rich­ti­ge Ablageort für die plist-Dateien. Lege dort eine neue Datei an, zum Beispiel de.ileif.wetter-report.plist:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>de.ileif.wetter-report</string>

    <key>ProgramArguments</key>
    <array>
        <string>/bin/bash</string>
        <string>/Users/leif/scripts/wetter-report.sh</string>
    </array>

    <key>StartCalendarInterval</key>
    <dict>
        <key>Hour</key>
        <integer>9</integer>
        <key>Minute</key>
        <integer>0</integer>
    </dict>

    <key>RunAtLoad</key>
    <false/>

    <key>StandardErrorPath</key>
    <string>/tmp/wetter-report.err</string>
</dict>
</plist>

Der Label-Eintrag ist der ein­deu­ti­ge Name des Jobs und soll­te der Dateibezeichnung ent­spre­chen. ProgramArguments gibt an, wel­ches Skript aus­ge­führt wer­den soll. StartCalendarInterval defi­niert den Zeitplan, hier täg­lich um 9:00 Uhr. StandardErrorPath lei­tet even­tu­el­le Fehlermeldungen in eine Datei um, was die Fehlersuche erleich­tert.

Damit launchd die neue Datei ein­liest, muss sie ein­ma­lig regis­triert wer­den:

launchctl load ~/Library/LaunchAgents/de.ileif.wetter-report.plist

Ab sofort läuft das Skript täg­lich zur ange­ge­be­nen Zeit. Mit unload lässt sich der Job wie­der deak­ti­vie­ren:

launchctl unload ~/Library/LaunchAgents/de.ileif.wetter-report.plist

Mit launchd wird ein ver­pass­ter Zeitpunkt nach­ge­holt. Wenn der Mac zur geplan­ten Zeit aus­ge­schal­tet war, führt launchdden Job beim nächs­ten Start aus, sofern man RunAtLoad auf true setzt. Cron hin­ge­gen über­springt ver­pass­te Ausführungen still­schwei­gend.

Fazit

Für das Versenden von E‑Mails aus der Kommandozeile oder aus einem Skript ist msmtp ein schlan­kes und zuver­läs­si­ges Werkzeug, das sich ohne gro­ßen Konfigurationsaufwand ein­bin­den lässt. Einmal ver­stan­den, wie eine E‑Mail intern auf­ge­baut ist, ist das Versenden von der Kommandozeile gar nicht so kom­pli­ziert. Die MIME-Struktur, die Zeichenkodierung und der Aufbau des Headers fol­gen kla­ren Regeln, und die gezeig­ten Skripte las­sen sich als Ausgangspunkt für eige­ne Ideen ver­wen­den.

Die Beispiele in die­sem Artikel sind bewusst ein­fach gehal­ten. Ein System-Report, ein Wetterbericht, aber das Prinzip lässt sich auf vie­les über­tra­gen: Monitoring-Alerts, täg­li­che Zusammenfassungen aus eige­nen Datenquellen oder Benachrichtigungen aus Automatisierungsskripten. Wer die Skripte ein­mal zum Laufen gebracht hat, wird schnell eige­ne Anwendungsfälle fin­den. Allein Open-Meteo bie­tet neben den aktu­el­len Wetterdaten noch Vorhersagen, Niederschlagsmengen, UV-Index und vie­les mehr, alles mit dem­sel­ben ein­fa­chen API-Aufruf. Die voll­stän­di­ge Dokumentation dazu fin­det sich auf open-meteo.com.

Fediverse-Reaktionen

5 Kommentare zu „E‑Mails aus Skripten versenden mit msmtp“

  1. @blog oder aber auch mit #curl

    https://everything.curl.dev/usingcurl/smtp.html

    Ich bevor­zu­ge curl in Bashskripten

    1. Danke für den Hinweis. Über die­sen Weg, E‑Mail zu ver­schi­cken, bin ich bis­her noch nicht gestol­pert. Da war die gan­ze Mühe mit dem Artikel ja fast umsonst ;-).

      1. @blog ne, schö­ne HTML E‑Mails for­ma­tie­ren mit Alttext und so ist auch bei curl viel Arbeit

        Da du teil­wei­se curl fürs zie­hen der Daten nutzt, ist es aber sicher­lich ne span­nen­de Ergänzung oder logi­sche Wahl

        1. Danke für den Trost. Aber curl ver­dient mehr Beachtung mein­ser­seits.

  2. @blog Und BSD.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert