497 lines · 19.0 KB · Text
RawDownload
#!/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"