#!/bin/sh # - Syncs mail for all accounts, or a single account given as an argument.# - Displays a notification showing the number of new mails.# - Displays a notification for each new mail with its subject displayed.# - Runs notmuch to index new mail.# - This script can be set up as a cron job for automated mail syncing. # There are many arbitrary and ugly features in this script because it is# inherently difficult to pass environmental variables to cronjobs and other# issues. It also should at least be compatible with Linux (and maybe BSD) with# Xorg and MacOS as well. # Run only if not already running in other instance. `pgrep mbsync` matched# other users' processes and anything with "mbsync" in its name, so use a lock# instead. The lock is a symlink whose target is the owner's PID: `ln -s` is# atomic, so two simultaneous runs cannot both win, and a lock left behind by# a crashed run is recognised as stale with `kill -0` and replaced.lock="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}/mailsync-$(id -u).lock"if ! ln -s "$$" "$lock" 2>/dev/null; then if kill -0 "$(readlink "$lock")" 2>/dev/null; then echo "mailsync is already running." exit 0 fi rm -f "$lock" ln -s "$$" "$lock" 2>/dev/null || { echo "mailsync is already running."; exit 0 ;}fitrap 'rm -f "$lock"' EXITtrap 'exit 1' HUP INT TERM # Settings for scheduled runs (cron, systemd, launchd have no session# environment) come from one env file written by `mw`. It is parsed here, never# evaluated or sourced: only KEY=VALUE lines for the variables below are# accepted, one level of matching quotes is stripped, and `$` or backticks in# values are kept literally. A variable already set in the environment wins.# If XDG_CONFIG_HOME is only defined in the env file, the file itself is still# looked up under the default ~/.config.mwenv="${XDG_CONFIG_HOME:-$HOME/.config}/mutt-wizard/env"if [ -r "$mwenv" ]; then while IFS= read -r line || [ -n "$line" ]; do line="${line#"${line%%[![:space:]]*}"}" case "$line" in ''|'#'*) continue ;; esac case "$line" in *=*) ;; *) continue ;; esac key="${line%%=*}" val="${line#*=}" case "$key" in MBSYNCRC|MPOPRC|PASSWORD_STORE_DIR|PASSWORD_STORE_GPG_OPTS|NOTMUCH_CONFIG|GNUPGHOME|MAILSYNC_MUTE|MAILSYNC_FOLDERS|MAILSYNC_MUTE_FROM|MAILSYNC_ACTION|MAILSYNC_OPEN|XDG_CONFIG_HOME|XDG_DATA_HOME) ;; *) continue ;; esac # key is whitelisted above, so this eval only ever sees a plain name eval "[ -n \"\${$key+x}\" ]" && continue case "$val" in \"*\") val="${val#\"}" ; val="${val%\"}" ;; \'*\') val="${val#\'}" ; val="${val%\'}" ;; esac eval "$key=\$val; export $key" done <"$mwenv"fi # Same rule as mw: $MBSYNCRC, else ~/.mbsyncrc, else the XDG isyncrc that# isync 1.5 reads. A function rather than an alias, so a path with spaces# stays one argument.if [ -z "$MBSYNCRC" ]; then if [ ! -f "$HOME/.mbsyncrc" ] && [ -f "${XDG_CONFIG_HOME:-$HOME/.config}/isyncrc" ]; then MBSYNCRC="${XDG_CONFIG_HOME:-$HOME/.config}/isyncrc" else MBSYNCRC="$HOME/.mbsyncrc" fifimbsync() { command mbsync -c "$MBSYNCRC" "$@" ;}[ -n "$MPOPRC" ] || MPOPRC="$HOME/.config/mpop/config" # For a non-interactive shell (e.g. a scheduled job) run only when the GPG key# of the pass store is unlocked. Skipped when no account fetches its password# with pass. The key is the first line of the store's .gpg-id.if ! tty -s && grep -qs "pass " "$MBSYNCRC" "$MPOPRC" \ "${XDG_CONFIG_HOME:-$HOME/.config}/msmtp/config" "$HOME/.msmtprc"; then gpgid="$(head -n 1 "${PASSWORD_STORE_DIR:-$HOME/.password-store}/.gpg-id" 2>/dev/null)" # shellcheck disable=SC2086 echo "dummy" | gpg --sign --batch --pinentry-mode error ${gpgid:+--local-user "$gpgid"} \ $PASSWORD_STORE_GPG_OPTS -o /dev/null >/dev/null 2>&1 || { echo "mailsync: skipping run, the GPG key${gpgid:+ $gpgid} of the password store is locked or unusable (unlock it, e.g. with \`pass show\`, then retry)." >&2 exit 1 }fi export GPG_TTY="$(tty)" lastrun="${XDG_CONFIG_HOME:-$HOME/.config}/mutt/.mailsynclastrun"mkdir -p "${lastrun%/*}" # Maildir root, as mw lays it out (honours XDG_DATA_HOME).maildir="${XDG_DATA_HOME:-$HOME/.local/share}/mail" # Settings are different for MacOS (Darwin) systems.case "$(uname)" in Darwin) notify() { # Escape backslashes and double quotes for AppleScript. t="$(printf %s "$1" | sed 's/[\\"]/\\&/g')" m="$(printf %s "$2" | sed 's/[\\"]/\\&/g')" osascript -e "display notification \"$m\" with title \"$t\"" ;} ;; *) # notify-send talks to the notification daemon over D-Bus, so what it needs # is the session bus, not an X display. Keep an address that is already set # (an inherited one is the right one); otherwise point at the standard # socket, but only if it exists. havebus= rundir="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" if [ -z "$DBUS_SESSION_BUS_ADDRESS" ] && [ -S "$rundir/bus" ]; then export DBUS_SESSION_BUS_ADDRESS="unix:path=$rundir/bus" fi [ -n "$DBUS_SESSION_BUS_ADDRESS" ] && havebus=1 # Only a cron job on an X session needs to guess its display: remember if a # display server is running since `ps` doesn't always contain a display. pgrepoutput= displays= if [ -z "$DISPLAY$WAYLAND_DISPLAY" ]; then pgrepoutput="$(pgrep -ax X\(\|org\|wayland\))" displays="$(echo "$pgrepoutput" | grep -wo "[0-9]*:[0-9]\+" | sort -u)" [ -z "$displays" ] && [ -d /tmp/.X11-unix ] && displays=$(cd /tmp/.X11-unix && for x in X*; do echo ":${x#X}"; done) fi # Send one notification, to the display(s) found above. Extra arguments # (for notify-send) go before the title. sendnote() { if [ -n "$DISPLAY$WAYLAND_DISPLAY" ]; then notify-send --app-name="mutt-wizard" "$@" </dev/null elif [ -n "$pgrepoutput" ]; then for x in ${displays:-:0}; do DISPLAY="$x" notify-send --app-name="mutt-wizard" "$@" </dev/null done elif [ -n "$havebus" ]; then notify-send --app-name="mutt-wizard" "$@" </dev/null fi ;} # Optional "Open" button (MAILSYNC_ACTION=1) that opens neomutt on the # account. notify-send --action waits until the notification is closed, so # the whole call runs in the background and the mail loop never blocks. # Without --action support in notify-send, or without an opener, notify # silently falls back to a plain notification. opener="${MAILSYNC_OPEN:-${TERMINAL:+$TERMINAL -e neomutt}}" canact= if [ "$MAILSYNC_ACTION" = 1 ] && [ -n "$opener" ] && notify-send --help 2>&1 </dev/null | grep -q -- '--action'; then canact=1 fi # Run the opener on folder $1 of the account being synced ("!" is its # spool mailbox). The opener gets neomutt options appended, and the # account address in MAILSYNC_ACCOUNT. openmail() { MAILSYNC_ACCOUNT="$acct" export MAILSYNC_ACCOUNT # the account loop below runs with IFS set to a newline IFS=" $(printf '\t\n_')" ; IFS="${IFS%_}" set -f accrc="${XDG_CONFIG_HOME:-$HOME/.config}/mutt/accounts/$acct.muttrc" if [ -f "$accrc" ]; then $opener -e "source \"$accrc\"" -f "$1" else $opener -f "$1" fi ;} # notify <title> <text> [folder]: a folder (or "!" for the spool mailbox) # offers the open action. notify() { if [ -n "$canact" ] && [ -n "$3" ]; then ( act="$(sendnote --action="open=Open" -- "$1" "$2")" [ "$act" = open ] && openmail "$3" ) >/dev/null 2>&1 </dev/null & else sendnote -- "$1" "$2" fi ;} ;;esac # Is notification muted for account $1? MAILSYNC_MUTE unset or empty: no. A# value without "@": everything is muted. Otherwise it is a space or comma# separated list of accounts to mute.muted() { [ -n "$MAILSYNC_MUTE" ] || return 1 case "$MAILSYNC_MUTE" in *@*) ;; *) return 0 ;; esac list="$(printf %s "$MAILSYNC_MUTE" | tr -s ', ' ' ')" case " $list " in *" $1 "*) return 0 ;; esac return 1} # Print the unfolded, decoded value of header $1 of message file $2.header() { awk -v h="$1" ' { sub(/\r$/, "") } /^$/ { exit } /^[ \t]/ { if (on) { sub(/^[ \t]+/, " "); acc = acc $0 } ; next } on { exit } tolower(substr($0, 1, length(h) + 1)) == tolower(h) ":" { on = 1; acc = substr($0, length(h) + 2); sub(/^[ \t]+/, "", acc) } END { if (on) print acc }' "$2" | perl -CS -MEncode -ne 'print decode("MIME-Header", $_)'} # Is folder $1 (a path below the account's maildir, e.g. Lists/foo) one that# MAILSYNC_FOLDERS asks to scan? INBOX always is. "all" means every folder# except those with a Sent, Drafts, Trash, Junk, Spam or Archive style name,# looked for case-insensitively in every path component whatever the server's# delimiter ("/" or "."), so [Gmail]/Sent Mail and INBOX.Sent both drop out.# Anything else is a list of folder names, in $folderlist.wantfolder() { low="$(printf %s "$1" | tr 'A-Z' 'a-z')" [ "$low" = inbox ] && return 0 if [ "$MAILSYNC_FOLDERS" = all ]; then for part in $(printf %s "$low" | tr './ ' '\n\n_'); do case "$part" in sent|sent_mail|sent_items|sent_messages|drafts|draft|trash|bin|deleted_items|deleted_messages|junk|junk_e-mail|junk_email|spam|bulk_mail|archive|archives|all_mail) return 1 ;; esac done return 0 fi printf '%s\n' "$folderlist" | grep -Fxq -e "$(printf %s "$low" | tr . /)"} # Print the files delivered since the last run in the folders to scan of# account $1: INBOX only, unless MAILSYNC_FOLDERS widens it.findnew() { if [ -z "$MAILSYNC_FOLDERS" ]; then find\ "$maildir/$1/"[Ii][Nn][Bb][Oo][Xx]/new/ \ "$maildir/$1/"[Ii][Nn][Bb][Oo][Xx]/cur/ \ -type f -newer "$lastrun" 2> /dev/null return fi find "$maildir/$1" \( -name cur -o -name tmp \) -prune -o -type d -name new -print 2>/dev/null | while IFS= read -r dir; do dir="${dir%/new}" [ -d "$dir/cur" ] || continue wantfolder "${dir#"$maildir/$1/"}" || continue find "$dir/new" "$dir/cur" -type f -newer "$lastrun" 2> /dev/null done} # Does the sender of message file $1 match a MAILSYNC_MUTE_FROM pattern (shell# globs in $mutepats, one per line, lowercase)? The address is the one in# <angle brackets> of the From header, else the whole header, lowercased.mutedsender() { addr="$(header From "$1" | tr -d '\n')" case "$addr" in *'<'*'>'*) addr="${addr##*<}" ; addr="${addr%%>*}" ;; esac addr="$(printf %s "$addr" | tr 'A-Z' 'a-z' | tr -d ' \t')" printf '%s\n' "$mutepats" | { while IFS= read -r pat; do # shellcheck disable=SC2254 case "$addr" in $pat) exit 0 ;; esac done exit 1 ;}} # Proton Bridge accounts. A Bridge account is an IMAPStore block of the mbsync# config whose Host is 127.0.0.1, localhost or ::1 and whose Port is 1143; its# channel is the store name without "-remote". One "address host" line each.# Read with awk only; the file is never sourced.bridgeaccts="$(awk ' function flush() { if (name != "" && host != "" && port == "1143") print name, host; name = ""; host = ""; port = "" } { sub(/\r$/, ""); line = $0; sub(/^[ \t]+/, "", line) } line == "" { flush(); next } line ~ /^#/ { next } { key = tolower(line); sub(/[ \t].*$/, "", key) val = line; sub(/^[^ \t]+[ \t]+/, "", val); sub(/[ \t]+$/, "", val) } key == "imapstore" { flush(); name = val; sub(/-remote$/, "", name); next } key == "maildirstore" || key == "channel" || key == "group" { flush(); next } name != "" && key == "host" && (val == "127.0.0.1" || val == "localhost" || val == "::1") { host = val } name != "" && key == "port" { port = val } END { flush() }' "$MBSYNCRC" 2>/dev/null)" # This script's own directory, to find the pin helper when it is installed# (PREFIX/bin/mailsync, PREFIX/lib/mutt-wizard/mutt-wizard-pin) or run from a# checkout (bin/mailsync, lib/mutt-wizard-pin).case "$0" in */*) bindir="${0%/*}" ;; *) bindir="$(dirname "$(command -v "$0" 2>/dev/null)")" ;;esachavetimeout=command -v timeout >/dev/null 2>&1 && havetimeout=1 # Run a command for at most 3 seconds when timeout(1) exists.tmo3() { if [ -n "$havetimeout" ]; then timeout 3 "$@"; else "$@"; fi} # Can a TCP connection to host $1 port $2 be made within 3 seconds? Status 0# yes, 1 no, 2 cannot tell (no tool to ask with). Uses nc -z when there is an# nc, else bash's /dev/tcp. With neither, the answer is "cannot tell" and the# sync goes ahead, so nothing is skipped that might have worked.tcpcheck() { if command -v nc >/dev/null 2>&1; then tmo3 nc -z -w 3 "$1" "$2" </dev/null >/dev/null 2>&1 && return 0 return 1 fi if command -v bash >/dev/null 2>&1; then # shellcheck disable=SC2016 tcperr="$(tmo3 bash -c 'exec 3<>"/dev/tcp/$0/$1"' "$1" "$2" 2>&1 </dev/null)" && return 0 # a bash built without /dev/tcp says so instead of "refused" case "$tcperr" in *"No such file"*|*"not supported"*) return 2 ;; esac return 1 fi return 2} # The pin helper: $MW_PIN, else next to the installed or checked-out script,# else $MW_LIB or the usual prefixes. Prints its path, or fails.pinhelper() { for cand in "$MW_PIN" "$bindir/../lib/mutt-wizard/mutt-wizard-pin" \ "$bindir/../lib/mutt-wizard-pin" "${MW_LIB:+$MW_LIB/mutt-wizard-pin}" \ /usr/local/lib/mutt-wizard/mutt-wizard-pin /usr/lib/mutt-wizard/mutt-wizard-pin; do [ -n "$cand" ] && [ -f "$cand" ] && { printf '%s\n' "$cand"; return 0; } done return 1} # bridgenote <kind> <account> <title> <text>: send a notification about a# Bridge problem, at most once per account and kind in 6 hours. The stamp file# is ${XDG_STATE_HOME:-~/.local/state}/mutt-wizard/bridge-<kind>-<account>;# deleting it re-arms the notification (mw repin does this for "cert").bridgenote() { stampdir="${XDG_STATE_HOME:-$HOME/.local/state}/mutt-wizard" stamp="$stampdir/bridge-$1-$(printf %s "$2" | tr '/' '_')" if [ -f "$stamp" ] && [ -n "$(find "$stamp" -mmin -360 2>/dev/null)" ]; then return 0 fi # the certificate warning is never muted: it is about trust, not new mail [ "$1" = cert ] || ! muted "$2" || return 0 mkdir -p "$stampdir" 2>/dev/null && touch "$stamp" 2>/dev/null notify "$3" "$4"} # Before syncing Bridge account $1: is Bridge up on host:port, and is it still# presenting the pinned certificate? Status 0 to go ahead, 1 to skip the# account (the reason is on stderr and, rate limited, in a notification).# Other accounts are not affected. Not a Bridge account: always 0.bridgecheck() { bhost="$(printf '%s\n' "$bridgeaccts" | MS_A="$1" awk '$1 == ENVIRON["MS_A"] { print $2; exit }')" [ -n "$bhost" ] || return 0 bport="${MAILSYNC_BRIDGE_PORT:-1143}" bdown="Proton Bridge is not running ($bhost:$bport)." if command -v systemctl >/dev/null 2>&1; then bdown="$bdown Start it with: systemctl --user start protonmail-bridge" else bdown="$bdown Start Proton Bridge, then run mailsync again." fi tcpcheck "$bhost" "$bport" if [ "$?" = 1 ]; then echo "mailsync: skipping $1: $bdown" >&2 bridgenote down "$1" "Proton Bridge is not running" "$bdown" return 1 fi [ -f "${XDG_DATA_HOME:-$HOME/.local/share}/mutt-wizard/certs/$1.pem" ] || return 0 if ! bpin="$(pinhelper)"; then echo "mailsync: cannot find mutt-wizard-pin, syncing $1 without checking its pinned certificate." >&2 return 0 fi bout="$(MW_DISCOVER_TIMEOUT="${MW_DISCOVER_TIMEOUT:-3}" sh "$bpin" check "$1" "$bhost" "$bport" starttls-imap </dev/null 2>&1)" case "$?" in 0) return 0 ;; 2) echo "mailsync: skipping $1: $bdown" >&2 bridgenote down "$1" "Proton Bridge is not running" "$bdown" return 1 ;; 3) bchg="Proton Bridge certificate changed. If you reset or reinstalled Bridge, run: mw repin $1" echo "mailsync: skipping $1: $bchg" >&2 printf '%s\n' "$bout" >&2 bridgenote cert "$1" "Proton Bridge certificate changed" "$bchg" return 1 ;; *) echo "mailsync: the pin check for $1 failed ($bout), syncing anyway." >&2 return 0 ;; esac} # Check account for new mail. Notify if there is new content. Mail is counted# even when the sync reported an error (a warning about one bad folder must not# hide what did arrive). Return 0 on a clean sync, 1 on a failed one, and 3 on# a failed one that still delivered new mail.syncandnotify() { syncrc=0 acct="$2" [ "$1" = imap ] && { bridgecheck "$2" || return 1 ;} case "$1" in imap) mbsync -q "$2" ;; pop) mpop -q "$2" ;; esac || { syncrc=$?; echo "mailsync: syncing $2 failed (exit status $syncrc)." >&2 ;} # A list of folder names: separated by commas when there is a comma (so a # name may hold spaces), else by blanks. Lowercase, "." read as "/". folderlist= case "$MAILSYNC_FOLDERS" in ''|all) ;; *,*) folderlist="$(printf %s "$MAILSYNC_FOLDERS" | tr 'A-Z,' 'a-z\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | tr . /)" ;; *) folderlist="$(printf %s "$MAILSYNC_FOLDERS" | tr 'A-Z \t' 'a-z\n\n' | tr . /)" ;; esac new=$(findnew "$2") newcount=$(echo "$new" | sed '/^[[:space:]]*$/d' | wc -l) # Mail from muted senders is counted above but never announced. mutepats="$(printf %s "$MAILSYNC_MUTE_FROM" | tr 'A-Z, \t' 'a-z\n\n\n' | sed '/^$/d')" toshow="$new" if [ -n "$mutepats" ] && [ "$newcount" -gt 0 ]; then toshow="$(printf '%s\n' "$new" | while IFS= read -r file; do [ -n "$file" ] || continue mutedsender "$file" || printf '%s\n' "$file" done)" fi showcount=$(echo "$toshow" | sed '/^[[:space:]]*$/d' | wc -l) mutednote= [ "$showcount" -lt "$newcount" ] && mutednote=" ($((newcount - showcount)) from muted senders)" if [ "$newcount" -gt 5 ]; then echo "$newcount new mails for $2.${mutednote}" elif [ "$newcount" -gt 0 ]; then echo "$newcount new mail(s) for $2.${mutednote}" else echo "No new mail for $2." fi if [ "$showcount" -gt 5 ]; then muted "$2" || notify "New Mail!" "📬 $showcount new mails in \`$2\` account." "!" elif [ "$showcount" -gt 0 ]; then muted "$2" || printf '%s\n' "$toshow" | while IFS= read -r file; do [ -n "$file" ] || continue # Extract and decode subject and sender from mail. subject="$(header Subject "$file" | tr -d '\n\t')" from="$(header From "$file" | tr -d '\n')" from="${from% *}" ; from="${from%\"}" ; from="${from#\"}" dir="${file%/*}" notify "📧$from:" "$subject" "${dir%/*}" done fi [ "$syncrc" = 0 ] && return 0 [ "$newcount" -gt 0 ] && return 3 return 1} allaccounts="$(grep -hs "^\(Channel\|account\)" "$MBSYNCRC" "$MPOPRC")" # Get accounts to sync. All if no argument. Prefix with `error` if non-existent.IFS=''if [ -z "$1" ]; then tosync="$allaccounts"else tosync="$(for arg in "$@"; do for availacc in $allaccounts; do [ "$arg" = "${availacc##* }" ] && echo "$availacc" && break done || echo "error $arg"; done)"fi pids= # PIDs of backgrounded subshellscount=0 # number of accounts in $tosyncfailed=0 # number of failed sync attemptsfor account in $tosync; do count=$((count+1)) case $account in Channel*) syncandnotify imap "${account##* }" & pids="$pids $!" ;; account*) syncandnotify pop "${account##* }" & pids="$pids $!" ;; error*) echo "ERROR: Account ${account##* } not found." failed=$((failed+1)) ;; esacdone IFS=' 'delivered=0 # a failed sync still brought in new mailfor pid in $pids; do wait "$pid" rc=$? [ "$rc" = 0 ] || failed=$((failed+1)) [ "$rc" = 3 ] && delivered=1done # Abort if no account sync succeeded. Mail that a failed sync did deliver was# notified already, so still index it and mark the run, or the next run would# announce it again.if [ "$count" -le "$failed" ]; then if [ "$delivered" = 1 ]; then notmuch new --quiet touch "$lastrun" fi exit 1fi notmuch new --quiet #Create a touch file that indicates the time of the last run of mailsynctouch "$lastrun"
#!/bin/sh
# - Syncs mail for all accounts, or a single account given as an argument.
# - Displays a notification showing the number of new mails.
# - Displays a notification for each new mail with its subject displayed.
# - Runs notmuch to index new mail.
# - This script can be set up as a cron job for automated mail syncing.
# There are many arbitrary and ugly features in this script because it is
# inherently difficult to pass environmental variables to cronjobs and other
# issues. It also should at least be compatible with Linux (and maybe BSD) with
# Xorg and MacOS as well.
# Run only if not already running in other instance. `pgrep mbsync` matched
# other users' processes and anything with "mbsync" in its name, so use a lock
# instead. The lock is a symlink whose target is the owner's PID: `ln -s` is
# atomic, so two simultaneous runs cannot both win, and a lock left behind by
# a crashed run is recognised as stale with `kill -0` and replaced.
lock="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}/mailsync-$(id -u).lock"
if ! ln -s "$$" "$lock" 2>/dev/null; then
if kill -0 "$(readlink "$lock")" 2>/dev/null; then
echo "mailsync is already running."
exit 0
fi
rm -f "$lock"
ln -s "$$" "$lock" 2>/dev/null || { echo "mailsync is already running."; exit 0 ;}
fi
trap 'rm -f "$lock"' EXIT
trap 'exit 1' HUP INT TERM
# Settings for scheduled runs (cron, systemd, launchd have no session
# environment) come from one env file written by `mw`. It is parsed here, never
# evaluated or sourced: only KEY=VALUE lines for the variables below are
# accepted, one level of matching quotes is stripped, and `$` or backticks in
# values are kept literally. A variable already set in the environment wins.
# If XDG_CONFIG_HOME is only defined in the env file, the file itself is still
# looked up under the default ~/.config.
mwenv="${XDG_CONFIG_HOME:-$HOME/.config}/mutt-wizard/env"
if [ -r "$mwenv" ]; then
while IFS= read -r line || [ -n "$line" ]; do
line="${line#"${line%%[![:space:]]*}"}"
case "$line" in ''|'#'*) continue ;; esac
case "$line" in *=*) ;; *) continue ;; esac
key="${line%%=*}"
val="${line#*=}"
case "$key" in
MBSYNCRC|MPOPRC|PASSWORD_STORE_DIR|PASSWORD_STORE_GPG_OPTS|NOTMUCH_CONFIG|GNUPGHOME|MAILSYNC_MUTE|MAILSYNC_FOLDERS|MAILSYNC_MUTE_FROM|MAILSYNC_ACTION|MAILSYNC_OPEN|XDG_CONFIG_HOME|XDG_DATA_HOME) ;;
*) continue ;;
esac
# key is whitelisted above, so this eval only ever sees a plain name
eval "[ -n \"\${$key+x}\" ]" && continue
case "$val" in
\"*\") val="${val#\"}" ; val="${val%\"}" ;;
\'*\') val="${val#\'}" ; val="${val%\'}" ;;
esac
eval "$key=\$val; export $key"
done <"$mwenv"
fi
# Same rule as mw: $MBSYNCRC, else ~/.mbsyncrc, else the XDG isyncrc that
# isync 1.5 reads. A function rather than an alias, so a path with spaces
# stays one argument.
if [ -z "$MBSYNCRC" ]; then
if [ ! -f "$HOME/.mbsyncrc" ] && [ -f "${XDG_CONFIG_HOME:-$HOME/.config}/isyncrc" ]; then
MBSYNCRC="${XDG_CONFIG_HOME:-$HOME/.config}/isyncrc"
else
MBSYNCRC="$HOME/.mbsyncrc"
fi
fi
mbsync() { command mbsync -c "$MBSYNCRC" "$@" ;}
[ -n "$MPOPRC" ] || MPOPRC="$HOME/.config/mpop/config"
# For a non-interactive shell (e.g. a scheduled job) run only when the GPG key
# of the pass store is unlocked. Skipped when no account fetches its password
# with pass. The key is the first line of the store's .gpg-id.
if ! tty -s && grep -qs "pass " "$MBSYNCRC" "$MPOPRC" \
"${XDG_CONFIG_HOME:-$HOME/.config}/msmtp/config" "$HOME/.msmtprc"; then
gpgid="$(head -n 1 "${PASSWORD_STORE_DIR:-$HOME/.password-store}/.gpg-id" 2>/dev/null)"
# shellcheck disable=SC2086
echo "dummy" | gpg --sign --batch --pinentry-mode error ${gpgid:+--local-user "$gpgid"} \
$PASSWORD_STORE_GPG_OPTS -o /dev/null >/dev/null 2>&1 || {
echo "mailsync: skipping run, the GPG key${gpgid:+ $gpgid} of the password store is locked or unusable (unlock it, e.g. with \`pass show\`, then retry)." >&2
exit 1
}
fi
export GPG_TTY="$(tty)"
lastrun="${XDG_CONFIG_HOME:-$HOME/.config}/mutt/.mailsynclastrun"
mkdir -p "${lastrun%/*}"
# Maildir root, as mw lays it out (honours XDG_DATA_HOME).
maildir="${XDG_DATA_HOME:-$HOME/.local/share}/mail"
# Settings are different for MacOS (Darwin) systems.
case "$(uname)" in
Darwin) notify() {
# Escape backslashes and double quotes for AppleScript.
t="$(printf %s "$1" | sed 's/[\\"]/\\&/g')"
m="$(printf %s "$2" | sed 's/[\\"]/\\&/g')"
osascript -e "display notification \"$m\" with title \"$t\"" ;} ;;
*)
# notify-send talks to the notification daemon over D-Bus, so what it needs
# is the session bus, not an X display. Keep an address that is already set
# (an inherited one is the right one); otherwise point at the standard
# socket, but only if it exists.
havebus=
rundir="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
if [ -z "$DBUS_SESSION_BUS_ADDRESS" ] && [ -S "$rundir/bus" ]; then
export DBUS_SESSION_BUS_ADDRESS="unix:path=$rundir/bus"
fi
[ -n "$DBUS_SESSION_BUS_ADDRESS" ] && havebus=1
# Only a cron job on an X session needs to guess its display: remember if a
# display server is running since `ps` doesn't always contain a display.
pgrepoutput=
displays=
if [ -z "$DISPLAY$WAYLAND_DISPLAY" ]; then
pgrepoutput="$(pgrep -ax X\(\|org\|wayland\))"
displays="$(echo "$pgrepoutput" | grep -wo "[0-9]*:[0-9]\+" | sort -u)"
[ -z "$displays" ] && [ -d /tmp/.X11-unix ] && displays=$(cd /tmp/.X11-unix && for x in X*; do echo ":${x#X}"; done)
fi
# Send one notification, to the display(s) found above. Extra arguments
# (for notify-send) go before the title.
sendnote() {
if [ -n "$DISPLAY$WAYLAND_DISPLAY" ]; then
notify-send --app-name="mutt-wizard" "$@" </dev/null
elif [ -n "$pgrepoutput" ]; then
for x in ${displays:-:0}; do
DISPLAY="$x" notify-send --app-name="mutt-wizard" "$@" </dev/null
done
elif [ -n "$havebus" ]; then
notify-send --app-name="mutt-wizard" "$@" </dev/null
fi ;}
# Optional "Open" button (MAILSYNC_ACTION=1) that opens neomutt on the
# account. notify-send --action waits until the notification is closed, so
# the whole call runs in the background and the mail loop never blocks.
# Without --action support in notify-send, or without an opener, notify
# silently falls back to a plain notification.
opener="${MAILSYNC_OPEN:-${TERMINAL:+$TERMINAL -e neomutt}}"
canact=
if [ "$MAILSYNC_ACTION" = 1 ] && [ -n "$opener" ] &&
notify-send --help 2>&1 </dev/null | grep -q -- '--action'; then
canact=1
fi
# Run the opener on folder $1 of the account being synced ("!" is its
# spool mailbox). The opener gets neomutt options appended, and the
# account address in MAILSYNC_ACCOUNT.
openmail() {
MAILSYNC_ACCOUNT="$acct"
export MAILSYNC_ACCOUNT
# the account loop below runs with IFS set to a newline
IFS=" $(printf '\t\n_')" ; IFS="${IFS%_}"
set -f
accrc="${XDG_CONFIG_HOME:-$HOME/.config}/mutt/accounts/$acct.muttrc"
if [ -f "$accrc" ]; then
$opener -e "source \"$accrc\"" -f "$1"
else
$opener -f "$1"
fi ;}
# notify <title> <text> [folder]: a folder (or "!" for the spool mailbox)
# offers the open action.
notify() {
if [ -n "$canact" ] && [ -n "$3" ]; then
( act="$(sendnote --action="open=Open" -- "$1" "$2")"
[ "$act" = open ] && openmail "$3" ) >/dev/null 2>&1 </dev/null &
else
sendnote -- "$1" "$2"
fi ;}
;;
esac
# Is notification muted for account $1? MAILSYNC_MUTE unset or empty: no. A
# value without "@": everything is muted. Otherwise it is a space or comma
# separated list of accounts to mute.
muted() {
[ -n "$MAILSYNC_MUTE" ] || return 1
case "$MAILSYNC_MUTE" in *@*) ;; *) return 0 ;; esac
list="$(printf %s "$MAILSYNC_MUTE" | tr -s ', ' ' ')"
case " $list " in *" $1 "*) return 0 ;; esac
return 1
}
# Print the unfolded, decoded value of header $1 of message file $2.
header() {
awk -v h="$1" '
{ sub(/\r$/, "") }
/^$/ { exit }
/^[ \t]/ { if (on) { sub(/^[ \t]+/, " "); acc = acc $0 } ; next }
on { exit }
tolower(substr($0, 1, length(h) + 1)) == tolower(h) ":" {
on = 1; acc = substr($0, length(h) + 2); sub(/^[ \t]+/, "", acc)
}
END { if (on) print acc }' "$2" |
perl -CS -MEncode -ne 'print decode("MIME-Header", $_)'
}
# Is folder $1 (a path below the account's maildir, e.g. Lists/foo) one that
# MAILSYNC_FOLDERS asks to scan? INBOX always is. "all" means every folder
# except those with a Sent, Drafts, Trash, Junk, Spam or Archive style name,
# looked for case-insensitively in every path component whatever the server's
# delimiter ("/" or "."), so [Gmail]/Sent Mail and INBOX.Sent both drop out.
# Anything else is a list of folder names, in $folderlist.
wantfolder() {
low="$(printf %s "$1" | tr 'A-Z' 'a-z')"
[ "$low" = inbox ] && return 0
if [ "$MAILSYNC_FOLDERS" = all ]; then
for part in $(printf %s "$low" | tr './ ' '\n\n_'); do
case "$part" in
sent|sent_mail|sent_items|sent_messages|drafts|draft|trash|bin|deleted_items|deleted_messages|junk|junk_e-mail|junk_email|spam|bulk_mail|archive|archives|all_mail) return 1 ;;
esac
done
return 0
fi
printf '%s\n' "$folderlist" | grep -Fxq -e "$(printf %s "$low" | tr . /)"
}
# Print the files delivered since the last run in the folders to scan of
# account $1: INBOX only, unless MAILSYNC_FOLDERS widens it.
findnew() {
if [ -z "$MAILSYNC_FOLDERS" ]; then
find\
"$maildir/$1/"[Ii][Nn][Bb][Oo][Xx]/new/ \
"$maildir/$1/"[Ii][Nn][Bb][Oo][Xx]/cur/ \
-type f -newer "$lastrun" 2> /dev/null
return
fi
find "$maildir/$1" \( -name cur -o -name tmp \) -prune -o -type d -name new -print 2>/dev/null |
while IFS= read -r dir; do
dir="${dir%/new}"
[ -d "$dir/cur" ] || continue
wantfolder "${dir#"$maildir/$1/"}" || continue
find "$dir/new" "$dir/cur" -type f -newer "$lastrun" 2> /dev/null
done
}
# Does the sender of message file $1 match a MAILSYNC_MUTE_FROM pattern (shell
# globs in $mutepats, one per line, lowercase)? The address is the one in
# <angle brackets> of the From header, else the whole header, lowercased.
mutedsender() {
addr="$(header From "$1" | tr -d '\n')"
case "$addr" in *'<'*'>'*) addr="${addr##*<}" ; addr="${addr%%>*}" ;; esac
addr="$(printf %s "$addr" | tr 'A-Z' 'a-z' | tr -d ' \t')"
printf '%s\n' "$mutepats" | {
while IFS= read -r pat; do
# shellcheck disable=SC2254
case "$addr" in $pat) exit 0 ;; esac
done
exit 1 ;}
}
# Proton Bridge accounts. A Bridge account is an IMAPStore block of the mbsync
# config whose Host is 127.0.0.1, localhost or ::1 and whose Port is 1143; its
# channel is the store name without "-remote". One "address host" line each.
# Read with awk only; the file is never sourced.
bridgeaccts="$(awk '
function flush() { if (name != "" && host != "" && port == "1143") print name, host; name = ""; host = ""; port = "" }
{ sub(/\r$/, ""); line = $0; sub(/^[ \t]+/, "", line) }
line == "" { flush(); next }
line ~ /^#/ { next }
{
key = tolower(line); sub(/[ \t].*$/, "", key)
val = line; sub(/^[^ \t]+[ \t]+/, "", val); sub(/[ \t]+$/, "", val)
}
key == "imapstore" { flush(); name = val; sub(/-remote$/, "", name); next }
key == "maildirstore" || key == "channel" || key == "group" { flush(); next }
name != "" && key == "host" && (val == "127.0.0.1" || val == "localhost" || val == "::1") { host = val }
name != "" && key == "port" { port = val }
END { flush() }
' "$MBSYNCRC" 2>/dev/null)"
# This script's own directory, to find the pin helper when it is installed
# (PREFIX/bin/mailsync, PREFIX/lib/mutt-wizard/mutt-wizard-pin) or run from a
# checkout (bin/mailsync, lib/mutt-wizard-pin).
case "$0" in
*/*) bindir="${0%/*}" ;;
*) bindir="$(dirname "$(command -v "$0" 2>/dev/null)")" ;;
esac
havetimeout=
command -v timeout >/dev/null 2>&1 && havetimeout=1
# Run a command for at most 3 seconds when timeout(1) exists.
tmo3() {
if [ -n "$havetimeout" ]; then timeout 3 "$@"; else "$@"; fi
}
# Can a TCP connection to host $1 port $2 be made within 3 seconds? Status 0
# yes, 1 no, 2 cannot tell (no tool to ask with). Uses nc -z when there is an
# nc, else bash's /dev/tcp. With neither, the answer is "cannot tell" and the
# sync goes ahead, so nothing is skipped that might have worked.
tcpcheck() {
if command -v nc >/dev/null 2>&1; then
tmo3 nc -z -w 3 "$1" "$2" </dev/null >/dev/null 2>&1 && return 0
return 1
fi
if command -v bash >/dev/null 2>&1; then
# shellcheck disable=SC2016
tcperr="$(tmo3 bash -c 'exec 3<>"/dev/tcp/$0/$1"' "$1" "$2" 2>&1 </dev/null)" && return 0
# a bash built without /dev/tcp says so instead of "refused"
case "$tcperr" in *"No such file"*|*"not supported"*) return 2 ;; esac
return 1
fi
return 2
}
# The pin helper: $MW_PIN, else next to the installed or checked-out script,
# else $MW_LIB or the usual prefixes. Prints its path, or fails.
pinhelper() {
for cand in "$MW_PIN" "$bindir/../lib/mutt-wizard/mutt-wizard-pin" \
"$bindir/../lib/mutt-wizard-pin" "${MW_LIB:+$MW_LIB/mutt-wizard-pin}" \
/usr/local/lib/mutt-wizard/mutt-wizard-pin /usr/lib/mutt-wizard/mutt-wizard-pin; do
[ -n "$cand" ] && [ -f "$cand" ] && { printf '%s\n' "$cand"; return 0; }
done
return 1
}
# bridgenote <kind> <account> <title> <text>: send a notification about a
# Bridge problem, at most once per account and kind in 6 hours. The stamp file
# is ${XDG_STATE_HOME:-~/.local/state}/mutt-wizard/bridge-<kind>-<account>;
# deleting it re-arms the notification (mw repin does this for "cert").
bridgenote() {
stampdir="${XDG_STATE_HOME:-$HOME/.local/state}/mutt-wizard"
stamp="$stampdir/bridge-$1-$(printf %s "$2" | tr '/' '_')"
if [ -f "$stamp" ] && [ -n "$(find "$stamp" -mmin -360 2>/dev/null)" ]; then
return 0
fi
# the certificate warning is never muted: it is about trust, not new mail
[ "$1" = cert ] || ! muted "$2" || return 0
mkdir -p "$stampdir" 2>/dev/null && touch "$stamp" 2>/dev/null
notify "$3" "$4"
}
# Before syncing Bridge account $1: is Bridge up on host:port, and is it still
# presenting the pinned certificate? Status 0 to go ahead, 1 to skip the
# account (the reason is on stderr and, rate limited, in a notification).
# Other accounts are not affected. Not a Bridge account: always 0.
bridgecheck() {
bhost="$(printf '%s\n' "$bridgeaccts" | MS_A="$1" awk '$1 == ENVIRON["MS_A"] { print $2; exit }')"
[ -n "$bhost" ] || return 0
bport="${MAILSYNC_BRIDGE_PORT:-1143}"
bdown="Proton Bridge is not running ($bhost:$bport)."
if command -v systemctl >/dev/null 2>&1; then
bdown="$bdown Start it with: systemctl --user start protonmail-bridge"
else
bdown="$bdown Start Proton Bridge, then run mailsync again."
fi
tcpcheck "$bhost" "$bport"
if [ "$?" = 1 ]; then
echo "mailsync: skipping $1: $bdown" >&2
bridgenote down "$1" "Proton Bridge is not running" "$bdown"
return 1
fi
[ -f "${XDG_DATA_HOME:-$HOME/.local/share}/mutt-wizard/certs/$1.pem" ] || return 0
if ! bpin="$(pinhelper)"; then
echo "mailsync: cannot find mutt-wizard-pin, syncing $1 without checking its pinned certificate." >&2
return 0
fi
bout="$(MW_DISCOVER_TIMEOUT="${MW_DISCOVER_TIMEOUT:-3}" sh "$bpin" check "$1" "$bhost" "$bport" starttls-imap </dev/null 2>&1)"
case "$?" in
0) return 0 ;;
2)
echo "mailsync: skipping $1: $bdown" >&2
bridgenote down "$1" "Proton Bridge is not running" "$bdown"
return 1 ;;
3)
bchg="Proton Bridge certificate changed. If you reset or reinstalled Bridge, run: mw repin $1"
echo "mailsync: skipping $1: $bchg" >&2
printf '%s\n' "$bout" >&2
bridgenote cert "$1" "Proton Bridge certificate changed" "$bchg"
return 1 ;;
*)
echo "mailsync: the pin check for $1 failed ($bout), syncing anyway." >&2
return 0 ;;
esac
}
# Check account for new mail. Notify if there is new content. Mail is counted
# even when the sync reported an error (a warning about one bad folder must not
# hide what did arrive). Return 0 on a clean sync, 1 on a failed one, and 3 on
# a failed one that still delivered new mail.
syncandnotify() {
syncrc=0
acct="$2"
[ "$1" = imap ] && { bridgecheck "$2" || return 1 ;}
case "$1" in
imap) mbsync -q "$2" ;;
pop) mpop -q "$2" ;;
esac || { syncrc=$?; echo "mailsync: syncing $2 failed (exit status $syncrc)." >&2 ;}
# A list of folder names: separated by commas when there is a comma (so a
# name may hold spaces), else by blanks. Lowercase, "." read as "/".
folderlist=
case "$MAILSYNC_FOLDERS" in
''|all) ;;
*,*) folderlist="$(printf %s "$MAILSYNC_FOLDERS" | tr 'A-Z,' 'a-z\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | tr . /)" ;;
*) folderlist="$(printf %s "$MAILSYNC_FOLDERS" | tr 'A-Z \t' 'a-z\n\n' | tr . /)" ;;
esac
new=$(findnew "$2")
newcount=$(echo "$new" | sed '/^[[:space:]]*$/d' | wc -l)
# Mail from muted senders is counted above but never announced.
mutepats="$(printf %s "$MAILSYNC_MUTE_FROM" | tr 'A-Z, \t' 'a-z\n\n\n' | sed '/^$/d')"
toshow="$new"
if [ -n "$mutepats" ] && [ "$newcount" -gt 0 ]; then
toshow="$(printf '%s\n' "$new" | while IFS= read -r file; do
[ -n "$file" ] || continue
mutedsender "$file" || printf '%s\n' "$file"
done)"
fi
showcount=$(echo "$toshow" | sed '/^[[:space:]]*$/d' | wc -l)
mutednote=
[ "$showcount" -lt "$newcount" ] && mutednote=" ($((newcount - showcount)) from muted senders)"
if [ "$newcount" -gt 5 ]; then
echo "$newcount new mails for $2.${mutednote}"
elif [ "$newcount" -gt 0 ]; then
echo "$newcount new mail(s) for $2.${mutednote}"
else
echo "No new mail for $2."
fi
if [ "$showcount" -gt 5 ]; then
muted "$2" || notify "New Mail!" "📬 $showcount new mails in \`$2\` account." "!"
elif [ "$showcount" -gt 0 ]; then
muted "$2" ||
printf '%s\n' "$toshow" | while IFS= read -r file; do
[ -n "$file" ] || continue
# Extract and decode subject and sender from mail.
subject="$(header Subject "$file" | tr -d '\n\t')"
from="$(header From "$file" | tr -d '\n')"
from="${from% *}" ; from="${from%\"}" ; from="${from#\"}"
dir="${file%/*}"
notify "📧$from:" "$subject" "${dir%/*}"
done
fi
[ "$syncrc" = 0 ] && return 0
[ "$newcount" -gt 0 ] && return 3
return 1
}
allaccounts="$(grep -hs "^\(Channel\|account\)" "$MBSYNCRC" "$MPOPRC")"
# Get accounts to sync. All if no argument. Prefix with `error` if non-existent.
IFS='
'
if [ -z "$1" ]; then
tosync="$allaccounts"
else
tosync="$(for arg in "$@"; do for availacc in $allaccounts; do
[ "$arg" = "${availacc##* }" ] && echo "$availacc" && break
done || echo "error $arg"; done)"
fi
pids= # PIDs of backgrounded subshells
count=0 # number of accounts in $tosync
failed=0 # number of failed sync attempts
for account in $tosync; do
count=$((count+1))
case $account in
Channel*)
syncandnotify imap "${account##* }" &
pids="$pids $!"
;;
account*)
syncandnotify pop "${account##* }" &
pids="$pids $!"
;;
error*)
echo "ERROR: Account ${account##* } not found."
failed=$((failed+1))
;;
esac
done
IFS=' '
delivered=0 # a failed sync still brought in new mail
for pid in $pids; do
wait "$pid"
rc=$?
[ "$rc" = 0 ] || failed=$((failed+1))
[ "$rc" = 3 ] && delivered=1
done
# Abort if no account sync succeeded. Mail that a failed sync did deliver was
# notified already, so still index it and mark the run, or the next run would
# announce it again.
if [ "$count" -le "$failed" ]; then
if [ "$delivered" = 1 ]; then
notmuch new --quiet
touch "$lastrun"
fi
exit 1
fi
notmuch new --quiet
#Create a touch file that indicates the time of the last run of mailsync
touch "$lastrun"