aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules/mail/README.md
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/modules/mail/README.md')
-rw-r--r--desktop/modules/mail/README.md254
1 files changed, 254 insertions, 0 deletions
diff --git a/desktop/modules/mail/README.md b/desktop/modules/mail/README.md
new file mode 100644
index 0000000..34eaa53
--- /dev/null
+++ b/desktop/modules/mail/README.md
@@ -0,0 +1,254 @@
+# mail-overview
+
+Unread mail across every account, as one waybar number and a drawer behind it.
+Clicking the icon opens the drawer; Escape or a click outside closes it.
+
+ ┌──────────────────────────────────────────────────┐
+ │ Mail 33 unread │
+ ├──────────────────────────────────────────────────┤
+ │ ● Account A 2 │
+ │ Some Sender Today 06:18 │
+ │ [a-list] a subject line that is elided... │
+ ├──────────────────────────────────────────────────┤
+ │ ● Account B 0 │
+ │ ● Account C 31 │
+ │ ...three newest threads... │
+ │ ● Account D 0 │
+ │ ● Account E 0 │
+ ├──────────────────────────────────────────────────┤
+ │ ● watcher ok · 25 folders │
+ ├──────────────────────────────────────────────────┤
+ │ [Open qtmaildir] │
+ └──────────────────────────────────────────────────┘
+
+## Running it
+
+ qs -p .
+
+It is started from `autostart.lua` and reached over IPC, so the shell has to be
+running for the waybar click to do anything:
+
+ qs -p ~/Programming/GIT/quickshell/mail-overview ipc call mail toggle
+
+Write that path out in full in the real config. Waybar's `exec` and `on-click`
+have no shell to expand `~`, and neither do Hyprland's Lua strings.
+
+## The accounts are not listed here
+
+`qtmaildir.conf` already has one `[account.<key>]` section per account, and
+`<key>` is exactly the suffix of the notmuch tag `account-<key>`, with a short
+`label` and a `color` alongside. The panel parses that file, in file order, so
+adding an account to qtmaildir makes it appear here with no edit to any QML.
+The file is watched, so that happens without a restart.
+
+The `color` is the account's own, from its config section. It marks the dot
+beside each row and nothing else: per-account identity is not a palette, and
+`Theme` still owns every other colour.
+
+**Parsing walks lines rather than matching one regex over the file.** The
+obvious pattern for a section body, everything up to the next `[`, is wrong
+here: several accounts have folders named like `[Gmail]/Bozze`, so the body
+ended at that bracket, before the `label` line, and three of five accounts
+quietly fell back to displaying their raw key. Nothing errored, because falling
+back is a legitimate path for an account with no label.
+
+## Counting
+
+Every count is `tag:unread and tag:inbox`. Plain `tag:unread` also counts
+archived-but-unread mail and mailing list traffic that was never in the inbox,
+which for one account here was the difference between 32 and 41; the
+inbox-scoped number is the one that means new mail worth looking at.
+
+**By the `account-*` tag, never by a `path:` glob.** qtmaildir itself scopes an
+account with `path:"<maildir>/**"`, and copying that query would be wrong for
+this panel. notmuch deduplicates by message id, so a message that arrived at
+two of the configured addresses is one message with two file paths: a glob
+counts it under both accounts, and the rows then sum to more than the total the
+waybar icon shows. Measured here, five accounts summed to 102 against a global
+total of 101. The tag is a property of the message, so it is singular and the
+rows always sum to the header. The cost is that such a message appears under
+only the account the `post-new` hook attributed it to, which is the right trade
+for an overview whose headline figure is a single number.
+
+## notmuch fails two ways, and only one is detectable
+
+A query notmuch rejects prints nothing and exits 1:
+
+ notmuch count 'tag:unread and ('
+
+A query Xapian merely misparses returns a plausible wrong number and exits 0:
+
+ notmuch count 'tag:unread and ((' # 41, exit 0
+ notmuch count 'tag:unread and tag:' # 3, exit 0
+
+So the exit status is not the test, and neither is any check that a wrong
+number could pass. Counts are validated as non-negative integers, which catches
+the first case, where empty output would otherwise render as an empty inbox.
+The second is not catchable; the defence against it is that the queries are
+fixed strings and are never built from anything.
+
+An unknown count shows a dash, never a zero, for the same reason the VM panel
+refuses to substitute libvirt's host-side figures: a number that is actually a
+failure reads as a fact.
+
+## The waybar module
+
+A `custom` module in **continuous mode**: the script never exits, prints one
+JSON line per database commit, and waybar redraws on each line. There is no
+`interval`, and no daemon to supervise, because waybar owns the process.
+
+ "custom/mail": {
+ "format": "<span font='18px'>󰇮</span> {}",
+ "return-type": "json",
+ "exec": "~/Programming/GIT/quickshell/mail-overview/waybar-mail.sh",
+ "on-click": "qs -p ~/Programming/GIT/quickshell/mail-overview ipc call mail toggle",
+ "on-click-right": "~/bin/mailsync.sh",
+ "tooltip": false
+ }
+
+The count drops the moment mail is read in qtmaildir and rises the moment
+mbsync commits, because both write the database and the watcher sees either.
+
+Two details in that loop are load-bearing. The watch is on the **xapian
+directory**, not on a file inside it: a commit replaces files, and a watch held
+on a filename dies with it. And a short sleep coalesces the several writes of
+one commit, without which the module redraws three or four times per sync with
+intermediate counts.
+
+The old setup this replaces ran three copies of a python script polling the
+Gmail API with stored credentials, covered three of the five accounts, and
+opened Thunderbird. Everything it fetched over the network was already in the
+local index.
+
+## Notifications on arrival
+
+`mail-notify.sh` sends one notification per account when mail lands, with the
+newest three threads' sender and subject and a `+N more` line when the batch is
+bigger. Each message is a bullet on its own line with a blank line between,
+under a bold `New Mail` heading with the account and count as the second line.
+Started from `autostart.lua`, it runs for the whole session.
+
+ /home/you/Programming/GIT/quickshell/mail-overview/mail-notify.sh
+
+It watches the same xapian directory the waybar module does, for the same
+reason and with the same 0.3s debounce. It is a **separate process rather
+than part of `waybar-mail.sh`**, which already has that edge: waybar owns
+that script's process, so a bar restart would stop notifications with nothing
+reporting it.
+
+**A commit is not the same as new mail.** Reading a message in qtmaildir drops
+its `unread` tag and commits; so does tagging. Arrival is found with notmuch's
+revision counter instead:
+
+ notmuch count --lastmod 'tag:unread and tag:inbox'
+
+which prints count, database UUID and revision, tab separated. Each account is
+then asked what it gained in `lastmod:<prev+1>..<cur>`, the lower bound one
+revision after the stored one so it is exclusive. A count delta was
+rejected because it cannot name senders, and a `date:` watermark because
+`date:` is the message's own Date header: backdated mail would never notify
+and future-dated mail would notify forever.
+
+Position is kept in `~/.local/state/mail-notify.lastmod`, written by atomic
+replace, holding the **UUID as well as the revision**. Revisions only compare
+within one database, so a rebuild restarts the counter and a stored revision
+from the old one means nothing.
+
+**Missing, corrupt or mismatched state seeds silently**, recording the current
+revision and notifying nothing. Without that floor, `lastmod:0..` matches every
+unread message ever: 101 of them here, which is a wall of popups at every
+login. The same applies to a restart, so mail that arrived while it was down is
+never notified. The waybar count is still right and the drawer still shows it,
+so nothing is lost but a stale popup.
+
+The notification is sent with dunstify. The icon is passed as an **absolute
+path** to the active icon theme's `mail-unread-multiple`, because this dunst
+resolves an icon *name* only through the `icon_path` in `dunstrc`, and that
+path holds no mail icon, so a name renders no icon at all. The click action
+(`-A default,open`) makes dunst prepend an `(A)` action indicator when
+`show_indicators` is on, so `dunstrc` sets `show_indicators = no`. A middle
+click (dunst's `do_action`) opens qtmaildir, but not the account it belongs
+to: `qtmaildir` takes no arguments and `startup_account` is a static setting
+rather than a flag, the same limitation the thread rows have.
+
+`test-mail-notify.sh` is the check. It sources the script as a library and
+asserts on the functions that only move text around, so it needs no mail and no
+notmuch:
+
+ ./test-mail-notify.sh
+
+## Geometry, and the one property that differs from the other panels
+
+The window is a fullscreen overlay with a dimmed backdrop and the drawer itself
+anchored to its top right, which is the idiom `vm-manager` and `appearance` both
+use and what gives click-outside-to-close and a focusable item for Escape.
+
+It differs from those two in one property: `exclusionMode` is `Normal`, not
+`Ignore`. Waybar claims an exclusive zone at the top of this screen, so
+respecting it places the overlay below the bar with no height constant here to
+drift. Measured: waybar at `y=-540 h=42`, this overlay at `y=-498 h=1038`,
+starting exactly where the bar ends. The backdrop therefore never dims waybar.
+
+**Key events reach a focused item, not a window.** Setting
+`WlrLayershell.keyboardFocus` is necessary but not sufficient, and
+`Keys.onEscapePressed` on a `PanelWindow` never fires, so the focus sits on an
+inner `Item`.
+
+## Thread rows are read-only
+
+Each account shows its three newest unread threads as author, relative date and
+subject. Clicking one launches qtmaildir and nothing more, because `qtmaildir`
+accepts no command line arguments: there is no way to tell it which thread to
+open. `startup_account` in its config is a static setting, not a flag, so
+"open on this account" is unavailable for the same reason. Marking read from
+here was rejected rather than deferred: it would write the database behind a
+possibly running client.
+
+"Open qtmaildir" launches the client. There is no "Sync now" button: the
+watcher triggers a sync the moment mail lands, the cron tick is the backstop,
+and `on-click-right` on the waybar module runs `~/bin/mailsync.sh` for a manual
+pull.
+
+## The watcher status dot
+
+Below the accounts, above the button, a coloured dot reports whether
+`mail-watcher` is alive and sane, read from its heartbeat at
+`~/.local/state/mail-watcher.heartbeat`:
+
+- **green** (`watcher ok · N folders`) heartbeat fresh, no dead threads.
+- **yellow** (`N folder(s) dead, check the log`) heartbeat fresh, but a folder
+ gave up permanently.
+- **red** (`watcher not running`) heartbeat missing, unparseable, or older than
+ 300s.
+
+Backoff never turns the dot: it is normal recovery from a dropped IDLE
+connection, and the watcher's own health check treats it as healthy. The
+staleness rule is the same one `mail-watcher` uses (`heartbeat_is_healthy`),
+reimplemented here in a few lines rather than shelling out to
+`mail-watcher.py --status` on every open.
+
+The heartbeat is read once per open. The drawer is a `LazyLoader`, so closing
+and reopening rebuilds the `FileView` and reads the file current. A file watch
+is deliberately not used: the heartbeat is written by atomic replace (tmpfile
+then rename), so an inotify watch held on the old inode dies with it, which is
+the same trap as watching a file inside the Xapian directory. The cost is that
+a drawer left open does not update until reopened, which for 60s heartbeat data
+is not worth a timer.
+
+## Theme and blur
+
+`Theme.qml` is the shared one: the palette comes from
+`~/.cache/wal/udt-palette.qml` and is watched. It is a fourth identical copy,
+which is a known loose end, not a palette to grow.
+
+Frosting is Hyprland's, matched on this window's namespace:
+
+ hl.layer_rule({
+ name = "blur-mail",
+ match = { namespace = "^(quickshell-mail)$" },
+ blur = true,
+ xray = false,
+ ignore_alpha = 0.1,
+ })
+
+Without the rule it still works, rendering flat translucent.