1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
|
# Launching qtmaildir at an account, thread or message
Date: 2026-09-13
Status: approved, not yet implemented
Backlog: item 200
## Problem
`qtmaildir` accepts no arguments beyond `--version` and `--help`. Another
program that knows which message it cares about, a notification, a script, a
sidecar like item 194's, has no way to say so: it can launch the client and
that is all.
The user's note asks for `--account`, `--thread` and `--message` "so that
another app can launch qtmaildir opening that account's inbox or a certain
message/thread".
## Verified context
Established by reading the code on 2026-09-13, not assumed.
- **Argument handling is a `strcmp` loop.** `main.cpp:38-66` walks `argv` for
`--version`/`-v` and `--help`/`-h`. Both answer and `return` BEFORE
`QApplication` is constructed, which is deliberate and documented there:
`--version` must work on a machine where the GUI cannot open.
- **There is no single-instance mechanism.** No `QLocalServer` or
`QLocalSocket` appears anywhere in `src/`. Two launches are two processes
against one notmuch database, and notmuch permits one open handle per
process.
- **`Qt6::Network` is not linked.** `CMakeLists.txt:20` sets the component list
to `Widgets Svg WebEngineWidgets` (plus `Test` for the suite), and
`src/CMakeLists.txt:58` links exactly those. A socket adds a component, which
is a packaging fact for the SlackBuild in `my-slackbuilds`, not just a CMake
line.
- **The startup path already composes an account with a query**, at
`mainwindow.cpp:624-657`, including the distinction this design must not
break: a generated filter comes back from `resolvedQuery()` ALREADY scoped and
takes `AccountScope::AlreadyScoped`, while a saved query states its own scope
and takes `AccountScope::Apply`. Getting it wrong is silent in both
directions, double-scoping one and leaving the other unscoped.
- **`recoverStaleThread(threadId, messageId)` already does the whole of what
`--thread` and `--message` need**, at `mainwindow.cpp:5002`. It runs
`thread:<id>`, remembers the target across the two queued round trips the
load takes, expands the thread when its row arrives
(`applyPendingRecovery()`, `mainwindow.cpp:5148`, called from the
query-complete handler at `mainwindow.cpp:3670`), selects the message once
the replies land, and falls
back to the root row when the message has gone. Item 91's double-click reuses
it outright and says so in a comment. **A CLI selector is a third caller.**
- **`announceAction()` / `showTransientStatus()`** (`mainwindow.cpp:5403`) is
how the window says something happened, with a timer that falls back to the
query's own result line.
## Decisions
Taken by the user on 2026-09-13.
1. **A second launch steers the running window.** The second process hands its
arguments over a socket and exits; the running window applies them and
raises itself. This is what makes the feature useful to the caller the note
describes, which will usually find qtmaildir already running, and it fixes
the two-notmuch-handles problem that exists today as a side effect.
2. **Three selectors: `--account`, `--thread`, `--message`.** `--query` was
considered and dropped: it is the most general and the cheapest, and it is
also the one with no caller. The query bar already accepts an arbitrary
query from the only party who would type one.
3. **A selector that matches nothing opens the window normally and says so in
the status bar.** Not an empty result, which makes a stale link look like a
broken client; not a refusal to start, which is right for a script and wrong
for a desktop launch where the user gets no window and no reason.
## Design
### The command line
`QCommandLineParser` replaces the `strcmp` loop, with one constraint that is
not negotiable: **`--version` and `--help` must keep answering without a
`QApplication`.** `QCommandLineParser` itself needs only `QCoreApplication`'s
argument list, which `QCommandLineParser::process(QStringList)` accepts, so the
parse can happen against `argv` before the GUI object exists. The two
early-exit options keep their current place and their current behaviour; the
parser gains the three selectors and nothing else.
```
qtmaildir [--account KEY] [--thread ID] [--message ID]
```
The three compose. `--account work --message '<abc@example.org>'` means what it
says, and the account half is applied before the query runs, exactly as
`startup_account` is.
### Single instance
A `QLocalServer` named under `QStandardPaths::GenericStateLocation`, beside the
UI state file, reached through a helper alongside `MainWindow::uiStatePath()`
so the two paths cannot drift. `GenericStateLocation`, not `StateLocation`, for
the reason already recorded: the latter appends both the organization and the
application name and both are `qtmaildir`.
Startup order, and the order is the design:
1. Parse the arguments. `--version`/`--help` answer and exit here, as now.
2. Try to CONNECT to the socket. If a connection succeeds, write the selectors
as one payload, wait briefly for the write to flush, and exit 0. This
process never constructs a `MainWindow` and never opens notmuch.
3. If the connection fails, this process becomes the server: listen, then build
the window and apply the selectors locally.
**A stale socket file is the failure mode to handle, not to hope about.** A
crash or a kill leaves the socket file behind, and `listen()` then fails with
`AddressInUse` on a file nothing is serving. The recovery is to attempt a
connection first, which step 2 already does: a refused connection on an
existing file proves it is stale, and `QLocalServer::removeServer()` clears it
before listening. Doing it in this order means a live instance is never
removed out from under itself.
**If the socket cannot be created at all, start anyway.** A read-only state
directory or a platform without local sockets must degrade to today's
behaviour, a window that opens and works, rather than to no mail client. Log
it; do not fail.
### Applying the selectors
One entry point on `MainWindow`, taking the three values, called from two
places: the local path at startup, and the socket handler when a second launch
arrives. The two must go through the same function or they will drift, which is
the lesson every other pair of paths in this file has already taught.
- **`--account KEY`** sets the account dropdown, through the same
`findData()`/`setCurrentIndex()` the startup account uses. `Config` already
validates the key against the configured accounts and clears an invalid one,
so the miss is detectable rather than silent.
- **`--thread ID`** calls `recoverStaleThread(id, QString())`. The empty
message id is already meaningful to that function: land on the root row,
which IS the thread's first message.
- **`--message ID`** resolves the message's thread and calls
`recoverStaleThread(threadId, messageId)`. The thread is what the user wants
to see, with that message selected: an `id:` query on the message alone shows
one card out of its conversation, which item 91 settled is the wrong reading
of "open this message".
**Resolving a Message-ID to a thread id is the one piece that does not exist.**
It is a worker question, since only the worker touches notmuch, and
`NotmuchWorker` already runs `id:"<...>"` at `notmuchworker.cpp:985`. It is one
more query with a queued reply, and the reply is what calls
`recoverStaleThread()`. Nothing about it is novel; it is listed here because it
is the only new round trip in the design.
**Every id goes through `SearchTerm::quote()`**, like every other query this
application builds. A Message-ID comes from outside the process, so it is
untrusted input in the ordinary sense. Note the existing code at
`mainwindow.cpp:5011` and `:6798` interpolates ids unquoted because they came
from notmuch itself; an id from `argv` did not, and the difference is the whole
reason `SearchTerm` exists.
### Raising the window
`show()`, `raise()` and `activateWindow()`, plus `setWindowState()` clearing
`Qt::WindowMinimized` so a minimized window comes back. **Under Wayland this is
a request, not a command**, and Hyprland may honour it as a focus hint or
ignore it by policy. That is the compositor's decision and not a defect to
chase: the selectors still apply and the window still shows the right thing,
which is the part that must work. Do not add workarounds for focus stealing
prevention.
### When a selector matches nothing
The window opens on its configured startup view and
`showTransientStatus()` names what missed:
- an account key naming no configured account,
- a thread id matching no thread,
- a Message-ID matching no message.
One message per miss, naming the value, so a caller passing a stale id can be
debugged from the client rather than from the caller. The account case is
detectable before any query runs; the other two are known when the query comes
back empty.
**A second launch that matches nothing must still raise the window.** The user
asked for it and something went wrong with the selector; showing them nothing
at all is the worst of both.
## Testing
What has a right answer and is worth building, per `AGENTS.md`.
- **The parse**: each selector, the three composed, the two early-exit options
still answering, and an unknown option not crashing. Pure over `QStringList`,
so it is testable without a window.
- **The quoting**: a Message-ID containing a quote, a backslash, whitespace or
a `)` produces the query `SearchTerm` promises, not a malformed one. This is
the security-relevant assertion and notmuch cannot check it for us, since it
parses garbage happily and matches zero (`searchterm.h:30-35`).
- **Message-ID to thread id**, in `test_notmuchworker`, against the throwaway
database: a known id resolves to its thread, an unknown one resolves to
empty.
- **Applying the selectors**, in `test_mainwindow` with `WorkerBackedWindow`: an
account key moves the dropdown, a thread id lands on that thread's row, a
message id lands on that message's row inside its thread. Wait on observable
state with `QTRY_VERIFY_WITH_TIMEOUT`, never on a fixed `qWait`, and remember
`rowCount()` on a thread row is 0 until expanded.
- **The miss path**: an unknown account key leaves the dropdown on its
configured value and the window on its startup query rather than on nothing.
- **Stale socket recovery**: a socket file with nothing behind it does not stop
the window from starting. Testable against a `QTemporaryDir` state path.
**Not tested, handed to the user to look at**: whether the window actually
raises and takes focus under Hyprland. It is compositor policy, the offscreen
platform cannot see it, and a test there would pass whatever the code does.
## Constraints
- **`Qt6::Network` joins the component list**, in the root `CMakeLists.txt` and
in `src/CMakeLists.txt`. It is already an installed part of Slackware's
monolithic `qt6` package, so the SlackBuild's dependencies do not change, but
the `.SlackBuild` lives in `my-slackbuilds` and the addition should be
verified there rather than assumed here.
- **No network protocol work.** A `QLocalServer` is a unix domain socket
between two copies of this program, not a network client. The rule
`AGENTS.md` states is about IMAP and SMTP; this does not touch it.
- **The socket is a trust boundary, and a small one.** It is owned by the user
and lives in their own state directory, so the payload comes from a process
running as them. That is not a reason to skip validation: the payload is
parsed as three optional strings, anything else is ignored, and every id
still goes through `SearchTerm::quote()` on the way to a query. A payload
size cap belongs here too, since a local socket will hand over whatever it is
given.
- **Five places for an action still applies if any of this gains one.** As
designed it does not: the selectors are startup input, not user-triggered
actions, and nothing appears in a menu. If a "raise" action is ever added it
takes the full treatment in `KeyMap::knownActions()`, the icon table and a
menu.
- **Every user-facing string is translatable**, including the status-bar misses
and the `--help` text. The option NAMES are wire format and are never
translated; their descriptions are prose and are.
- **`--help` gains three lines** and must stay accurate, since it is the only
documentation a caller will read. The README's usage section gains the same.
## Sizing
**M**, and the halves are uneven.
The selectors are the small half, because `recoverStaleThread()` already exists
and is already proven by two callers. The single-instance socket is the real
work: the connect-first ordering, stale socket recovery, the degrade path when
no socket is possible, and one new worker round trip for the Message-ID lookup.
They could ship separately, the parser and startup selectors first and the
socket after, and the spec is written so that split is available. It is not
recommended: the selectors without the socket answer the note's actual use case
("another app can launch qtmaildir") with a second window, which is the
behaviour the user rejected.
|