Sending Emails from Scripts with msmtp

A computer in front of a colorful background. Only meant as visual candy.

A few scripts run on my Rasp­ber­ry Pi that mon­i­tor my sys­tems and send an email when cer­tain events occur. In an ear­li­er post, on my ger­man web­site, I described how I set up Post­fix as a local mail serv­er for that pur­pose. Here I want to show a sim­pler approach for cas­es where all you need is to send emails from the com­mand line on a Lin­ux or Mac sys­tem.

How Email Actually Works

Send­ing and receiv­ing email requires a mail serv­er that is reach­able from the inter­net. In most home setups, your own machine does not act as a mail serv­er direct­ly. Instead, you use an email client like Apple Mail or Thun­der­bird to fetch mail from an extern serv­er or send mail through it.

The com­mu­ni­ca­tion between the client and the serv­er uses two pro­to­cols: SMTP for send­ing, and IMAP for read­ing and man­ag­ing mail on the serv­er. With IMAP, the server’s con­tents are essen­tial­ly mir­rored to your local machine, sim­i­lar to how Drop­box or iCloud sync files. When you delete a mes­sage in Thun­der­bird, it gets delet­ed on the serv­er too. That way the same account can be con­nect­ed to mul­ti­ple com­put­ers, all see­ing the same fold­er struc­ture and mes­sages. But that part of the email expe­ri­ence is not what this arti­cle is about.

For send­ing email, SMTP is what mat­ters. When you com­pose a mes­sage in your email client and hit Send, the client assem­bles the mes­sage accord­ing to cer­tain rules and trans­mits it via SMTP to your mail serv­er. That serv­er then for­wards it to the recip­i­en­t’s serv­er, which stores it in the recip­i­en­t’s inbox.

Header, Body, and MIME: How an Email Is Structured

An email con­sists of two parts: a head­er and a body, sep­a­rat­ed by a blank line.

The head­er con­tains the required fields From (sender) and To (recip­i­ent). The Date field is also required and should be set by the send­ing client. If it is miss­ing, the mail serv­er adds it as a fall­back. Option­al fields include the sub­ject (Subject), copy and blind copy recip­i­ents (CC and BCC), and Reply-To if replies should go to a dif­fer­ent address. The Message-ID field should also be set, because mail clients use it to group mes­sages into con­ver­sa­tions.

For emails with attach­ments or mixed con­tent, the head­er must also include a Content-Type field with a boundary, so that the receiv­ing client knows how to parse the body. In that case, MIME-Version: 1.0 must appear in the head­er as well.

The MIME Structure of HTML Emails

A plain text email has a sim­ple body. As soon as HTML, images, or attach­ments enter the pic­ture, the email is split into mul­ti­ple parts, which can them­selves con­tain parts. This looks more com­pli­cat­ed than it is.

Here is an exam­ple of an email with an HTML part, a plain text fall­back, and an embed­ded image used as a sig­na­ture:

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 nest­ed struc­ture looks a bit intim­i­dat­ing at first glance, but it fol­lows a clear log­ic. The out­er con­tain­er multipart/mixed is the enve­lope for the entire email and would be the right place for actu­al file attach­ments. Inside it sits multipart/related, which keeps the HTML part and the embed­ded image togeth­er. The image is ref­er­enced in the HTML via cid:signature.png and is there­fore not a stand­alone attach­ment. With­in related, multipart/alternative gives the mail client a choice: it ren­ders either the HTML part or, if it does not sup­port HTML, the plain text fall­back.

Character Encoding: Why quoted-printable?

The SMTP pro­to­col was defined at a time when 7‑bit ASCII was the reli­able stan­dard for data trans­mis­sion. That set of 127 char­ac­ters does not include umlauts or oth­er spe­cial char­ac­ters. The let­ter ö, for exam­ple, is encod­ed in UTF‑8 as two bytes, 0xC3 and 0xB6, both val­ues above 127. Old­er relay servers could cor­rupt or drop such bytes.

quoted-printable solves this by con­vert­ing all non-ASCII char­ac­ters into a safe ASCII rep­re­sen­ta­tion. The ö becomes =C3=B6, and ü becomes =C3=BC. The receiv­ing serv­er trans­ports these encod­ed bytes with­out mod­i­fi­ca­tion, and only the mail client decodes them and inter­prets them accord­ing to the charset=utf-8 dec­la­ra­tion in the Content-Type head­er.

In the head­er itself, things work a bit dif­fer­ent­ly. There is no charset field there, so the RFC 2047 Encod­ed Word for­mat is used instead:

Subject: =?utf-8?Q?Nice_subject_with_Uml=C3=A4uten?=

The for­mat is always:

=?charset?encoding?encoded text?=

The Q stands for Quot­ed-Print­able and B stands for Base64. Mail clients decode this auto­mat­i­cal­ly and dis­play the read­able sub­ject line.

Mod­ern mail servers often sup­port the SMTP exten­sion SMTPUTF8 (RFC 6531), which allows raw UTF‑8 bytes in trans­port, but you can nev­er be sure which servers an email pass­es through on its way. Encod­ing prop­er­ly is the safe choice.

Installing and Configuring msmtp

msmtp is a light­weight SMTP client. It sends emails through your provider’s SMTP serv­er with­out being a mail serv­er itself. It accepts a fin­ished mes­sage on stan­dard input and for­wards it. That means the mes­sage must already be cor­rect­ly assem­bled, includ­ing the head­er, the blank line sep­a­ra­tor, and the con­tent. msmtp does not build HTML mail, add attach­ments, or cre­ate any struc­ture. It just sends what­ev­er you give it. And since no mail client is there to han­dle encod­ing, that part has to be done before­hand, as described in the pre­vi­ous sec­tion.

Here is a min­i­mal exam­ple to send a plain text mes­sage from the com­mand line:

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

The -e flag for echo is impor­tant here, because there must be a blank line between the sub­ject head­er and the mes­sage body. With­out -e, the \n would be treat­ed as lit­er­al text rather than a new­line char­ac­ter.

Installation

On a Lin­ux sys­tem like Ubun­tu, there are two rel­e­vant pack­ages:

  • msmtp is the actu­al SMTP client.
  • msmtp-mta is a com­pat­i­bil­i­ty wrap­per that reg­is­ters msmtp as a drop-in replace­ment for clas­sic MTAs like send­mail or Post­fix.

For sim­ple com­mand-line send­ing, the first pack­age is all you need. The sec­ond one only makes sense if you want to use oth­er com­mand-line tools like mail for send­ing and do not already have an MTA like send­mail or Post­fix con­fig­ured. If you have a desk­top mail client installed, it talks direct­ly to your mail serv­er, so this instal­la­tion has no effect on your reg­u­lar email.

Install with:

sudo apt update
sudo apt install msmtp

If the installer sug­gests adding msmtp-mta, you can decline, which leaves any exist­ing con­fig­u­ra­tion untouched.

After the down­load you may be asked whether to add msmtp to your AppAr­mor pro­file. AppAr­mor is a secu­ri­ty mech­a­nism in Ubun­tu. In my test instal­la­tion I con­firmed this option and have not had any prob­lems with it.

On macOS, msmtp can be installed with Home­brew or anoth­er pack­age man­ag­er:

brew install msmtp

Configuration

Next, a con­fig­u­ra­tion file needs to be cre­at­ed. You can choose between a user-spe­cif­ic file at ~/.msmtprc or a glob­al one at /etc/msmtprc. With a glob­al con­fig­u­ra­tion, all users on the sys­tem, includ­ing root, cron jobs, and sys­tem ser­vices, can use msmtp with the cre­den­tials stored there with­out need­ing their own con­fig­u­ra­tion file.

In my set­up, the glob­al vari­ant makes sense because I am the only inter­ac­tive user, and this way even scripts run­ning as root can send mail with­out any extra set­up. If mul­ti­ple users each need their own mail account, the user-spe­cif­ic ~/.msmtprc is the bet­ter choice. On macOS it is gen­er­al­ly rec­om­mend­ed, because the Home­brew-installed msmtp expects the sys­tem-wide con­fig­u­ra­tion file at a ver­sion-spe­cif­ic path that changes with every msmtp update. The user-spe­cif­ic ~/.msmtprc works the same on macOS and Lin­ux and is not affect­ed 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 mul­ti­ple account entries in the con­fig­u­ra­tion to send mail from dif­fer­ent sender address­es, for exam­ple. One of them must be named default, which is used when msmtp is called with­out explic­it­ly spec­i­fy­ing an account. More on that below.

Once you have cre­at­ed 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

Strict­ly speak­ing, this does not pro­duce a ful­ly stan­dards-com­pli­ant email, since all head­er fields except Subject are miss­ing. msmtp cre­ates an SMTP enve­lope from the email address in the argu­ment and the from entry in the con­fig file, wraps the out­put of echo -e in it, and sends it. Most mod­ern mail servers that receive such a mes­sage will add the miss­ing fields like From, To, Date, and Message-ID, so a com­plete email shows up in the inbox, but you should not rely on that.

One detail that might not be obvi­ous: the -e flag ensures that \n is treat­ed as a real new­line, not as the two char­ac­ters back­slash and n. It is also impor­tant that the head­er is sep­a­rat­ed from the body by a blank line. That is why there are two \n in a row: the first ends the last head­er line, and the sec­ond cre­ates the required blank line.

For that rea­son, the fol­low­ing approach is a bet­ter choice. All impor­tant head­er fields are ful­ly assem­bled before the fin­ished 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 won­der­ing about the \ at the end of each line: it pre­vents the shell from treat­ing each line as a sep­a­rate com­mand. The entire call would nor­mal­ly have to be on one line, which quick­ly becomes unread­able. At the same time, each head­er field needs to be sep­a­rat­ed by a new­line in the trans­mit­ted mes­sage. That is han­dled by the for­mat string '%s\n' in the printf com­mand, which appends a new­line after each quot­ed string.

Script for Sending System Info

Now that we know send­ing mail from the com­mand line works, here is a script that assem­bles some cur­rent sys­tem para­me­ters and sends them as an email.

The mail uses multipart/alternative as its Content-Type, which means the out­er con­tain­er holds two or more parts. In this case, an HTML part that presents the data as a table, and a plain text fall­back for mail clients with­out HTML sup­port.

The script works on both macOS and Lin­ux.

It is orga­nized in three sec­tions. The first sec­tion fills vari­ables with the data need­ed to build the email. This includes the head­er fields, where the sub­ject is assem­bled from the text “Sys­tem Report” and the cur­rent date. The bound­ary vari­able is con­struct­ed from the pre­fix ALT_ and the num­ber of sec­onds since Jan­u­ary 1, 1970, which is where Unix time count­ing begins. This ensures the bound­ary name is unique and avoids col­li­sions when, for exam­ple, one email is embed­ded inside anoth­er.

The sec­ond sec­tion col­lects sys­tem data into vari­ables. HOSTNAME=$(hostname) runs the hostname com­mand and stores its out­put in the HOSTNAME vari­able. The $(...) syn­tax is the pat­tern here: what­ev­er sits between the paren­the­ses is exe­cut­ed as a com­mand, and the result is used direct­ly. The same pat­tern appears through­out the script, for exam­ple with $(date) or $(uname). It can also cap­ture the out­put of more com­plex calls:

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

Here df -h / shows the disk usage of the root direc­to­ry. The -h flag makes the out­put human-read­able, so sizes are shown in GB or MB rather than raw bytes. The out­put looks some­thing like this:

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

The | pipes that out­put to awk, a pow­er­ful tool for rec­og­niz­ing and pro­cess­ing text pat­terns. In this case, NR==2 lim­its pro­cess­ing to the sec­ond line, which is the one with the actu­al data. $3, $2, and $5 address the val­ues in the respec­tive columns, so:

{print $3 " of " $2 " used (" $5 ")"}

pro­duces the text 8.2G of 30G used (28%) and stores it in the DISK vari­able.

The oth­er sys­tem para­me­ters fol­low the same pat­tern. The script dis­tin­guish­es between Lin­ux and macOS, since RAM and CPU infor­ma­tion are queried dif­fer­ent­ly on the two sys­tems.

The third sec­tion defines the email con­tent. The plain text part is writ­ten to the vari­able PLAIN and the HTML part to HTML. Then the email is assem­bled, with a sep­a­rate printf com­mand for each part: head­er, plain text, and HTML.

To cor­rect­ly encode the plain text and HTML parts, the script uses a Python one-lin­er that works on both Lin­ux and macOS:

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

The printf com­mands assem­ble the email and pipe the result to msmtp, which deliv­ers 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 exam­ple shows the basic prin­ci­ple of assem­bling a mul­ti­part email using sys­tem data. The next sec­tion applies the same approach with a live weath­er ser­vice.

Email with Current Weather

Open-Meteo is a free weath­er ser­vice with a sim­ple API, mean­ing a straight­for­ward inter­face that a script or pro­gram can use to fetch weath­er data. A sin­gle curl call with geo­graph­ic coor­di­nates and a few para­me­ters returns cur­rent con­di­tions as JSON. Open-Meteo is a good exam­ple here because weath­er data can be retrieved with­out an account or API key. The script requests weath­er for the loca­tion whose coor­di­nates you spec­i­fy.

Since the loca­tion name is shown next to the date in the sub­ject line and may con­tain spe­cial char­ac­ters, the script has to ensure those char­ac­ters are cor­rect­ly encod­ed.

Unlike the plain text and HTML parts, where Content-Type and Content-Transfer-Encoding tell the receiv­ing mail client how the fol­low­ing text is encod­ed, the sub­ject line requires its own han­dling.

For that pur­pose, the func­tion 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 char­ac­ters are present, the text is con­vert­ed 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 com­pact loop that decides how it should appear in the result string. Three rules apply: a space (byte 32) is replaced by an under­score, since spaces in head­er fields are treat­ed as sep­a­ra­tors. Bytes above 127 and the spe­cial char­ac­ters ?, =, and _ are rep­re­sent­ed as =XX, where XX is the hexa­dec­i­mal val­ue of the byte. All oth­er bytes, mean­ing reg­u­lar ASCII char­ac­ters like let­ters or dig­its, are passed through unchanged.

The result­ing pieces are joined into a sin­gle string with join. For Mönsheim the result would be:

M=C3=B6nsheim

Final­ly, this encod­ed text is wrapped in the RFC 2047 enve­lope:

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

This tells the mail client that the text is UTF‑8 encod­ed and uses Q‑Encoding. The client auto­mat­i­cal­ly decodes it and dis­plays “Mön­sheim”.

With the sub­ject han­dled, the API call is assem­bled. The script does this by append­ing one seg­ment at a time to the API_URL vari­able. The final result is equiv­a­lent to this com­mand:

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"

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 sin­gle field from the JSON response into a vari­able, here is the pat­tern using tem­per­a­ture as an exam­ple:

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

The JSON data from the bash vari­able $RESPONSE is passed to a Python process. Python reads the val­ue of temperature_2m under the current node and prints it, so the bash vari­able TEMP gets a plain num­ber it can work with.

The same pat­tern is used to retrieve humid­i­ty, wind speed, and the weath­er code. The weath­er code is then trans­lat­ed into a human-read­able descrip­tion.

After that, the indi­vid­ual parts are assem­bled into a prop­er­ly struc­tured email and hand­ed to msmtp, just as in the pre­vi­ous exam­ple.

Here is the com­plete 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}&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 "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 exam­ples demon­strate the core prin­ci­ple: an email is ulti­mate­ly noth­ing but struc­tured text that msmtp accepts and for­wards. Once you under­stand that, you can not only repli­cate these scripts but also adapt them to your own needs. Open-Meteo alone offers a wide range of addi­tion­al data: fore­casts for the com­ing days, pre­cip­i­ta­tion, UV index, or sun­rise and sun­set times can all be added with an extra para­me­ter in the API call. The full doc­u­men­ta­tion is avail­able at open-meteo.com.

Scheduling with a Task Runner

Scripts like these are only real­ly use­ful when they run auto­mat­i­cal­ly on a sched­ule. On Lin­ux, cron has tra­di­tion­al­ly han­dled this. On macOS, Apple pro­vides its own mech­a­nism called launchd. In either case, the machine should of course be run­ning 24/7 and con­nect­ed to the inter­net.

Cron on Linux

Cron is a back­ground ser­vice that exe­cutes tasks at defined times. Sched­ules are stored in what is called the crontab. Open the crontab for the cur­rent user with:

crontab -e

This typ­i­cal­ly opens in nano. Each entry con­sists of five time fields fol­lowed by the com­mand to run:

Minute  Hour  Day  Month  Weekday  Command

An aster­isk * in any field means “every pos­si­ble val­ue.” Two exam­ples:

# 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 build­ing cron expres­sions is crontab.guru.

One impor­tant note: cron runs in a min­i­mal envi­ron­ment with­out the usu­al shell vari­ables. In par­tic­u­lar, the PATH is heav­i­ly restrict­ed, so com­mands 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 com­mand with which:

which curl
which python3
which msmtp

Alter­na­tive­ly, set the PATH vari­able at the top of the script. Here is an exam­ple for macOS:

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

launchd on macOS

On macOS, launchd is the sys­tem ser­vice that man­ages both back­ground process­es and sched­uled tasks. Sched­ules are defined as XML files in the Prop­er­ty List for­mat (.plist) and placed in a spe­cif­ic loca­tion so that launchd picks them up.

One impor­tant note before get­ting start­ed: macOS pro­tects cer­tain fold­ers like the Desk­top, Doc­u­ments, and Down­loads from access by auto­mat­ed process­es. Scripts that should be exe­cut­ed by launchd belong in an unpro­tect­ed fold­er, for exam­ple ~/scripts/. You can cre­ate it if it does not already exist:

mkdir ~/scripts

For tasks belong­ing to the cur­rent user, the right place for plist files is ~/Library/LaunchAgents/. Cre­ate a new file there, for exam­ple 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 file­name. ProgramArguments spec­i­fies which script to run. StartCalendarInterval defines the sched­ule, here dai­ly at 9:00 AM. StandardErrorPath redi­rects any error out­put to a file, which makes debug­ging eas­i­er.

To reg­is­ter the new file with launchd, load it once:

launchctl load ~/Library/LaunchAgents/com.ileif.weather-report.plist

From that point on, the script runs auto­mat­i­cal­ly at the spec­i­fied time. To deac­ti­vate the job:

launchctl unload ~/Library/LaunchAgents/com.ileif.weather-report.plist

One nice behav­ior of launchd: missed sched­ules can be caught up on. If the Mac was off when a job was sup­posed to run, launchd exe­cutes it the next time the sys­tem starts, pro­vid­ed you set RunAtLoad to true. Cron, by con­trast, silent­ly skips any missed runs.

Wrapping Up

For send­ing email from the com­mand line or from a script, msmtp is a lean and reli­able tool that inte­grates with min­i­mal con­fig­u­ra­tion effort. Once you under­stand how an email is struc­tured inter­nal­ly, send­ing from the com­mand line is not all that com­pli­cat­ed. The MIME struc­ture, the char­ac­ter encod­ing, and the head­er for­mat all fol­low clear rules, and the scripts shown here can serve as a start­ing point for your own ideas.

The exam­ples in this arti­cle are inten­tion­al­ly straight­for­ward. A sys­tem report, a weath­er brief, but the prin­ci­ple trans­fers to a lot of dif­fer­ent use cas­es: mon­i­tor­ing alerts, dai­ly sum­maries from your own data sources, or noti­fi­ca­tions from automa­tion scripts. Once you get the scripts run­ning, you will quick­ly find your own appli­ca­tions. Open-Meteo alone offers fore­casts for the com­ing days, pre­cip­i­ta­tion data, UV index, and much more, all with the same sim­ple API call. The full doc­u­men­ta­tion is at open-meteo.com.

Leave a Reply

Your email address will not be published. Required fields are marked *