A few scripts run on my Raspberry Pi that monitor my systems and send an email when certain events occur. In an earlier post, on my german website, I described how I set up Postfix as a local mail server for that purpose. Here I want to show a simpler approach for cases where all you need is to send emails from the command line on a Linux or Mac system.
How Email Actually Works
Sending and receiving email requires a mail server that is reachable from the internet. In most home setups, your own machine does not act as a mail server directly. Instead, you use an email client like Apple Mail or Thunderbird to fetch mail from an extern server or send mail through it.
The communication between the client and the server uses two protocols: SMTP for sending, and IMAP for reading and managing mail on the server. With IMAP, the server’s contents are essentially mirrored to your local machine, similar to how Dropbox or iCloud sync files. When you delete a message in Thunderbird, it gets deleted on the server too. That way the same account can be connected to multiple computers, all seeing the same folder structure and messages. But that part of the email experience is not what this article is about.
For sending email, SMTP is what matters. When you compose a message in your email client and hit Send, the client assembles the message according to certain rules and transmits it via SMTP to your mail server. That server then forwards it to the recipient’s server, which stores it in the recipient’s inbox.
Header, Body, and MIME: How an Email Is Structured
An email consists of two parts: a header and a body, separated by a blank line.
The header contains the required fields From (sender) and To (recipient). The Date field is also required and should be set by the sending client. If it is missing, the mail server adds it as a fallback. Optional fields include the subject (Subject), copy and blind copy recipients (CC and BCC), and Reply-To if replies should go to a different address. The Message-ID field should also be set, because mail clients use it to group messages into conversations.
For emails with attachments or mixed content, the header must also include a Content-Type field with a boundary, so that the receiving client knows how to parse the body. In that case, MIME-Version: 1.0 must appear in the header as well.
The MIME Structure of HTML Emails
A plain text email has a simple body. As soon as HTML, images, or attachments enter the picture, the email is split into multiple parts, which can themselves contain parts. This looks more complicated than it is.
Here is an example of an email with an HTML part, a plain text fallback, and an embedded image used as a signature:
From: sender@example.de
To: recipient@example.de
Subject: =?utf-8?Q?Nice_subject_with_Uml=C3=A4uten?=
Date: Tue, 17 Mar 2026 10:00:00 +0100
Message-ID: <unique-id@example.de>
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="==BOUNDARY_1=="
--==BOUNDARY_1==
Content-Type: multipart/related; boundary="==BOUNDARY_2=="
--==BOUNDARY_2==
Content-Type: multipart/alternative; boundary="==BOUNDARY_3=="
--==BOUNDARY_3==
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: quoted-printable
Hello! Kind regards!
--==BOUNDARY_3==
Content-Type: text/html; charset="utf-8"
Content-Transfer-Encoding: quoted-printable
<html>
<body>
<p>Hello! Kind regards!</p>
<img src="cid:signature.png">
</body>
</html>
--==BOUNDARY_3==--
--==BOUNDARY_2==
Content-Type: image/png
Content-Transfer-Encoding: base64
Content-ID: <signature.png>
Content-Disposition: inline
iVBORw0KGgoAAAANSUhEUgAA...
--==BOUNDARY_2==--
--==BOUNDARY_1==--
The nested structure looks a bit intimidating at first glance, but it follows a clear logic. The outer container multipart/mixed is the envelope for the entire email and would be the right place for actual file attachments. Inside it sits multipart/related, which keeps the HTML part and the embedded image together. The image is referenced in the HTML via cid:signature.png and is therefore not a standalone attachment. Within related, multipart/alternative gives the mail client a choice: it renders either the HTML part or, if it does not support HTML, the plain text fallback.
Character Encoding: Why quoted-printable?
The SMTP protocol was defined at a time when 7‑bit ASCII was the reliable standard for data transmission. That set of 127 characters does not include umlauts or other special characters. The letter ö, for example, is encoded in UTF‑8 as two bytes, 0xC3 and 0xB6, both values above 127. Older relay servers could corrupt or drop such bytes.
quoted-printable solves this by converting all non-ASCII characters into a safe ASCII representation. The ö becomes =C3=B6, and ü becomes =C3=BC. The receiving server transports these encoded bytes without modification, and only the mail client decodes them and interprets them according to the charset=utf-8 declaration in the Content-Type header.
In the header itself, things work a bit differently. There is no charset field there, so the RFC 2047 Encoded Word format is used instead:
Subject: =?utf-8?Q?Nice_subject_with_Uml=C3=A4uten?=
The format is always:
=?charset?encoding?encoded text?=
The Q stands for Quoted-Printable and B stands for Base64. Mail clients decode this automatically and display the readable subject line.
Modern mail servers often support the SMTP extension SMTPUTF8 (RFC 6531), which allows raw UTF‑8 bytes in transport, but you can never be sure which servers an email passes through on its way. Encoding properly is the safe choice.
Installing and Configuring msmtp
msmtp is a lightweight SMTP client. It sends emails through your provider’s SMTP server without being a mail server itself. It accepts a finished message on standard input and forwards it. That means the message must already be correctly assembled, including the header, the blank line separator, and the content. msmtp does not build HTML mail, add attachments, or create any structure. It just sends whatever you give it. And since no mail client is there to handle encoding, that part has to be done beforehand, as described in the previous section.
Here is a minimal example to send a plain text message from the command line:
echo -e "Subject: Test\n\nHello World" | msmtp test@example.com
The -e flag for echo is important here, because there must be a blank line between the subject header and the message body. Without -e, the \n would be treated as literal text rather than a newline character.
Installation
On a Linux system like Ubuntu, there are two relevant packages:
msmtpis the actual SMTP client.msmtp-mtais a compatibility wrapper that registersmsmtpas a drop-in replacement for classic MTAs like sendmail or Postfix.
For simple command-line sending, the first package is all you need. The second one only makes sense if you want to use other command-line tools like mail for sending and do not already have an MTA like sendmail or Postfix configured. If you have a desktop mail client installed, it talks directly to your mail server, so this installation has no effect on your regular email.
Install with:
sudo apt update sudo apt install msmtp
If the installer suggests adding msmtp-mta, you can decline, which leaves any existing configuration untouched.
After the download you may be asked whether to add msmtp to your AppArmor profile. AppArmor is a security mechanism in Ubuntu. In my test installation I confirmed this option and have not had any problems with it.
On macOS, msmtp can be installed with Homebrew or another package manager:
brew install msmtp
Configuration
Next, a configuration file needs to be created. You can choose between a user-specific file at ~/.msmtprc or a global one at /etc/msmtprc. With a global configuration, all users on the system, including root, cron jobs, and system services, can use msmtp with the credentials stored there without needing their own configuration file.
In my setup, the global variant makes sense because I am the only interactive user, and this way even scripts running as root can send mail without any extra setup. If multiple users each need their own mail account, the user-specific ~/.msmtprc is the better choice. On macOS it is generally recommended, because the Homebrew-installed msmtp expects the system-wide configuration file at a version-specific path that changes with every msmtp update. The user-specific ~/.msmtprc works the same on macOS and Linux and is not affected by updates.
defaults auth on tls on tls_starttls on tls_trust_file /etc/ssl/certs/ca-certificates.crt # On macOS, certificates are loaded from the Keychain, # so the tls_trust_file line should be removed or commented out. # On Ubuntu, syslog is usually more practical than a log file syslog LOG_MAIL # On macOS you can write to a log file directly # logfile /Users/myuser/Library/Logs/msmtp.log # Global log file, may cause permission issues # logfile /var/log/msmtp.log # User log file, simple solution if msmtp is always called by the same user # logfile ~/.msmtp.log account default host smtp.yourprovider.com port 587 from youruser@domain.com user youruser@domain.com password yourpassword
You can define multiple account entries in the configuration to send mail from different sender addresses, for example. One of them must be named default, which is used when msmtp is called without explicitly specifying an account. More on that below.
Once you have created and saved the file, you are ready to send mail with msmtp.
First Tests on the Command Line
Send your first test email:
echo -e "Subject: Test\n\nHello World" | msmtp test@example.com --debug
Strictly speaking, this does not produce a fully standards-compliant email, since all header fields except Subject are missing. msmtp creates an SMTP envelope from the email address in the argument and the from entry in the config file, wraps the output of echo -e in it, and sends it. Most modern mail servers that receive such a message will add the missing fields like From, To, Date, and Message-ID, so a complete email shows up in the inbox, but you should not rely on that.
One detail that might not be obvious: the -e flag ensures that \n is treated as a real newline, not as the two characters backslash and n. It is also important that the header is separated from the body by a blank line. That is why there are two \n in a row: the first ends the last header line, and the second creates the required blank line.
For that reason, the following approach is a better choice. All important header fields are fully assembled before the finished email is passed to msmtp:
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" \ "" \ "Hello," \ "this is a test." \ | msmtp test@example.com
If you are wondering about the \ at the end of each line: it prevents the shell from treating each line as a separate command. The entire call would normally have to be on one line, which quickly becomes unreadable. At the same time, each header field needs to be separated by a newline in the transmitted message. That is handled by the format string '%s\n' in the printf command, which appends a newline after each quoted string.
Script for Sending System Info
Now that we know sending mail from the command line works, here is a script that assembles some current system parameters and sends them as an email.
The mail uses multipart/alternative as its Content-Type, which means the outer container holds two or more parts. In this case, an HTML part that presents the data as a table, and a plain text fallback for mail clients without HTML support.
The script works on both macOS and Linux.
It is organized in three sections. The first section fills variables with the data needed to build the email. This includes the header fields, where the subject is assembled from the text “System Report” and the current date. The boundary variable is constructed from the prefix ALT_ and the number of seconds since January 1, 1970, which is where Unix time counting begins. This ensures the boundary name is unique and avoids collisions when, for example, one email is embedded inside another.
The second section collects system data into variables. HOSTNAME=$(hostname) runs the hostname command and stores its output in the HOSTNAME variable. The $(...) syntax is the pattern here: whatever sits between the parentheses is executed as a command, and the result is used directly. The same pattern appears throughout the script, for example with $(date) or $(uname). It can also capture the output of more complex calls:
DISK=$(df -h / | awk 'NR==2 {print $3 " of " $2 " used (" $5 ")"}')
Here df -h / shows the disk usage of the root directory. The -h flag makes the output human-readable, so sizes are shown in GB or MB rather than raw bytes. The output looks something like this:
Filesystem Size Used Avail Use% Mounted on /dev/sda1 30G 8.2G 22G 28% /
The | pipes that output to awk, a powerful tool for recognizing and processing text patterns. In this case, NR==2 limits processing to the second line, which is the one with the actual data. $3, $2, and $5 address the values in the respective columns, so:
{print $3 " of " $2 " used (" $5 ")"}
produces the text 8.2G of 30G used (28%) and stores it in the DISK variable.
The other system parameters follow the same pattern. The script distinguishes between Linux and macOS, since RAM and CPU information are queried differently on the two systems.
The third section defines the email content. The plain text part is written to the variable PLAIN and the HTML part to HTML. Then the email is assembled, with a separate printf command for each part: header, plain text, and HTML.
To correctly encode the plain text and HTML parts, the script uses a Python one-liner that works on both Linux and macOS:
printf '%s\n' "$PLAIN" | python3 -c " import sys, quopri sys.stdout.buffer.write(quopri.encodestring(sys.stdin.buffer.read())) "
The printf commands assemble the email and pipe the result to msmtp, which delivers it to $TO.
#!/bin/bash
TO="recipient@example.com"
FROM="sender@example.com"
SUBJECT="System Report $(date '+%m/%d/%Y')"
BOUNDARY_ALT="==ALT_$(date +%s)=="
# System data
HOSTNAME=$(hostname)
OS=$(uname)
# Disk usage (works on both systems)
DISK=$(df -h / | awk 'NR==2 {print $3 " of " $2 " used (" $5 ")"}')
# Uptime (works on both systems)
UPTIME=$(uptime | sed 's/.*up //' | sed 's/,.*//')
# RAM and CPU differ by operating system
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} used of ${RAM_TOTAL}"
CPU_INFO=$(sysctl -n machdep.cpu.brand_string)
else
RAM=$(free -m | awk 'NR==2 {printf "%d MB of %d MB used", $3, $2}')
CPU_INFO=$(grep "model name" /proc/cpuinfo | head -1 | cut -d: -f2 | xargs)
fi
# Plain text part
PLAIN="System Report: $HOSTNAME
$(date)
Operating System : $OS
CPU : $CPU_INFO
Disk : $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>Operating System</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>Disk</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>"
# Assemble and send the email
# Header, followed by a blank line
{
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\"" \
""
# Plain text part (starts with boundary and blank line)
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 (starts with boundary and blank line)
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()))
"
# Closing boundary
printf '%s\n' \
"" \
"--$BOUNDARY_ALT--"
} | msmtp "$TO"
This example shows the basic principle of assembling a multipart email using system data. The next section applies the same approach with a live weather service.
Email with Current Weather
Open-Meteo is a free weather service with a simple API, meaning a straightforward interface that a script or program can use to fetch weather data. A single curl call with geographic coordinates and a few parameters returns current conditions as JSON. Open-Meteo is a good example here because weather data can be retrieved without an account or API key. The script requests weather for the location whose coordinates you specify.
Since the location name is shown next to the date in the subject line and may contain special characters, the script has to ensure those characters are correctly encoded.
Unlike the plain text and HTML parts, where Content-Type and Content-Transfer-Encoding tell the receiving mail client how the following text is encoded, the subject line requires its own handling.
For that purpose, the function encode_subject is defined:
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"
}
When non-ASCII characters are present, the text is converted to UTF‑8 bytes. The ö in a city name like Mönsheim becomes two bytes in UTF‑8: 0xC3 and 0xB6. Each of those bytes is then processed by a compact loop that decides how it should appear in the result string. Three rules apply: a space (byte 32) is replaced by an underscore, since spaces in header fields are treated as separators. Bytes above 127 and the special characters ?, =, and _ are represented as =XX, where XX is the hexadecimal value of the byte. All other bytes, meaning regular ASCII characters like letters or digits, are passed through unchanged.
The resulting pieces are joined into a single string with join. For Mönsheim the result would be:
M=C3=B6nsheim
Finally, this encoded text is wrapped in the RFC 2047 envelope:
=?utf-8?Q?M=C3=B6nsheim?=
This tells the mail client that the text is UTF‑8 encoded and uses Q‑Encoding. The client automatically decodes it and displays “Mönsheim”.
With the subject handled, the API call is assembled. The script does this by appending one segment at a time to the API_URL variable. The final result is equivalent to this command:
curl "https://api.open-meteo.com/v1/forecast?latitude=48.85&longitude=8.93¤t=temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code&wind_speed_unit=kmh&timezone=Europe/Berlin"
The response looks like this:
{
"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
}
}
To extract a single field from the JSON response into a variable, here is the pattern using temperature as an example:
TEMP=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['current']['temperature_2m'])")
The JSON data from the bash variable $RESPONSE is passed to a Python process. Python reads the value of temperature_2m under the current node and prints it, so the bash variable TEMP gets a plain number it can work with.
The same pattern is used to retrieve humidity, wind speed, and the weather code. The weather code is then translated into a human-readable description.
After that, the individual parts are assembled into a properly structured email and handed to msmtp, just as in the previous example.
Here is the complete script:
#!/bin/bash
TO="recipient@example.org"
FROM="sender@example.com"
BOUNDARY_ALT="==ALT_$(date +%s)=="
# Adjust coordinates and location name
LAT="48.85"
LON="8.93"
LOCATION="Moensheim"
TIMEZONE="Europe%2FBerlin"
# The subject is encoded per RFC 2047 when it contains non-ASCII characters
# (e.g. umlauts in the location name). Without this encoding, those characters
# would be corrupted in the mail client's message list.
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 "Weather $LOCATION $(date '+%m/%d/%Y')")
# Fetch weather data from Open-Meteo (no API key required)
API_URL="https://api.open-meteo.com/v1/forecast"
API_URL="${API_URL}?latitude=${LAT}&longitude=${LON}"
API_URL="${API_URL}¤t=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 "Error: No response from the API." >&2
exit 1
fi
# Extract values from JSON
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'])")
# Translate WMO weather code into readable text
WEATHER=$(python3 -c "
code = int('$WMO')
codes = {
0: 'Clear sky',
1: 'Mainly clear', 2: 'Partly cloudy', 3: 'Overcast',
45: 'Fog', 48: 'Rime fog',
51: 'Light drizzle', 53: 'Drizzle', 55: 'Heavy drizzle',
61: 'Light rain', 63: 'Rain', 65: 'Heavy rain',
71: 'Light snowfall', 73: 'Snowfall', 75: 'Heavy snowfall',
80: 'Light showers', 81: 'Showers', 82: 'Heavy showers',
95: 'Thunderstorm', 96: 'Thunderstorm with hail', 99: 'Thunderstorm with heavy hail',
}
print(codes.get(code, 'Unknown (Code: ' + str(code) + ')'))
")
DATE=$(date '+%A, %B %d, %Y %H:%M')
# Plain text part
PLAIN="Weather Report: $LOCATION
$DATE
Conditions : $WEATHER
Temperature : ${TEMP}°C
Humidity : ${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>Weather Report: $LOCATION</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>Conditions</strong></td>
<td style=\"padding: 8px; border: 1px solid #ccc;\">$WEATHER</td>
</tr>
<tr>
<td style=\"padding: 8px; border: 1px solid #ccc;\"><strong>Temperature</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>Humidity</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;\">
Weather data: <a href=\"https://open-meteo.com\">Open-Meteo</a>
</p>
</body>
</html>"
# Assemble and send the email
{
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\"" \
""
# Plain text 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()))
"
# Closing boundary
printf '%s\n' \
"" \
"--$BOUNDARY_ALT--"
} | msmtp "$TO"
Both examples demonstrate the core principle: an email is ultimately nothing but structured text that msmtp accepts and forwards. Once you understand that, you can not only replicate these scripts but also adapt them to your own needs. Open-Meteo alone offers a wide range of additional data: forecasts for the coming days, precipitation, UV index, or sunrise and sunset times can all be added with an extra parameter in the API call. The full documentation is available at open-meteo.com.
Scheduling with a Task Runner
Scripts like these are only really useful when they run automatically on a schedule. On Linux, cron has traditionally handled this. On macOS, Apple provides its own mechanism called launchd. In either case, the machine should of course be running 24/7 and connected to the internet.
Cron on Linux
Cron is a background service that executes tasks at defined times. Schedules are stored in what is called the crontab. Open the crontab for the current user with:
crontab -e
This typically opens in nano. Each entry consists of five time fields followed by the command to run:
Minute Hour Day Month Weekday Command
An asterisk * in any field means “every possible value.” Two examples:
# System report every morning at 7 AM 0 7 * * * /home/leif/scripts/system-report.sh # Weather report daily at 9 AM 0 9 * * * /home/leif/scripts/weather-report.sh
A handy tool for building cron expressions is crontab.guru.
One important note: cron runs in a minimal environment without the usual shell variables. In particular, the PATH is heavily restricted, so commands like curl or python3 might not be found. The safest approach is to use absolute paths in your script. You can find the path of any command with which:
which curl which python3 which msmtp
Alternatively, set the PATH variable at the top of the script. Here is an example for macOS:
export PATH="/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:$PATH"
launchd on macOS
On macOS, launchd is the system service that manages both background processes and scheduled tasks. Schedules are defined as XML files in the Property List format (.plist) and placed in a specific location so that launchd picks them up.
One important note before getting started: macOS protects certain folders like the Desktop, Documents, and Downloads from access by automated processes. Scripts that should be executed by launchd belong in an unprotected folder, for example ~/scripts/. You can create it if it does not already exist:
mkdir ~/scripts
For tasks belonging to the current user, the right place for plist files is ~/Library/LaunchAgents/. Create a new file there, for example com.ileif.weather-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>com.ileif.weather-report</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/leif/scripts/weather-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/weather-report.err</string>
</dict>
</plist>
The Label entry is the unique name of the job and should match the filename. ProgramArguments specifies which script to run. StartCalendarInterval defines the schedule, here daily at 9:00 AM. StandardErrorPath redirects any error output to a file, which makes debugging easier.
To register the new file with launchd, load it once:
launchctl load ~/Library/LaunchAgents/com.ileif.weather-report.plist
From that point on, the script runs automatically at the specified time. To deactivate the job:
launchctl unload ~/Library/LaunchAgents/com.ileif.weather-report.plist
One nice behavior of launchd: missed schedules can be caught up on. If the Mac was off when a job was supposed to run, launchd executes it the next time the system starts, provided you set RunAtLoad to true. Cron, by contrast, silently skips any missed runs.
Wrapping Up
For sending email from the command line or from a script, msmtp is a lean and reliable tool that integrates with minimal configuration effort. Once you understand how an email is structured internally, sending from the command line is not all that complicated. The MIME structure, the character encoding, and the header format all follow clear rules, and the scripts shown here can serve as a starting point for your own ideas.
The examples in this article are intentionally straightforward. A system report, a weather brief, but the principle transfers to a lot of different use cases: monitoring alerts, daily summaries from your own data sources, or notifications from automation scripts. Once you get the scripts running, you will quickly find your own applications. Open-Meteo alone offers forecasts for the coming days, precipitation data, UV index, and much more, all with the same simple API call. The full documentation is at open-meteo.com.


Leave a Reply