Email automation
Most of the received emails require repetitive actions that can be automated, and you may also want to access your emails through a command line interface and be able to search through them.
One of the ways to achieve that goals is to use a combination of tools to synchronize the mailboxes, tag them, and run scripts automatically based on the tags.
Fetch emails⚑
First you need a program that syncs your mailboxes, following pazz's advice , I'll use mbsync. Follow the steps under installation to configure your accounts, taking as an example an account called lyz
you should be able to sync all your emails with:
mbsync -V lyz
Tag and index emails⚑
If you want to use alot
(which I no longer do) you need to install notmuch
a tool to index, search, read, and tag large collections of email messages. Follow the steps under installation under you have created the database that indexes your emails.
Once we have that, we need a tool to tag the emails following our desired rules. afew is one way to go. Follow the steps under installation.
Automatically sync emails⚑
The new way⚑
I have many emails, and I want to fetch them with different frequencies, in the background and be notified if anything goes wrong.
For that purpose I've created a python script, a systemd service and some loki rules to monitor it.
Script to sync emails and calendars with different frequencies⚑
The script iterates over the configured accounts in accounts_config
and runs mbsync
for email accounts and vdirsyncer
for email accounts based on some cron expressions. It logs the output in logfmt
format so that it's easily handled by loki
To run it you'll first need to create a virtualenv, I use mkvirtualenv account_syncer
which creates a virtualenv in ~/.local/share/virtualenv/account_syncer
.
Then install the dependencies:
pip install aiocron
Then place this script somewhere, for example (~/.local/bin/account_syncer.py
)
import asyncio
import logging
from datetime import datetime
import asyncio.subprocess
import aiocron
# Dependencies:
# pip install aiocron
# Configuration for accounts (example)
accounts_config = {
"emails": [
{
"account_name": "lyz",
"cron_expressions": ["*/15 9-23 * * *"],
},
{
"account_name": "work",
"cron_expressions": ["*/60 8-17 * * 1-5"], # Monday-Friday
},
{
"account_name": "monitorization",
"cron_expressions": ["*/5 * * * *"],
},
],
"calendars": [
{
"account_name": "lyz",
"cron_expressions": ["*/15 9-23 * * *"],
},
{
"account_name": "work",
"cron_expressions": ["*/60 8-17 * * 1-5"], # Monday-Friday
},
],
}
class LogfmtFormatter(logging.Formatter):
"""Custom formatter to output logs in logfmt style."""
def format(self, record: logging.LogRecord) -> str:
log_message = (
f"level={record.levelname.lower()} "
f"logger={record.name} "
f'msg="{record.getMessage()}"'
)
return log_message
def setup_logging(logging_name: str) -> logging.Logger:
"""Configure logging to use logfmt format.
Args:
logging_name (str): The logger's name and identifier in the systemd journal.
Returns:
Logger: The configured logger.
"""
console_handler = logging.StreamHandler()
logfmt_formatter = LogfmtFormatter()
console_handler.setFormatter(logfmt_formatter)
logger = logging.getLogger(logging_name)
logger.setLevel(logging.INFO)
logger.addHandler(console_handler)
return logger
log = setup_logging("account_syncer")
async def run_mbsync(account_name: str) -> None:
"""Run mbsync command asynchronously for email accounts.
Args:
account_name (str): The name of the email account to sync.
"""
command = f"mbsync {account_name}"
log.info(f"Syncing emails for {account_name}...")
process = await asyncio.create_subprocess_shell(
command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if stdout:
log.info(f"Output for {account_name}: {stdout.decode()}")
if stderr:
log.error(f"Error for {account_name}: {stderr.decode()}")
async def run_vdirsyncer(account_name: str) -> None:
"""Run vdirsyncer command asynchronously for calendar accounts.
Args:
account_name (str): The name of the calendar account to sync.
"""
command = f"vdirsyncer sync {account_name}"
log.info(f"Syncing calendar for {account_name}...")
process = await asyncio.create_subprocess_shell(
command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
_, stderr = await process.communicate()
if stderr:
command_log = stderr.decode().strip()
if "error" in command_log or "critical" in command_log:
log.error(f"Output for {account_name}: {command_log}")
elif len(command_log.splitlines()) > 1:
log.info(f"Output for {account_name}: {command_log}")
def should_i_sync_today(cron_expr: str) -> bool:
"""Check if the current time matches the cron expression day and hour constraints."""
_, hour, _, _, day_of_week = cron_expr.split()
now = datetime.now()
if "*" in hour:
return True
elif not (int(hour.split("-")[0]) <= now.hour <= int(hour.split("-")[1])):
return False
if day_of_week != "*" and str(now.weekday()) not in day_of_week.split(","):
return False
return True
async def main():
log.info("Starting account syncer for emails and calendars")
accounts_to_sync = {"emails": [], "calendars": []}
# Schedule email accounts
for account in accounts_config["emails"]:
account_name = account["account_name"]
for cron_expression in account["cron_expressions"]:
if (
should_i_sync_today(cron_expression)
and account_name not in accounts_to_sync["emails"]
):
accounts_to_sync["emails"].append(account_name)
aiocron.crontab(cron_expression, func=run_mbsync, args=[account_name])
log.info(
f"Scheduled mbsync for {account_name} with cron expression: {cron_expression}"
)
# Schedule calendar accounts
for account in accounts_config["calendars"]:
account_name = account["account_name"]
for cron_expression in account["cron_expressions"]:
if (
should_i_sync_today(cron_expression)
and account_name not in accounts_to_sync["calendars"]
):
accounts_to_sync["calendars"].append(account_name)
aiocron.crontab(cron_expression, func=run_vdirsyncer, args=[account_name])
log.info(
f"Scheduled vdirsyncer for {account_name} with cron expression: {cron_expression}"
)
log.info("Running an initial fetch on today's accounts")
for account_name in accounts_to_sync["emails"]:
await run_mbsync(account_name)
for account_name in accounts_to_sync["calendars"]:
await run_vdirsyncer(account_name)
log.info("Finished loading accounts")
while True:
await asyncio.sleep(60)
# Run the main async loop
if __name__ == "__main__":
asyncio.run(main())
Where:
accounts_config
: Holds your account configuration. Each account must contain anaccount_name
which should be the name of thembsync
orvdirsyncer
profile, andcron_expressions
must be a list of cron valid expressions you want the email to be synced.
Create the systemd service⚑
We're using a non-root systemd service. You can follow these instructions to configure this service:
[Unit]
Description=Account Sync Service for emails and calendars
After=graphical-session.target
[Service]
Type=simple
# Run the script using the virtual environment's Python interpreter
ExecStart=/home/lyz/.local/share/virtualenvs/account_syncer/bin/python /home/lyz/.local/bin/
WorkingDirectory=/home/lyz/.local/bin
Restart=on-failure
StandardOutput=journal
StandardError=journal
SyslogIdentifier=account_syncer
# Set the virtual environment's bin directory in the PATH
Environment="PATH=/home/lyz/.local/share/virtualenvs/account_syncer/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
# Environment variable to use the current user's DISPLAY and DBUS_SESSION
Environment="DISPLAY=:0"
Environment="DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus"
[Install]
WantedBy=graphical-session.target
Remember to tweak the service to match your current case and paths.
As we'll probably need to enter our pass
password we need the service to start once we've logged into the graphical interface.
Monitor the automation⚑
It's always nice to know if the system is working as expected without adding mental load. To do that I'm creating the next loki rules:
groups:
- name: account_sync
rules:
- alert: AccountSyncIsNotRunningWarning
expr: |
(sum by(hostname) (count_over_time({job="systemd-journal", syslog_identifier="account_syncer"}[15m])) or sum by(hostname) (count_over_time({hostname="my_computer"} [15m])) * 0 ) == 0
for: 0m
labels:
severity: warning
annotations:
summary: "The account sync script is not running {{ $labels.hostname}}"
- alert: AccountSyncIsNotRunningError
expr: |
(sum by(hostname) (count_over_time({job="systemd-journal", syslog_identifier="account_syncer"}[3h])) or sum by(hostname) (count_over_time({hostname="my_computer"} [3h])) * 0 ) == 0
for: 0m
labels:
severity: error
annotations:
summary: "The account sync script has been down for at least 3 hours {{ $labels.hostname}}"
- alert: AccountSyncError
expr: |
count(rate({job="systemd-journal", syslog_identifier="account_syncer"} |= `` | logfmt | level_extracted=`error` [5m])) > 0
for: 0m
labels:
severity: warning
annotations:
summary: "There are errors in the account sync log at {{ $labels.hostname}}"
- alert: EmailAccountIsOutOfSyncLyz
expr: |
(sum by(hostname) (count_over_time({job="systemd-journal", syslog_identifier="account_syncer"} | logfmt | msg=`Syncing emails for lyz...`[1h])) or sum by(hostname) (count_over_time({hostname="my_computer"} [1h])) * 0 ) == 0
for: 0m
labels:
severity: error
annotations:
summary: "The email account lyz has been out of sync for 1h {{ $labels.hostname}}"
- alert: CalendarAccountIsOutOfSyncLyz
expr: |
(sum by(hostname) (count_over_time({job="systemd-journal", syslog_identifier="account_syncer"} | logfmt | msg=`Syncing calendar for lyz...`[3h])) or sum by(hostname) (count_over_time({hostname="my_computer"} [3h])) * 0 ) == 0
for: 0m
labels:
severity: error
annotations:
summary: "The calendar account lyz has been out of sync for 3h {{ $labels.hostname}}"
my_computer
for the hostname of the device running the service - Tweak the OutOfSync alerts to match your account (change the lyz
part). These rules will raise: - A warning if the sync has not shown any activity in the last 15 minutes. - An error if the sync has not shown any activity in the last 3 hours. - An error if there is an error in the logs of the automation.
The old way⚑
The remaining step to keep the inboxes synced and tagged is to run all the steps above in a cron. Particularize pazz's script for your usecase:
#!/bin/bash
#
# Download and index new mail.
#
# Copyright (c) 2017 Patrick Totzke
# Dependencies: flock, nm-online, mbsync, notmuch, afew
# Example crontab entry:
#
# */2 * * * * /usr/bin/flock -n /home/pazz/.pullmail.lock /home/pazz/bin/pullmail.sh > /home/pazz/.pullmail.log
#
PATH=/home/pazz/.local/bin:/usr/local/bin/:$PATH
ACCOUNTDIR=/home/pazz/.pullmail/
# this makes the keyring daemon accessible
function keyring-control() {
local -a vars=( \
DBUS_SESSION_BUS_ADDRESS \
GNOME_KEYRING_CONTROL \
GNOME_KEYRING_PID \
XDG_SESSION_COOKIE \
GPG_AGENT_INFO \
SSH_AUTH_SOCK \
)
local pid=$(ps -C i3 -o pid --no-heading)
eval "unset ${vars[@]}; $(printf "export %s;" $(sed 's/\x00/\n/g' /proc/${pid//[^0-9]/}/environ | grep $(printf -- "-e ^%s= " "${vars[@]}")) )"
}
function log() {
notify-send -t 2000 'mail sync:' "$@"
}
function die() {
notify-send -t 2000 -u critical 'mail sync:' "$@"
exit 1
}
# Let's Do stuff
keyring-control
# abort as soon as something fails
set -e
# abort if not online
nm-online -x -t 0
echo ---------------------------------------------------------
date
for accfile in `ls $ACCOUNTDIR`;
do
ACC=$(basename $accfile)
echo ------------------------ $ACC ------------------------
mbsync -V $ACC || log "$ACC failed"
done
# index and tag new mails
echo ------------------------ NOTMUCH ------------------------
notmuch new 2>/dev/null || die "NOTMUCH new failed"
echo ------------------------ AFEW ------------------------
afew -v --tag --new || die "AFEW died"
echo ---------------------------------------------------------
echo "all done, goodbye."
Where flock
is a tool to manage locks from shell scripts.
And add the entry in your crontab -e
.
If you want to process your emails with this system through a command line interface, you can configure alot.