aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules/mail
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/modules/mail')
-rw-r--r--desktop/modules/mail/Accounts.qml19
-rw-r--r--desktop/modules/mail/MailModule.qml2
-rw-r--r--desktop/modules/mail/MailPage.qml17
-rw-r--r--desktop/modules/mail/README.md38
-rwxr-xr-xdesktop/modules/mail/mail-notify.sh73
-rwxr-xr-xdesktop/modules/mail/mail-open.sh46
-rwxr-xr-xdesktop/modules/mail/test-mail-notify.sh13
7 files changed, 153 insertions, 55 deletions
diff --git a/desktop/modules/mail/Accounts.qml b/desktop/modules/mail/Accounts.qml
index c2bc9d8..4f8b114 100644
--- a/desktop/modules/mail/Accounts.qml
+++ b/desktop/modules/mail/Accounts.qml
@@ -35,7 +35,7 @@ Singleton {
// it is singular and the rows always sum to the header.
readonly property string scope: "tag:unread and tag:inbox"
- // [{ key, label, color, count, threads: [{ authors, date, subject }] }]
+ // [{ key, label, color, count, threads: [{ thread, authors, date, subject }] }]
// count is -1 until known, and stays -1 on failure: a zero there would
// read as an empty inbox.
property var accounts: []
@@ -170,6 +170,7 @@ Singleton {
const rows = JSON.parse(parts[1]);
if (Array.isArray(rows)) {
acct.threads = rows.map(r => ({
+ thread: String(r.thread ?? ""),
authors: String(r.authors ?? ""),
date: String(r.date_relative ?? ""),
subject: String(r.subject ?? "(no subject)"),
@@ -206,12 +207,16 @@ Singleton {
Timer { id: debounce; interval: 300; onTriggered: root.refresh() }
Timer { id: respawn; interval: 5000; onTriggered: watcher.running = true }
- // Launching qtmaildir. It takes no arguments, so there is nothing to tell
- // it about the account or thread clicked.
- Process { id: openProc; command: [`${Quickshell.env("HOME")}/bin/qtmaildir`] }
-
+ // Launching qtmaildir, detached: a running client takes the selectors
+ // over its socket and the launch exits, but a first launch is the client
+ // itself and must not belong to a Process that the next click restarts.
function openClient() {
- openProc.running = false;
- openProc.running = true;
+ Quickshell.execDetached([`${Quickshell.env("HOME")}/bin/qtmaildir`]);
+ }
+
+ // An account, or a thread's newest unread message in it. mail-open.sh is
+ // shared with the notifications, so both open mail the same way.
+ function open(key, thread) {
+ Quickshell.execDetached([`${Quickshell.shellDir}/modules/mail/mail-open.sh`, key, thread || ""]);
}
}
diff --git a/desktop/modules/mail/MailModule.qml b/desktop/modules/mail/MailModule.qml
index 5f501ba..0c71b2c 100644
--- a/desktop/modules/mail/MailModule.qml
+++ b/desktop/modules/mail/MailModule.qml
@@ -40,7 +40,7 @@ Module {
Component.onCompleted: Accounts.refresh()
MailPage {
width: parent.width
- onClose: mailPage.back()
+ onClose: mailPage.dismiss()
}
}
}
diff --git a/desktop/modules/mail/MailPage.qml b/desktop/modules/mail/MailPage.qml
index 85b0876..1b41412 100644
--- a/desktop/modules/mail/MailPage.qml
+++ b/desktop/modules/mail/MailPage.qml
@@ -97,6 +97,7 @@ Column {
model: Accounts.accounts
Column {
+ id: account
required property var modelData
width: page.width
spacing: 4
@@ -105,6 +106,12 @@ Column {
width: parent.width
implicitHeight: 26
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: { Accounts.open(modelData.key); page.close(); }
+ }
+
Rectangle {
id: dot
anchors.verticalCenter: parent.verticalCenter
@@ -132,12 +139,13 @@ Column {
}
}
- // The newest three unread threads. Read-only: qtmaildir takes no
- // arguments, so there is no way to ask it for a particular thread.
+ // The newest three unread threads. A click opens the thread's
+ // newest unread message, in this account.
Repeater {
model: modelData.threads
Column {
+ id: row
required property var modelData
width: page.width - 18
x: 18
@@ -177,6 +185,11 @@ Column {
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize - 2
}
+
+ // Handlers, not a MouseArea: a Column would lay one out
+ // as a row of its own rather than let it fill.
+ HoverHandler { cursorShape: Qt.PointingHandCursor }
+ TapHandler { onTapped: { Accounts.open(account.modelData.key, row.modelData.thread); page.close(); } }
}
}
}
diff --git a/desktop/modules/mail/README.md b/desktop/modules/mail/README.md
index 2349da6..5083c34 100644
--- a/desktop/modules/mail/README.md
+++ b/desktop/modules/mail/README.md
@@ -156,15 +156,19 @@ login. The same applies to a restart, so mail that arrived while it was down is
never notified. The drawer count is still right and 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.
+The notification is sent with `notify-send` (libnotify 0.8), whose `-A
+NAME=Text` prints the picked action's name; dunstify is not installed. The
+icon is passed as an **absolute path** to the active icon theme's
+`mail-unread-multiple`, so it does not depend on how the daemon resolves icon
+names.
+
+Each listed thread is an action, keyed by its thread id, and the account is
+the `default` one. A click on the balloon offers them in the notifications'
+picker. The picked one runs `mail-open.sh`: the account opens qtmaildir on that
+account, a thread on its newest unread message. Thread ids rather than
+Message-IDs are the keys because they are hex, so a key never carries the `=`
+that separates it from its label; the message is looked up when the action is
+picked, and if it was read in the meantime the whole thread opens instead.
`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
@@ -191,15 +195,15 @@ respected.
`Keys.onEscapePressed` on a `PanelWindow` never fires, so the focus sits on an
inner `Item`.
-## Thread rows are read-only
+## Opening mail from the page
-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.
+A click on an account's header opens qtmaildir on that account, and a click on
+a thread row opens that thread's newest unread message, through the same
+`mail-open.sh` the notifications use. qtmaildir hands a second launch's
+selectors to the running window, so neither opens a second client. Launches
+are detached: a first launch *is* the client, and a reused `Process` would kill
+it on the next click. 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,
diff --git a/desktop/modules/mail/mail-notify.sh b/desktop/modules/mail/mail-notify.sh
index 968799c..0052a8d 100755
--- a/desktop/modules/mail/mail-notify.sh
+++ b/desktop/modules/mail/mail-notify.sh
@@ -32,6 +32,7 @@ set -u
STATE="${MAIL_NOTIFY_STATE:-$HOME/.local/state/mail-notify.lastmod}"
CONFIG="${MAIL_NOTIFY_CONFIG:-$HOME/.config/qtmaildir/qtmaildir.conf}"
SCOPE='tag:unread and tag:inbox'
+OPEN="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/mail-open.sh"
# How many threads a notification body lists before eliding into "+N more".
# Three matches the drawer's own --limit=3.
@@ -119,6 +120,25 @@ build_body() {
printf '\n'
}
+# One action per listed thread, "<thread-id>=<label>" per line,
+# followed by the account itself as the default action. The balloon shell
+# offers them in a picker on click.
+#
+# The key is the thread id, not a Message-ID: thread ids are hex, so a key
+# never carries an "=" or a quote, and mail-open.sh finds the message when
+# the action is picked. The label is plain text for rofi, so no escaping, but
+# it is flattened to one line and shortened, since it is a menu entry.
+build_actions() {
+ local json="$1" label="$2"
+
+ printf '%s' "$json" | jq -r '
+ .[] | select(.thread | test("^[0-9a-f]+$"))
+ | .thread + "=" + ((.authors // "(unknown)") + " — " + (.subject // "(no subject)")
+ | gsub("[\r\n]+"; " ") | .[0:80])
+ ' 2>/dev/null
+ printf 'default=Open %s\n' "$label"
+}
+
# The last revision this script notified up to, or empty when there is none
# to trust. Empty means "seed silently": record where we are now and notify
# nothing.
@@ -158,41 +178,38 @@ write_state() {
# Sends one notification for one account.
#
-# dunstify rather than notify-send because actions need it. A stack tag per
-# account means a second batch for the same account replaces the first rather
-# than stacking, which is what "one notification per account" has to mean when
-# mail keeps arriving.
+# notify-send, whose -A takes NAME=Text and prints the picked NAME, which is
+# all the actions need; dunstify is not installed here. NAME is split on the
+# first "=", so a label may contain one. A stack tag per account means a
+# second batch for the same account replaces the first rather than stacking,
+# which is what "one notification per account" has to mean when mail keeps
+# arriving.
#
# Normal urgency and an explicit 10s timeout, deliberately not -u critical:
-# on most dunst configurations critical notifications never expire, which
-# would leave mail popups on screen until clicked.
+# critical notifications commonly never expire, which would leave mail popups
+# on screen until clicked.
#
-# The click cannot open the account it belongs to. qtmaildir accepts no
-# command line arguments and startup_account is a static config setting, not
-# a flag, which is the same limitation the drawer's thread rows already have.
+# The picked action opens qtmaildir through mail-open.sh: a thread's key opens
+# its newest unread message, "default" the account.
notify_account() {
- local label="$1" key="$2" count="$3" body="$4"
-
- # -a carries "New Mail" because the user's dunst format renders %a as the
- # bold heading line, with %s italic below it.
- if ! command -v dunstify >/dev/null 2>&1; then
- # No actions available, but a notification without a click is still
- # worth having.
- notify-send -a "New Mail" -u normal -t 10000 -i "$MAIL_ICON" \
- "$label ($count)" "$body"
- return 0
- fi
+ local label="$1" key="$2" count="$3" body="$4" rows="$5"
+ local acts=() line picked
+
+ while IFS= read -r line; do acts+=(-A "$line"); done < <(build_actions "$rows" "$label")
- # Backgrounded because -b blocks until the notification is dismissed or
- # clicked. Without this the loop would stall for the full timeout on
+ # Backgrounded because -A waits until the notification is dismissed or
+ # acted on. Without this the loop would stall for the full timeout on
# every account, and a five-account batch would take most of a minute.
+ # -a carries "New Mail" because it renders as the bold heading line.
(
- if [[ "$(dunstify -a "New Mail" -i "$MAIL_ICON" -u normal -t 10000 -b \
+ picked="$(notify-send -a "New Mail" -i "$MAIL_ICON" -u normal -t 10000 \
-h "string:x-dunst-stack-tag:mail-$key" \
- -A "default,open" \
- "$label ($count)" "$body")" == "default" ]]; then
- "$HOME/bin/qtmaildir" &
- fi
+ "${acts[@]}" \
+ "$label ($count)" "$body")"
+ case "$picked" in
+ default) "$OPEN" "$key" & ;;
+ [0-9a-f]*) "$OPEN" "$key" "$picked" & ;;
+ esac
) >/dev/null 2>&1 &
}
@@ -243,7 +260,7 @@ tick() {
rows="$(notmuch search --format=json --limit="$ROWS" \
--sort=newest-first "$query" 2>/dev/null)" || rows="[]"
- notify_account "$label" "$key" "$count" "$(build_body "$rows" "$count")"
+ notify_account "$label" "$key" "$count" "$(build_body "$rows" "$count")" "$rows"
done < <(parse_accounts < "$CONFIG")
# Written only after every account is done. A single account whose count
diff --git a/desktop/modules/mail/mail-open.sh b/desktop/modules/mail/mail-open.sh
new file mode 100755
index 0000000..90877bd
--- /dev/null
+++ b/desktop/modules/mail/mail-open.sh
@@ -0,0 +1,46 @@
+#!/bin/bash
+#
+# 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.
+#
+# Opens qtmaildir on an account, or on a thread's newest unread message.
+# Shared by the drawer's mail page and mail-notify.sh, so both open mail the
+# same way.
+#
+# mail-open.sh <account-key> [thread-id]
+#
+# A running qtmaildir takes the selectors over its socket and raises, so this
+# never opens a second window.
+#
+# The message is looked up at click time rather than when the row or the
+# notification was built: by then it may have been read, and an unread-only
+# lookup that finds nothing falls back to the whole thread instead of a miss.
+# Newest, because that is the message the row's date and the notification
+# are about.
+
+set -u
+
+key="${1:?usage: mail-open.sh <account-key> [thread-id]}"
+tid="${2:-}"
+args=(--account "$key")
+
+# notmuch thread ids are hex; anything else never reaches a query.
+if [[ "$tid" =~ ^[0-9a-f]+$ ]]; then
+ id="$(notmuch search --format=json --output=messages --sort=newest-first --limit=1 \
+ "thread:$tid and tag:unread and tag:inbox" 2>/dev/null | jq -r '.[0] // empty' 2>/dev/null)"
+ if [[ -n "$id" ]]; then
+ args+=(--message "$id")
+ else
+ args+=(--thread "$tid")
+ fi
+fi
+
+exec "$HOME/bin/qtmaildir" "${args[@]}"
diff --git a/desktop/modules/mail/test-mail-notify.sh b/desktop/modules/mail/test-mail-notify.sh
index 1c3e01a..4ca5d00 100755
--- a/desktop/modules/mail/test-mail-notify.sh
+++ b/desktop/modules/mail/test-mail-notify.sh
@@ -119,6 +119,19 @@ check "a newline in a subject stays on one row" \
"• Alice Example — line one line two" \
"$(build_body '[{"authors":"Alice Example","subject":"line one\nline two"}]' 1)"
+check "one action per thread, then the account as default" \
+ "00a1=Alice Example — First subject
+default=Open Work" \
+ "$(build_actions '[{"thread":"00a1","authors":"Alice Example","subject":"First subject"}]' Work)"
+
+check "a thread id that is not hex gets no action" \
+ "default=Open Work" \
+ "$(build_actions '[{"thread":"x=y","authors":"A","subject":"B"}]' Work)"
+
+check "malformed json still leaves the account action" \
+ "default=Open Work" \
+ "$(build_actions 'not json' Work)"
+
# A scratch state file, removed at exit. Never the real one: that is the
# user's live notification position, and a test must not move it.
tmpstate="$(mktemp)"