#!/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" "$@" &1 [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 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 # 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 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 <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"