aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-04 12:04:31 +0200
committerDanilo M. <danix@danix.xyz>2026-08-04 12:54:59 +0200
commit74d1da2755ff8e1a47bda676fab7b84e910ef738 (patch)
treee32bcf4a5d752c64255ae779666a504385434813
parent1f25f2b10d995dd0978d5846e175c9094b69f53f (diff)
downloadqtmaildir-74d1da2755ff8e1a47bda676fab7b84e910ef738.tar.gz
qtmaildir-74d1da2755ff8e1a47bda676fab7b84e910ef738.zip
feat(sync): bring mailsync.sh here, print to stdout, exit truthfully
The reference sync script lived in the companion mailctl project, which documents that it never calls it: sync and organization are separate there on purpose. qtmaildir is the only thing that runs it programmatically, and two of its behaviours were live bugs here, so it belongs in this repo where the constraints on it are. It printed nothing a caller could see. Everything was redirected to $LOGFILE, so MailSync captured an empty stream and the sync log pane stayed blank, a known limitation carried since 0.1.0. It now writes to both through tee: cron keeps its log, and the pane has something to show. It also ended in an unconditional exit 0, discarding statuses it had already computed. qtmaildir believes that exit code: a failed mbsync reported success, cleared the unsynced-changes count, and would have allowed a sync-on-exit to quit over a sync that never happened, which is exactly the case the exit prompt exists to prevent. It now exits with mbsync's status, or notmuch's when mbsync succeeded. The statuses move through files rather than shell variables because the run block is piped into tee, which puts it in a subshell where an assignment does not survive. Verified against stub mbsync and notmuch binaries in a fake HOME, so no mail or database was touched: output reaches stdout, the log still gets every line, and a failing mbsync or notmuch produces its own exit code rather than zero. Both properties are now recorded in CLAUDE.md, since they exist for this application's sake and an edit that looks like a cleanup would remove them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-rw-r--r--CHANGELOG.md18
-rw-r--r--CLAUDE.md10
-rw-r--r--README.md25
-rwxr-xr-xassets/mailsync.sh102
4 files changed, 154 insertions, 1 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e09e176..e50b2da 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,7 +11,23 @@ point at which they are stable.
## [Unreleased]
-Nothing yet.
+### Added
+
+- **`assets/mailsync.sh`**, the reference sync command, moved here from the
+ companion `mailctl` project. It never belonged there: `mailctl` does not call
+ it, while qtmaildir runs it as a subprocess and depends on how it behaves.
+ Symlink it into `~/bin` rather than copying, so one script serves both cron
+ and the application.
+
+### Fixed
+
+- **The sync log pane stayed empty**, listed as a known limitation since 0.1.0.
+ The reference `mailsync.sh` redirected all its output to a log file, so the
+ subprocess printed nothing for the pane to show. It now writes to both.
+- **A failed sync reported success.** That script ended in an unconditional
+ `exit 0`, so qtmaildir could not tell a clean sync from a broken one: it
+ cleared the unsynced-changes count either way, and would have quit on a
+ sync-on-exit that had not synced anything. It now exits with the real status.
## [0.7.0] - 2026-08-04
diff --git a/CLAUDE.md b/CLAUDE.md
index 694852f..9eb21a4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -75,6 +75,16 @@ combined `thread:a or thread:b` query rather than one query per thread.
The only escape hatch is `general/notmuch_config`, pointing at an alternate notmuch config.
Per-account subdirectories *are* configured, since notmuch does not model accounts at all.
+**The sync script lives here, in `assets/mailsync.sh`.** It moved from the
+companion `mailctl` project, which documents that it never calls it: the script
+is `mbsync` plus `notmuch new` with a lock, and qtmaildir is the only thing that
+runs it programmatically. Two properties exist for this application's sake and
+must survive any edit. It **prints to stdout as well as its log file**, because
+`MailSync` shows what the command prints and a self-redirecting script leaves
+the pane empty; and it **exits with the real status**, because a `0` from a
+failed sync makes qtmaildir report success, clear the unsynced-changes count,
+and quit on a sync that never happened.
+
**Every user-facing string is translatable.** Wrap UI text in `tr()`, including strings
that are only ever shown in passing: status bar messages, tooltips, dialog prose,
completion descriptions. Query syntax itself is not user-facing text — notmuch keywords
diff --git a/README.md b/README.md
index ec4c22a..a726a24 100644
--- a/README.md
+++ b/README.md
@@ -137,6 +137,7 @@ identity.
[sync]
; Optional. Omit and the Sync button disables itself with a tooltip.
+; assets/mailsync.sh is the reference implementation; see "The sync command".
; command = /home/you/bin/mailsync.sh
; Section names use a dot, not a slash: QSettings treats "/" as its own
@@ -269,6 +270,30 @@ set is valid.
Every change goes on the undo stack, so `Ctrl+Z` reverses a mistyped tag.
+## The sync command
+
+qtmaildir does not fetch mail. `[sync] command` names a script it runs as a
+subprocess, and `assets/mailsync.sh` is the reference implementation: `mbsync -a`
+followed by `notmuch new`, under a `flock` so a cron timer and a click here
+cannot run two `mbsync` processes over one Maildir.
+
+```bash
+ln -s "$PWD/assets/mailsync.sh" ~/bin/mailsync.sh
+```
+
+A symlink rather than a copy, so the same script serves cron and the running
+application and there is only one of it to edit.
+
+Two things any replacement has to get right, both learned the hard way:
+
+- **Print to stdout as well as any log file.** qtmaildir shows what the command
+ prints. A script that redirects its own output to a log leaves the sync pane
+ empty, which is what the previous version of this one did.
+- **Exit non-zero when the sync failed.** qtmaildir believes the exit status:
+ it reports success, clears the unsynced-changes count, and will quit on it
+ during a sync-on-exit. The previous version ended in an unconditional
+ `exit 0`, so a failed `mbsync` was indistinguishable from a clean run.
+
## Unsynced changes
Tagging changes the notmuch index at once, but the mail store only learns about
diff --git a/assets/mailsync.sh b/assets/mailsync.sh
new file mode 100755
index 0000000..ee918b6
--- /dev/null
+++ b/assets/mailsync.sh
@@ -0,0 +1,102 @@
+#!/bin/bash
+# mailsync.sh - fetch mail and reindex it.
+#
+# 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.
+#
+# Runs mbsync followed by notmuch new, under a lock so a cron timer and a
+# click in qtmaildir cannot run two mbsync processes over one Maildir.
+#
+# Two audiences, which is what shapes the output handling below: a cron or
+# systemd timer, which wants a log file it can read afterwards, and
+# qtmaildir, which runs this as a subprocess and shows what it prints.
+# Output therefore goes to BOTH, and the exit status is real.
+
+# Defensive: don't rely on cron/systemd/whatever invokes this to have
+# set these correctly. Explicit beats inferred, especially after the
+# HOME-not-set failure we hit once already. Fall back to the invoking
+# user's home from passwd rather than a hardcoded path.
+export HOME="${HOME:-$(getent passwd "$(id -u)" | cut -d: -f6)}"
+export GNUPGHOME="${GNUPGHOME:-$HOME/.gnupg}"
+
+LOCKFILE="/tmp/mbsync.lock"
+LOGFILE="$HOME/.local/state/mailsync.log"
+MAX_LOG_BYTES=$((10 * 1024 * 1024)) # rotate past 10MB, see note below
+
+mkdir -p "$(dirname "$LOGFILE")"
+
+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 ==="
+ echo "$msg" >> "$LOGFILE"
+ echo "$msg" >&2
+ exit 1
+fi
+
+# Simple rotation: if the log's gotten big, keep the last run's worth
+# and move the rest aside rather than letting it grow forever.
+if [ -f "$LOGFILE" ] && [ "$(stat -c%s "$LOGFILE" 2>/dev/null || echo 0)" -gt "$MAX_LOG_BYTES" ]; then
+ mv "$LOGFILE" "${LOGFILE}.1"
+fi
+
+# Statuses are written to files rather than shell variables because the
+# block below is piped into tee, which puts it in a subshell: a variable
+# assigned in there is gone by the time the parent reads it.
+STATUS_DIR="$(mktemp -d)"
+trap 'rm -rf "$STATUS_DIR"' EXIT
+
+START_TS="$(date -Iseconds)"
+{
+ echo "===== RUN START: $START_TS ====="
+
+ # Timestamp every line of mbsync/notmuch output as it streams,
+ # rather than only marking run boundaries, this is what actually
+ # lets you tell which errors are from which run at a glance.
+ mbsync -a 2>&1 | while IFS= read -r line; do
+ echo "$(date '+%H:%M:%S') $line"
+ done
+ echo "${PIPESTATUS[0]}" > "$STATUS_DIR/mbsync"
+
+ notmuch new 2>&1 | while IFS= read -r line; do
+ echo "$(date '+%H:%M:%S') $line"
+ done
+ echo "${PIPESTATUS[0]}" > "$STATUS_DIR/notmuch"
+
+ END_TS="$(date -Iseconds)"
+ MBSYNC_STATUS="$(cat "$STATUS_DIR/mbsync")"
+ NOTMUCH_STATUS="$(cat "$STATUS_DIR/notmuch")"
+ if [ "$MBSYNC_STATUS" -eq 0 ] && [ "$NOTMUCH_STATUS" -eq 0 ]; then
+ echo "===== RUN END: $END_TS status=OK ====="
+ else
+ echo "===== RUN END: $END_TS status=FAILED mbsync=$MBSYNC_STATUS notmuch=$NOTMUCH_STATUS ====="
+ fi
+# tee, not a plain redirect. Appending only to the log left every caller
+# that runs this as a subprocess with nothing to show: qtmaildir's sync
+# pane was empty for exactly this reason. Cron still gets its log.
+} 2>&1 | tee -a "$LOGFILE"
+
+MBSYNC_STATUS="$(cat "$STATUS_DIR/mbsync" 2>/dev/null || echo 1)"
+NOTMUCH_STATUS="$(cat "$STATUS_DIR/notmuch" 2>/dev/null || echo 1)"
+
+# 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
+# sync-on-exit prompt would report success over a sync that had not
+# happened, which is precisely the case that loses work.
+if [ "$MBSYNC_STATUS" -ne 0 ]; then
+ exit "$MBSYNC_STATUS"
+fi
+exit "$NOTMUCH_STATUS"