sync-mail
I’m a bit of an email nerd, and my setup is rather complicated. I wrote a sync-mail
script to sync email between the mail server and my laptop, keeping it organized in the process.
(I have lots of scripts to do various things on my computer, most of which I don’t publish. But someone asked to see this one, so here it is.)
Below, I examine the script bit by bit. If you’d prefer to see the full script, click to expand the following box. (Note that I’ve redacted a few things, using all caps placeholders like ACCT1
and MY_EMAIL
.)
Full sync-mail
script
#!/bin/sh
set -e
acct="$1"
folder="$2"
profiles="${acct:-ACCT1 ACCT2}"
maildir_mv() {
src_path="$1"
dst_maildir="$2"
[ -f "$src_path" ] || return 0
# rename the file to remove the U=<uid> and prevent mbsync errors
subdir="$(basename "$(dirname "$src_path")")"
file="$(basename "$src_path")"
id="${file%%,*}"; id="${id%%:*}"
flags="${file##*,}"
if [ "$subdir" = cur ] || [ -n "$flags" ]; then
new_file="$id:2,$flags"
else
new_file="$id"
fi
mv "$src_path" ~/pim/mail/"$dst_maildir/$subdir/$new_file"
}
maildir_mvs() {
while read -r file; do
maildir_mv "$file" "$1"
done
}
set_vars() {
profile="$1"
trash_dir=Trash
spam_dir=Junk
sent_dir=Sent
to_me=to:MY_EMAIL
if [ "$profile" = ACCT2 ]; then
trash_dir="Deleted Items"
spam_dir="Junk Email"
sent_dir="Sent Items"
to_me=to:MY_OTHER_EMAIL
fi
export NOTMUCH_PROFILE="$profile"
}
# PRE-SYNC
#
# Move emails between folders based on tag changes. It's important not to move
# any emails that are still tagged as new; they've been indexed by notmuch but
# haven't yet received initial tags based on what folder they arrived in using
# the post-sync logic below.
#
# Searches are carefully constructed to ensure that each email is moved at most
# one time.
for profile in $profiles; do
set_vars "$profile"
# if tagged inbox, spam, or trash but not in corresponding folder, move it
notmuch search --output=files not tag:new \
and tag:inbox and not folder:INBOX \
| maildir_mvs "$profile/INBOX"
notmuch search --output=files not tag:new \
and tag:trash and not tag:inbox and not folder:"\"$trash_dir\"" \
| maildir_mvs "$profile/$trash_dir"
notmuch search --output=files not tag:new \
and tag:spam and not tag:inbox and not tag:trash and not folder:"\"$spam_dir\"" \
| maildir_mvs "$profile/$spam_dir"
# if in inbox, spam, or trash but missing corresponding tag, move to archive
notmuch search --output=files not tag:new and \
"(folder:INBOX and not tag:inbox)" \
or "(folder:\"$spam_dir\" and not tag:spam)" \
or "(folder:\"$trash_dir\" and not tag:trash)" \
| maildir_mvs "$profile/Archive"
done
# SYNC
set +e
mbsync "${acct:--a}${folder:+:$folder}" || status=$?
# POST-SYNC
#
# Update notmuch, tagging new emails based on folders. Once initial tags are
# applied, the new tag must be removed so that the above pre-sync logic to move
# emails to the correct folders based on tags will work on the next run.
for profile in $profiles; do
set_vars "$profile"
notmuch new && notmuch tag --batch <<EOF
# tag based on folder (inbox, drafts, spam, trash)
+inbox -- tag:new and folder:INBOX
+draft -- tag:new and folder:Drafts
+spam -- tag:new and folder:"$spam_dir"
+trash -- tag:new and folder:"$trash_dir"
# remove unread tag if in sent or drafts
-unread -- tag:new and (folder:"$sent_dir" or folder:Drafts)
# tag as trash if in a trashed, muted thread and not addressed to me
+trash +mute -inbox -- tag:new and not $to_me and thread:"{tag:mute and tag:trash}"
# remove new tag
-new -- tag:new
EOF
status="${status:-$?}"
done
exit "${status:-0}"
Background
I use mbsync (a.k.a. isync) to sync messages from my mail server to my laptop in maildir format. On top of that, I use notmuch to organize my email with tags and get powerful search capabilities. My email client, aerc, interfaces directly with notmuch.
I try to stick to notmuch’s philosophy as much as possible, primarily using tags for organization rather than folders. This approach works for me because I almost always access email on my laptop, where I have notmuch set up. I do keep email properly sorted in the “standard” folders – INBOX
, Sent
, Archive
, Drafts
, Trash
, Junk
– for the rare occasions when I need to check email on my phone or via the web client, though. I have notmuch tags that correspond to these folders, with the exception of Sent
and Archive
.
Within my email client, I only add and remove tags, never move messages directly between folders. Instead, my sync-mail
script moves messages for me based on their tags. I settled on this method because I found that aerc didn’t handle moving messages well via notmuch.
For this tag-only approach to work, the script must tag new emails based on the folder that they start in. For example, a new email in the INBOX
folder will have the inbox
tag applied to it. The script also moves existing emails to the correct folder when their tags change. For example, when I delete a message by removing the inbox
tag and adding the trash
tag, it will be moved to the Trash folder on the next run of the script.
I make use of notmuch profiles to keep my email accounts separate. As a result, the sync script is a bit more complex.
My script was inspired by some snippets I found on Reddit.
Setup
First, I do some basic setup. The script expects 0-2 arguments: sync-mail [account [folder]]
. If no arguments are provided, all folders in all accounts are synced. If only an account is provided, all folders in that account are synced. If both account and folder are provided, only that specific folder is synced.
#!/bin/sh
set -e
acct="$1"
folder="$2"
profiles="${acct:-ACCT1 ACCT2}"
Helper functions
Next, I define some helper functions. The first moves a single file between maildirs, being careful to remove mbsync’s UID from the filename in the process. I keep the same subdirectory (either cur
or new
); I’m not sure if that’s “correct” behavior or if I should always move the file into new
, but it’s been working fine so far.
The function takes the file’s full path as the first argument. The second argument is the destination folder (without the final cur
or new
) relative to my base maildir (~/pim/mail/
), which is hardcoded in the function.
maildir_mv() {
src_path="$1"
dst_maildir="$2"
[ -f "$src_path" ] || return 0
# rename the file to remove the U=<uid> and prevent mbsync errors
subdir="$(basename "$(dirname "$src_path")")"
file="$(basename "$src_path")"
id="${file%%,*}"; id="${id%%:*}"
flags="${file##*,}"
if [ "$subdir" = cur ] || [ -n "$flags" ]; then
new_file="$id:2,$flags"
else
new_file="$id"
fi
mv "$src_path" ~/pim/mail/"$dst_maildir/$subdir/$new_file"
}
The next helper function takes a destination folder as its first argument, reads source files from stdin, and then calls maildir_mv()
for each file. This function is designed to work on the receiving end of a notmuch search --output=files
pipe.
maildir_mvs() {
while read -r file; do
maildir_mv "$file" "$1"
done
}
I also have a helper function for setting necessary variables for each account. Some of the folder names differ between accounts, and I set the notmuch profile. The to_me
variable is used to filter emails that are addressed to my email address; I use this variable for the mute feature described below in the post-sync section.
set_vars() {
profile="$1"
trash_dir=Trash
spam_dir=Junk
sent_dir=Sent
to_me=to:MY_EMAIL
if [ "$profile" = ACCT2 ]; then
trash_dir="Deleted Items"
spam_dir="Junk Email"
sent_dir="Sent Items"
to_me=to:MY_OTHER_EMAIL
fi
export NOTMUCH_PROFILE="$profile"
}
Pre-sync
Before syncing, I move emails between folders based on their tags. I don’t move anything that still has the new
tag; those messages have been indexed by notmuch but haven’t yet received initial folder-based tags in the post-sync logic below.
I’ve carefully crafted my search queries to ensure that each message is moved at most one time. The first moves messages that have the inbox
tag to the INBOX
folder. The second moves messages that have the trash
tag but not the inbox
tag to the Trash
folder. The third moves messages that have the spam
tag but not the inbox
or trash
tags to the Junk
folder. The last moves messages that from the INBOX
, Trash
, and Junk
folders that don’t have the corresponding tag to the Archive
folder.
My mail server auto-deletes messages from Trash
and Junk
after a month or so. As a result, my goal here is to err on the side of caution by giving priority to the inbox. If I accidentally tag a message as spam or trash without removing the inbox
tag, it will stay in the INBOX
folder and not get auto-deleted.
# PRE-SYNC
for profile in $profiles; do
set_vars "$profile"
# if tagged inbox, spam, or trash but not in corresponding folder, move it
notmuch search --output=files not tag:new \
and tag:inbox and not folder:INBOX \
| maildir_mvs "$profile/INBOX"
notmuch search --output=files not tag:new \
and tag:trash and not tag:inbox and not folder:"\"$trash_dir\"" \
| maildir_mvs "$profile/$trash_dir"
notmuch search --output=files not tag:new \
and tag:spam and not tag:inbox and not tag:trash and not folder:"\"$spam_dir\"" \
| maildir_mvs "$profile/$spam_dir"
# if in inbox, spam, or trash but missing corresponding tag, move to archive
notmuch search --output=files not tag:new and \
"(folder:INBOX and not tag:inbox)" \
or "(folder:\"$spam_dir\" and not tag:spam)" \
or "(folder:\"$trash_dir\" and not tag:trash)" \
| maildir_mvs "$profile/Archive"
done
Sync
Next, I run mbsync to actually sync messages between the mail server and my computer. If no account is specified, they’re all synced. If no folder is specified, all folders in the specified account are synced.
Starting here, I don’t abort on errors because I want to run the post-sync logic for all synced accounts even if the sync isn’t fully successful. I do keep track of any failed exit status in order to exit the script with a failure code if something went wrong, though.
# SYNC
set +e
mbsync "${acct:--a}${folder:+:$folder}" || status=$?
Post-sync
After syncing, I run notmuch new
for each synced account and then apply some folder-based tagging. Once these initial tags have been applied, the new tag is removed so that the pre-sync logic above will move the messages between maildir folders when their tags change.
I don’t apply any tags for mail in the Sent
folder. Mail only ends up in Sent
when I send it – I never manually move messages there – and I can find sent messages in my email client with a simple from:<my email>
notmuch query.
Similarly, I don’t apply any tags for mail in the Archive
folder. Instead, I consider a message archived if it doesn’t have the inbox
, draft
, spam
, or trash
tag.
The other special tagging feature that I’ve implemented is muted threads. Once I tag a message with the mute
and trash
tags, all future messages in that thread will automatically get those same tags applied. This feature is nice for mailing lists, where threads often go on for quite a while; once I decide I’m no longer interested in a thread, I can mute it and make its subsequent messages skip the inbox. (There’s an exception for emails that are directly addressed to me – they’ll still land in my inbox.)
# POST-SYNC
for profile in $profiles; do
set_vars "$profile"
notmuch new && notmuch tag --batch <<EOF
# tag based on folder (inbox, drafts, spam, trash)
+inbox -- tag:new and folder:INBOX
+draft -- tag:new and folder:Drafts
+spam -- tag:new and folder:"$spam_dir"
+trash -- tag:new and folder:"$trash_dir"
# remove unread tag if in sent or drafts
-unread -- tag:new and (folder:"$sent_dir" or folder:Drafts)
# tag as trash if in a trashed, muted thread and not addressed to me
+trash +mute -inbox -- tag:new and not $to_me and thread:"{tag:mute and tag:trash}"
# remove new tag
-new -- tag:new
EOF
status="${status:-$?}"
done
Last but not least, I exit with a failure code if mbsync or the post-sync logic had any errors.
exit "${status:-0}"