aboutsummaryrefslogtreecommitdiffstats
path: root/assets
diff options
context:
space:
mode:
Diffstat (limited to 'assets')
-rwxr-xr-xassets/mailsync.sh114
-rwxr-xr-xassets/test_mailsync.py170
2 files changed, 271 insertions, 13 deletions
diff --git a/assets/mailsync.sh b/assets/mailsync.sh
index ea81fa5..abc56c7 100755
--- a/assets/mailsync.sh
+++ b/assets/mailsync.sh
@@ -43,10 +43,63 @@
export HOME="${HOME:-$(getent passwd "$(id -u)" | cut -d: -f6)}"
export GNUPGHOME="${GNUPGHOME:-$HOME/.gnupg}"
-LOCKFILE="/tmp/mbsync.lock"
+# Overridable for the test suite ONLY. A test must never take the real lock:
+# that is the mutex the user's cron sync uses, so a test run holding it would
+# block their mail. Production passes nothing and gets the real path.
+LOCKFILE="${MAILSYNC_LOCKFILE:-/tmp/mbsync.lock}"
LOGFILE="$HOME/.local/state/mailsync.log"
+# What qtmaildir READS, as against the log, which is for a human (item 174).
+# The application used to infer a finished run from the log's RUN END banner
+# and from this lock's inode in /proc/locks. That made a human-readable line
+# into wire format, said nothing about WHICH channels a run carried, and left a
+# skipped run reporting nothing at all (item 125). This file is the interface;
+# the log stays the log.
+STATUSFILE="$HOME/.local/state/qtmaildir/syncstatus.json"
+
mkdir -p "$(dirname "$LOGFILE")"
+mkdir -p "$(dirname "$STATUSFILE")"
+
+# Written atomically, because qtmaildir watches this file and a reader must
+# never see a half-written one: mv within a directory is atomic, a redirect
+# into the final path is not.
+#
+# Failure to write it is deliberately NOT fatal. The status file is a
+# convenience for the application; the sync itself has already happened, and
+# taking the run down over a report of it would turn a cosmetic problem into a
+# mail problem.
+write_status() {
+ local state="$1" mbsync_status="$2" notmuch_status="$3"
+ local started="$4" ended="$5"
+ shift 5
+
+ local channels="" sep="" channel
+ for channel in "$@"; do
+ # The two characters JSON requires escaping in a string. A channel name
+ # comes from ~/.mbsyncrc and is an identifier rather than free text, so
+ # this is belt and braces rather than a real expectation.
+ channel="${channel//\\/\\\\}"
+ channel="${channel//\"/\\\"}"
+ channels="${channels}${sep}\"${channel}\""
+ sep=", "
+ done
+
+ local tmp
+ tmp="$(mktemp "${STATUSFILE}.XXXXXX")" || return 0
+ cat > "$tmp" <<JSON
+{
+ "version": 1,
+ "run_id": "$started",
+ "started": "$started",
+ "ended": "$ended",
+ "state": "$state",
+ "channels": [$channels],
+ "mbsync_status": $mbsync_status,
+ "notmuch_status": $notmuch_status
+}
+JSON
+ mv -f "$tmp" "$STATUSFILE" 2>/dev/null || rm -f "$tmp"
+}
# Rotation is NOT this script's job: /etc/logrotate.d/mailsync owns this file,
# keeping seven compressed days. An earlier version also rotated by size here,
@@ -54,12 +107,31 @@ mkdir -p "$(dirname "$LOGFILE")"
# logrotate had just put at .1, losing a day of history and leaving an
# uncompressed file where a compressed one belonged.
+# Resolved HERE, before the lock and before the pipeline below, for two
+# reasons. The block below is piped into tee and so runs in a subshell, where
+# a "set --" cannot be seen by the parent that writes the status file; and the
+# skip branch needs the list too, to report what the run WOULD have carried.
+#
+# -a is NOT equivalent to naming every channel: with no arguments at all mbsync
+# syncs nothing and exits, which would look like a clean sync that moved no
+# mail. It is reported to qtmaildir as "-a" rather than expanded, since only
+# ~/.mbsyncrc knows what every channel is, and the application reads "-a" as
+# "every account".
+[ "$#" -gt 0 ] || set -- -a
+CHANNELS=("$@")
+
exec 200>"$LOCKFILE"
if ! flock -n 200; then
# Both streams again: a caller that skipped because the cron run holds
# the lock needs to be told, not left with silence and an error code.
- msg="$(date -Iseconds) === SKIPPED: previous run still in progress ==="
+ SKIP_TS="$(date -Iseconds)"
+ msg="$SKIP_TS === SKIPPED: previous run still in progress ==="
echo "$msg" | tee -a "$LOGFILE" >&2
+ # Item 125. A skip used to report NOTHING a watcher could see: it releases
+ # a lock it never took, so qtmaildir's spinner waited for a completion that
+ # never came. A skipped run is a terminal state and says so, with -1 for
+ # both statuses since neither program ran.
+ write_status "skipped" -1 -1 "$SKIP_TS" "$SKIP_TS" "${CHANNELS[@]}"
# 75 (EX_TEMPFAIL), not 1. A skip is not a failure: the other run is
# doing the work. qtmaildir reports 1 as "sync failed" and shows its log
# pane, which is wrong for a click that landed during the cron run, and
@@ -88,17 +160,12 @@ START_TS="$(date -Iseconds)"
# which is both the progress and the account name the status bar shows.
# This is not a buffering problem and stdbuf does not help: the output
# streams fine, there simply is none to stream.
- # "$@" when channels were named, -a otherwise. Quoted and passed as
- # separate words, never flattened into a string: a channel name is an
- # argument, and mbsync takes an unknown one as a fatal error rather than
- # skipping it, which would fail the whole run.
- #
- # -a is NOT equivalent to naming every channel and cannot be dropped: with
- # no arguments at all mbsync syncs nothing and exits, which would look like
- # a clean sync that moved no mail.
- [ "$#" -gt 0 ] || set -- -a
-
- mbsync -V "$@" 2>&1 | while IFS= read -r line; do
+ # "$@" when channels were named, -a otherwise, resolved into CHANNELS
+ # before the lock above. Quoted and passed as separate words, never
+ # flattened into a string: a channel name is an argument, and mbsync takes
+ # an unknown one as a fatal error rather than skipping it, which would fail
+ # the whole run.
+ mbsync -V "${CHANNELS[@]}" 2>&1 | while IFS= read -r line; do
echo "$(date '+%H:%M:%S') $line"
done
echo "${PIPESTATUS[0]}" > "$STATUS_DIR/mbsync"
@@ -109,6 +176,9 @@ START_TS="$(date -Iseconds)"
echo "${PIPESTATUS[0]}" > "$STATUS_DIR/notmuch"
END_TS="$(date -Iseconds)"
+ # Also to a file, for the same subshell reason the statuses are: the parent
+ # writes the status file and cannot see a variable assigned in here.
+ echo "$END_TS" > "$STATUS_DIR/end"
MBSYNC_STATUS="$(cat "$STATUS_DIR/mbsync")"
NOTMUCH_STATUS="$(cat "$STATUS_DIR/notmuch")"
if [ "$MBSYNC_STATUS" -eq 0 ] && [ "$NOTMUCH_STATUS" -eq 0 ]; then
@@ -123,6 +193,24 @@ START_TS="$(date -Iseconds)"
MBSYNC_STATUS="$(cat "$STATUS_DIR/mbsync" 2>/dev/null || echo 1)"
NOTMUCH_STATUS="$(cat "$STATUS_DIR/notmuch" 2>/dev/null || echo 1)"
+END_TS="$(cat "$STATUS_DIR/end" 2>/dev/null || date -Iseconds)"
+
+# Item 174. What qtmaildir reads, written before the exit below so it exists
+# whichever way this run went. "ok" only when BOTH programs succeeded, matching
+# the log banner and the exit status: a caller must not be able to read success
+# here and failure there.
+#
+# The two statuses are reported separately as well as folded into the state,
+# because they mean different things to the application: a failed mbsync means
+# the edits never reached the server, while a failed notmuch means they did and
+# only the local index is behind.
+if [ "$MBSYNC_STATUS" -eq 0 ] && [ "$NOTMUCH_STATUS" -eq 0 ]; then
+ write_status "ok" "$MBSYNC_STATUS" "$NOTMUCH_STATUS" \
+ "$START_TS" "$END_TS" "${CHANNELS[@]}"
+else
+ write_status "failed" "$MBSYNC_STATUS" "$NOTMUCH_STATUS" \
+ "$START_TS" "$END_TS" "${CHANNELS[@]}"
+fi
# Report the real outcome. The old unconditional "exit 0" meant a caller
# could not distinguish a clean sync from a failed one, so qtmaildir's
diff --git a/assets/test_mailsync.py b/assets/test_mailsync.py
new file mode 100755
index 0000000..a0bf7ae
--- /dev/null
+++ b/assets/test_mailsync.py
@@ -0,0 +1,170 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0-only
+#
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+"""Checks for the status file mailsync.sh writes (item 174).
+
+Nothing here touches real mail or the network: mbsync and notmuch are stubs on
+PATH, HOME points at a temp directory, and the lock file is redirected there
+too, so a run of this suite cannot collide with the user's cron sync.
+
+The properties worth proving are the ones qtmaildir depends on and a reader of
+the script cannot confirm:
+
+ - a status file is written on success, on failure, and on a SKIP, which is
+ the case item 125 is open for
+ - it names the channels the run actually synced, which is what lets the
+ application clear its pending count per account instead of wholesale
+ - "-a" is reported as such, since a full run carries every account and a
+ reader that treats it as a channel name clears nothing
+ - the file is valid JSON at every moment a watcher could read it
+
+Run: ./test_mailsync.py
+"""
+
+import json
+import os
+import stat
+import subprocess
+import tempfile
+from pathlib import Path
+
+SCRIPT = Path(__file__).resolve().parent / "mailsync.sh"
+
+failures = []
+
+
+def check(name, condition, detail=""):
+ if condition:
+ print(f"ok {name}")
+ else:
+ print(f"FAIL {name}{(': ' + detail) if detail else ''}")
+ failures.append(name)
+
+
+def write_stub(path, exit_code, output="", sleep_for=0):
+ """A stub standing in for mbsync or notmuch."""
+ body = "#!/bin/bash\n"
+ if output:
+ body += f"echo {output!r}\n"
+ if sleep_for:
+ body += f"sleep {sleep_for}\n"
+ body += f"exit {exit_code}\n"
+ path.write_text(body)
+ path.chmod(path.stat().st_mode | stat.S_IEXEC)
+
+
+def run(tmp, args=(), mbsync_exit=0, notmuch_exit=0, lockfile=None,
+ hold_lock=False):
+ """Runs the script with stubbed binaries, returns (exit code, status dict)."""
+ bindir = tmp / "bin"
+ bindir.mkdir(exist_ok=True)
+ write_stub(bindir / "mbsync", mbsync_exit, "Channel work")
+ write_stub(bindir / "notmuch", notmuch_exit, "No new mail.")
+
+ env = dict(os.environ)
+ env["HOME"] = str(tmp)
+ env["PATH"] = f"{bindir}:{env['PATH']}"
+ # Never the real /tmp/mbsync.lock: a test must not join the mutex the
+ # user's cron sync uses, or a run of this suite blocks their mail.
+ env["MAILSYNC_LOCKFILE"] = str(lockfile or (tmp / "lock"))
+
+ proc = subprocess.run([str(SCRIPT), *args], env=env, capture_output=True,
+ text=True, timeout=60)
+
+ status_path = tmp / ".local/state/qtmaildir/syncstatus.json"
+ status = None
+ if status_path.exists():
+ status = json.loads(status_path.read_text())
+ return proc.returncode, status
+
+
+def main():
+ with tempfile.TemporaryDirectory() as raw:
+ tmp = Path(raw)
+
+ # --- a successful full run
+ code, st = run(tmp)
+ check("a clean run exits 0", code == 0, f"exit {code}")
+ check("a clean run writes a status file", st is not None)
+ if st:
+ check("state is ok", st.get("state") == "ok", str(st.get("state")))
+ check("mbsync status recorded", st.get("mbsync_status") == 0)
+ check("notmuch status recorded", st.get("notmuch_status") == 0)
+ check("a full run reports -a", st.get("channels") == ["-a"],
+ str(st.get("channels")))
+ check("version is 1", st.get("version") == 1)
+ check("carries a start and an end", bool(st.get("started"))
+ and bool(st.get("ended")))
+
+ # --- named channels are reported, which is the whole point for the
+ # application: it clears its pending count for these accounts only.
+ code, st = run(tmp, args=("work", "personal"))
+ check("named channels exit 0", code == 0)
+ if st:
+ check("named channels are listed",
+ st.get("channels") == ["work", "personal"],
+ str(st.get("channels")))
+
+ # --- a failing mbsync
+ code, st = run(tmp, mbsync_exit=1)
+ check("a failed mbsync exits nonzero", code != 0, f"exit {code}")
+ if st:
+ check("state is failed", st.get("state") == "failed",
+ str(st.get("state")))
+ check("the failing status is recorded",
+ st.get("mbsync_status") == 1)
+
+ # --- a failing notmuch, with mbsync fine. Distinguished because the
+ # mail DID reach the server: only the index is behind.
+ code, st = run(tmp, notmuch_exit=1)
+ check("a failed notmuch exits nonzero", code != 0)
+ if st:
+ check("a notmuch failure is still failed",
+ st.get("state") == "failed")
+ check("mbsync is recorded as fine", st.get("mbsync_status") == 0)
+
+ # --- a SKIP, which is item 125: the run exits 75 holding no lock, and
+ # before this the application had nothing to clear its spinner on.
+ lock = tmp / "held.lock"
+ lock.touch()
+ with open(lock, "w") as handle:
+ holder = subprocess.Popen(
+ ["flock", "-x", str(lock), "sleep", "10"])
+ try:
+ # Give flock a moment to actually take it.
+ subprocess.run(["sleep", "0.3"])
+ code, st = run(tmp, lockfile=lock)
+ finally:
+ holder.terminate()
+ holder.wait()
+
+ check("a skipped run exits 75", code == 75, f"exit {code}")
+ check("a skipped run still writes a status file", st is not None)
+ if st:
+ check("state is skipped", st.get("state") == "skipped",
+ str(st.get("state")))
+
+ print()
+ if failures:
+ print(f"{len(failures)} FAILED: {', '.join(failures)}")
+ return 1
+ print("all checks passed")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())