aboutsummaryrefslogtreecommitdiffstats
path: root/src/config.h
blob: 51fc1eef9f781a4ff2057c9ff8784c15cb156ce8 (plain)
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
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
/*
 * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
 * 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.
 */

#pragma once

#include <QColor>
#include <QCoreApplication>
#include <QJsonObject>
#include <QList>
#include <QString>
#include <QStringList>

#include "completionentry.h"

class QSettings;

/// One mail account. notmuch has no concept of accounts; it sees a single flat
/// tree. An account is therefore a path prefix within that tree plus an
/// identity.
struct Account
{
    QString key;      ///< INI group suffix, e.g. "work" from [account/work].
    QString name;
    QString address;
    QString maildir;  ///< Relative to notmuch's database.path.
    /// The account's drafts folder, relative to maildir. Optional, exactly as
    /// `sent` is, and absent more often: an account that composes elsewhere
    /// keeps no local drafts folder at all.
    ///
    /// Composing drafts is v2. Reading them is not: the placeholder pane
    /// counts them (item 67), which is why this is no longer unused.
    QString drafts;

    /// The account's sent folder, relative to maildir. Optional and empty for
    /// an account that has none, which is a real case rather than a
    /// misconfiguration: an account may keep no sent mail locally at all.
    ///
    /// A key rather than a <maildir>/Sent convention because the folder is not
    /// uniform across providers. Measured across one real setup: two accounts
    /// use `Sent`, two nest a localised name under a bracketed parent
    /// (`[Provider]/Posta inviata`), and one has no sent folder whatsoever. A
    /// convention would produce an empty view for the nested ones and a wrong
    /// one for the account that has none.
    QString sent;

    /// The account's trash folder, relative to maildir.
    ///
    /// MANDATORY, unlike `sent` and `drafts`. Delete moves a file into this
    /// folder, so an account without one cannot delete at all, and the user
    /// chose a config error over a per-account disabled state: "it is
    /// mandatory for the program to function properly". Config::load()
    /// reports a missing key through the warnings path.
    QString trash;

    /// The command that sends mail from this account, receiving the complete
    /// RFC822 message on stdin. Optional, and its ABSENCE is meaningful:
    /// an account without one is receive-only by construction.
    ///
    /// Not a separate `receive_only` key. The capability IS this command's
    /// presence, so there is nothing to keep in step and nothing to
    /// contradict. One real account is receive-only on purpose and gains no
    /// configuration at all, which is the point.
    ///
    /// Split with QProcess::splitCommand and run WITHOUT a shell, exactly as
    /// [sync] command is, so nothing in a message body, a recipient address or
    /// a display name can reach sh. No message content is ever placed in an
    /// argument: recipients come from the message's own headers.
    QString sendCommand;

    /// Whether this account can send at all.
    bool canSend() const { return !sendCommand.isEmpty(); }

    /// The account's inbox folder, relative to maildir. Optional.
    ///
    /// Only Restore reads it, as the destination for a message that carries no
    /// `deleted-from:` origin, which is what mail trashed by another client
    /// looks like. Defaults to "Inbox", the Maildir convention and mbsync's
    /// own default.
    ///
    /// Configurable rather than hardcoded because the name is not ours to
    /// assume: naming a folder that does not exist CREATES it, beside the real
    /// one, and under mbsync's `Create Both` that folder reaches the server.
    /// Unlike `trash` this is optional, since the default is right for every
    /// ordinary Maildir and a wrong guess here only affects the fallback.
    QString inbox;

    /// Chip colour in the thread list. Invalid when unset, in which case one
    /// is generated from the account tag's name.
    QColor color;

    /// Text shown on the chip. Empty falls back to the key, which can be long:
    /// a provider-plus-mailbox key of 25 characters is a lot of row for one
    /// bit of information. This renames nothing in notmuch, only the display.
    QString label;

    /// mbsync channel name, when it differs from the key. Optional, and empty
    /// for most accounts: see syncChannel().
    QString channel;

    /// The mbsync channel to sync this account, for item 49's per-account sync.
    ///
    /// Defaults to the key, which is right for most accounts, but the two are
    /// genuinely separate names and cannot be collapsed. A QSettings section
    /// key may carry dots that the channel does not ([account.mail-first.last]
    /// against the channel `mail-firstlast`), and mbsync exits nonzero on a
    /// channel it does not know, which qtmaildir would report as a failed sync.
    QString syncChannel() const { return channel.isEmpty() ? key : channel; }

    bool isValid() const { return !key.isEmpty() && !maildir.isEmpty(); }

    /// Restricts a notmuch query to this account's subtree.
    QString scopedQuery(const QString &query) const;

    /// Matches this account's sent mail, or empty when `sent` is unset.
    ///
    /// Composes with scopedQuery() rather than replacing it: the account
    /// selector wraps whatever query runs, so a Sent view under one account
    /// intersects to that account's sent mail and cannot leak another's.
    QString sentQuery() const;

    /// Matches this account's drafts, or empty when `drafts` is unset.
    ///
    /// Separate from sentQuery() rather than one parameterised helper: the two
    /// keys are independent, and one real account configures `drafts` with no
    /// `sent` at all.
    QString draftsQuery() const;

    /// Matches this account's trash, or empty when `trash` is unset.
    ///
    /// Empty is a config error rather than a legitimate state, unlike
    /// sentQuery(). The query helper still returns empty so callers compose
    /// uniformly; it is Config::load() that reports the problem.
    QString trashQuery() const;

    /// Matches this account's inbox folder, using inboxFolder().
    QString inboxQuery() const;

    /// The inbox folder name, which is `inbox` when set and "Inbox"
    /// otherwise. Never empty, so a caller always has a folder to name.
    QString inboxFolder() const;
};

/// A named query, stored in queries.json.
///
/// Stored as an ORDERED array, which is the whole reason the storage moved out
/// of `[queries]`: QSettings reads a section through childKeys(), which sorts
/// alphabetically and cannot express the order the buttons appear in.
struct SavedQuery
{
    QString name;
    QString query;

    /// Renders as a button in the query row; otherwise it lives in the menu.
    /// The two tiers are the point of the flag: a row that shows every saved
    /// query does not scale past a handful.
    bool pinned = false;

    /// Account KEY, the INI group suffix ("work" from [account.work]), and
    /// empty for a query that spans every account.
    ///
    /// A key rather than a maildir path: the path already lives in the account
    /// section, and storing a second copy here would go stale the moment the
    /// user edits it. Resolve through Config::resolvedQuery().
    QString account;

    /// Names a builtin that COMPOSES this query from the accounts at run time,
    /// rather than storing it. Empty for an ordinary query.
    ///
    /// "sent" is the only one today. Its query is built from every account's
    /// `sent` key, so adding an account or correcting a folder name is a config
    /// edit and nothing else; a stored copy of the same string would go stale
    /// silently. That property is why Sent used to be hardcoded beside the
    /// saved queries instead of living with them, which left one button on the
    /// row that could not be reordered, renamed, unpinned or removed.
    ///
    /// Storing the GENERATOR rather than its output keeps both: the query stays
    /// live, and the entry is an ordinary row the user owns.
    QString generated;

    /// Lists messages rather than threads. Set for the sent view, where a
    /// thread would fold every reply back into the conversation the user sent
    /// one message into.
    bool flat = false;

    bool isGenerated() const { return !generated.isEmpty(); }

    /// Keys this build does not understand, preserved verbatim so a file
    /// written by a later version survives a save from this one.
    QJsonObject unknown;
};

/// The [compose] section. Every key is optional with the default shown.
struct ComposeSettings
{
    /// Where the quote goes in a reply. Whether to quote AT ALL is not here:
    /// that is decided by which action was invoked (reply quotes,
    /// reply_no_quote does not).
    enum class QuotePosition { Above, Below };

    QuotePosition quotePosition = QuotePosition::Above;

    /// Seeds the per-message toggle for New and Forward only. Reply and
    /// Reply-all seed from whether the original carried a text/html part,
    /// ignoring this value: an HTML part in the original is a fact about the
    /// sender's software, not a guess about their taste.
    bool sendHtml = true;

    int autosaveIntervalMs = 30000;

    /// The undo window before sending. Zero skips the countdown entirely and
    /// sends at once, for anyone who finds it irritating.
    int sendDelayMs = 5000;

    /// Preferred account for a New message when the dropdown is on All
    /// accounts. Falls through when it names an account that cannot send.
    QString defaultAccount;

    qint64 attachmentWarnBytes = 26214400;
};

/// Reads ~/.config/qtmaildir/qtmaildir.conf.
///
/// The Maildir path is deliberately NOT configurable here: notmuch already
/// stores it as database.path and libnotmuch reads it. Duplicating it would
/// allow the GUI to index a different tree than the CLI.
class Config
{
    // Not a QObject: this class is a value holder read from every thread. The
    // macro gives it tr() for the built-in filters' NAMES, which are the labels
    // on the query row's buttons and therefore user-facing.
    Q_DECLARE_TR_FUNCTIONS(Config)

public:
    /// Path used when load() is called with no argument.
    static QString defaultPath();

    void load(const QString &path);

    QList<Account> accounts() const { return m_accounts; }
    Account account(const QString &key) const;

    ComposeSettings compose() const { return m_compose; }

    /// Every account with a send_command, in configuration order.
    ///
    /// Empty is a valid read-only installation, NOT a misconfiguration: the
    /// compose actions are simply disabled and nothing is warned about.
    QList<Account> sendingAccounts() const;

    /// In document order, which IS the display order. Never sort this.
    QList<SavedQuery> savedQueries() const { return m_savedQueries; }
    void setSavedQueries(const QList<SavedQuery> &queries)
    {
        m_savedQueries = queries;
    }

    /// Path of queries.json, derived from the config file's own directory so a
    /// test can point load() at a temporary tree and get both files there.
    static QString queriesPath(const QString &configPath);

    /// Writes queries.json. False when the file could not be written, or when
    /// the loaded file had a version this build refuses: overwriting a
    /// newer-format file with a lossy reading of it is the one outcome worth
    /// preventing outright.
    bool saveSavedQueries() const;

    /// The query as it should be run: scoped to its account when it names one.
    ///
    /// Composes through Account::scopedQuery(), whose parentheses are
    /// load-bearing. `path:... and a or b` binds as `(path:... and a) or b`, so
    /// an unparenthesised disjunction escapes its scope and matches every
    /// account. An account key naming nothing returns the bare query rather
    /// than a scope built from an empty maildir, which would be path:"/**".
    QString resolvedQuery(const SavedQuery &query) const;

    /// The query as it should be run in one account's scope, or across all of
    /// them when `accountKey` is empty.
    ///
    /// This is what makes a built-in filter COMPOSE with the account dropdown
    /// rather than fight it (item 93). A generator is asked for the account's
    /// own query, never handed its all-accounts query to wrap: wrapping gives
    ///     path:"a/**" and (path:"a/Sent/**" or path:"b/Sent/**")
    /// which returns the right rows only because path: is hierarchical, and
    /// says something other than what is meant.
    ///
    /// An ordinary saved query ignores `accountKey` and keeps resolving through
    /// its OWN stored account, which is the behaviour item 90 leaves alone: a
    /// saved query is a destination and states its own scope.
    QString resolvedQuery(const SavedQuery &query,
                          const QString &accountKey) const;

    /// The built-in filters, in the order they appear on the query row.
    ///
    /// Shipped rather than stored: these are not the user's saved queries and
    /// are not in queries.json at all. The row used to be whatever the user had
    /// pinned, which is how it drifted (item 93).
    static QList<SavedQuery> builtinFilters();

    /// One built-in filter by generator name, or a default-constructed
    /// SavedQuery when the name is not one.
    static SavedQuery builtinFilter(const QString &generator);

    /// Whether `generator` is one this build knows how to resolve.
    ///
    /// A closed set, so a typo is reported on load rather than producing a
    /// button that silently finds nothing.
    static bool isKnownGenerator(const QString &generator);

    /// A query that deliberately matches no message.
    ///
    /// Needed because an EMPTY query means "match everything" to notmuch, so a
    /// generator with nothing to match cannot simply return one: Sent under an
    /// account that configures no sent folder would show the entire Maildir.
    static QString matchNothingQuery();

    /// Empty when unset; the caller disables the Sync button in that case.
    QString syncCommand() const { return m_syncCommand; }

    /// Toolbar icon size in pixels, 16 to 64, defaulting to 24.
    ///
    /// The desktop's own PM_ToolBarIconSize was the obvious default and was
    /// rejected empirically: it reports 16 here, which is a small target now
    /// that the toolbar follows the platform's "icon only" style and the icon
    /// is the whole control. Setting this to 16 restores the theme's value.
    ///
    /// Clamped rather than trusted: a 4px icon is invisible and a 4000px one
    /// makes the toolbar taller than the window, and neither is recoverable
    /// from the UI the value just broke.
    int toolbarIconSize() const { return m_toolbarIconSize; }

    /// The sync script's log file, read to learn the outcome of a sync this
    /// process did not start (item 54).
    ///
    /// Never empty: an unset key falls back to where assets/mailsync.sh writes
    /// by default. An empty value would make every background sync report
    /// SyncOutcome::Unknown, and the pending-edit indicator would then never
    /// clear on a cron sync, which is exactly the defect this exists to fix.
    QString syncLog() const { return m_syncLog; }

    /// Optional alternate notmuch config file. Empty means "let notmuch decide".
    QString notmuchConfig() const { return m_notmuchConfig; }

    /// Matches every configured account's sent mail, or empty when no account
    /// configures one.
    ///
    /// Joins only the NON-EMPTY sentQuery() results. An account without a
    /// `sent` key contributes nothing, and joining it anyway would leave a bare
    /// `or` in the query. notmuch does not reject that: `A or  or B` returns
    /// 190 messages where the correct pair returns 211, measured directly. A
    /// malformed query that still returns plausible mail is the failure that
    /// ships, which is why the join lives here and is tested rather than being
    /// open-coded at the call site.
    QString allSentQuery() const;

    /// Matches every configured account's trash, or empty when none has one.
    ///
    /// Joins only the NON-EMPTY trashQuery() results, for the same reason
    /// allSentQuery() does: notmuch accepts a bare "or" without complaint and
    /// silently answers a different question.
    QString allTrashQuery() const;

    /// Matches every configured account's drafts, or empty when none has one.
    ///
    /// Joins only the NON-EMPTY draftsQuery() results, for the same reason
    /// allSentQuery() does: notmuch accepts a bare "or" without complaint and
    /// silently answers a different question.
    QString allDraftsQuery() const;

    /// A QDateTime::toString() pattern for the date on a card, or empty for the
    /// system locale's short format.
    ///
    /// Empty is both the default and what an unusable pattern falls back to, so
    /// a caller never has to distinguish "unset" from "rejected": either way
    /// the locale decides. Validated at load, because toString() with a pattern
    /// carrying no date field returns the pattern verbatim, which would print
    /// the same fixed string on every card rather than failing visibly.
    QString dateFormat() const { return m_dateFormat; }

    /// Interface language, or empty to follow the environment.
    ///
    /// A locale name, short ("it") or full ("it_IT"); Qt resolves the short
    /// form to a country. `system` reads as empty, so a user can write the
    /// default down rather than having to delete the key to get it back.
    ///
    /// Validated at load, because an unrecognised name does NOT fail: QLocale
    /// degrades it to C, which then loads no translation and is indistinguishable
    /// from asking for English on purpose. A typo would otherwise be silent.
    QString language() const { return m_language; }

    /// The saved query to open at startup, by name. Falls back to "Unread"
    /// when unset, and to the first saved query when no query by that name
    /// exists: [queries] is read through childKeys(), which sorts
    /// alphabetically, so "first" would otherwise mean whatever happens to
    /// sort first rather than anything the user chose.
    QString startupQuery() const { return m_startupQuery; }

    /// Account key the dropdown starts on, or empty for "All accounts".
    ///
    /// Scoping happens because the built-in filters COMPOSE with the dropdown,
    /// so setting it before the startup query runs is the whole mechanism: this
    /// key does not need to reach the query builders at all.
    ///
    /// Validated on load. A name matching no account is reported and left
    /// empty, rather than passed on to a dropdown that has no such entry and
    /// would silently stay on "All accounts".
    QString startupAccount() const { return m_startupAccount; }

    /// The saved query startupQuery() names, or the first one when it names
    /// nothing that exists. A default-constructed SavedQuery when there are
    /// none at all.
    SavedQuery startupSavedQuery() const;

    /// Starting message-pane zoom for a profile with no saved UI state. Once
    /// the user zooms, the state file remembers that instead, so this is only
    /// ever the default. Clamped by MessageView::clampZoom() on use.
    qreal messageZoom() const { return m_messageZoom; }

    /// Whether focusing an empty query bar opens the completion popup. Off by
    /// default: it is helpful when learning the query language and intrusive
    /// once it is known. The manual trigger works regardless.
    bool completionOnFocus() const { return m_completionOnFocus; }

    /// What to do about unsynced edits when the window closes.
    enum class SyncOnExit {
        Ask,     ///< Prompt, offering to sync, quit anyway, or stay. The default.
        Always,  ///< Sync without asking, then quit once it finishes.
        Never,   ///< Quit silently, which is the behaviour before this existed.
    };

    SyncOnExit syncOnExit() const { return m_syncOnExit; }

    /// How long an opened thread stays unread before it is marked read.
    ///
    /// Three meanings, all deliberate: a positive value is the delay in
    /// milliseconds, 0 marks read immediately, and any negative value disables
    /// the behaviour so a thread stays unread until toggled by hand.
    int markReadDelayMs() const { return m_markReadDelayMs; }

    /// How long to wait after a tag edit before syncing it out on the user's
    /// behalf. Item 71.
    ///
    /// Same three meanings as markReadDelayMs() above, and deliberately so: a
    /// positive value is the debounce in milliseconds, 0 syncs on the next trip
    /// through the event loop, and any negative value disables the behaviour so
    /// edits wait for a manual sync or the user's cron job, which is what every
    /// release before this one did.
    ///
    /// Defaults to 2000. The delay is a debounce, not a schedule: each edit
    /// restarts it, so a burst of tagging produces one sync after the burst
    /// rather than one per tag.
    int autoSyncDelayMs() const { return m_autoSyncDelayMs; }

    /// User-supplied mimetype completions, APPENDED to the built-in list.
    /// Appending rather than replacing means a typo cannot leave completion
    /// worse off than the defaults. Mimetypes are the only completion list a
    /// user can extend, because they are the only one with no enumerator and
    /// an open-ended set: prefixes are fixed, paths come from the configured
    /// accounts, dates are closed, tags come from the database.
    QList<CompletionEntry> extraMimetypes() const { return m_extraMimetypes; }

    /// Every non-fatal problem, both kinds below. Shown in the status bar.
    QStringList warnings() const { return m_warnings; }

    /// The subset worth interrupting startup for: something in the config is
    /// wrong and the user's stated intent is not being honoured (a malformed
    /// account, an unparseable key binding, a sync command that does not
    /// exist). An optional setting simply being absent is NOT one of these:
    /// nothing is broken, the feature is just off, and a modal on every launch
    /// trains the user to dismiss dialogs without reading them.
    QStringList problems() const { return m_problems; }

private:
    /// Records a problem: something configured but wrong. Also appears in
    /// warnings(), so callers that want everything need only that one.
    void addProblem(const QString &message);

    /// Records a notice: nothing is wrong, a feature is simply not configured.
    void addNotice(const QString &message);

    /// Reads queries.json, or migrates [queries] when it is absent. Called by
    /// load(), which has already parsed the INI by then.
    void loadSavedQueries(const QString &configPath, QSettings &settings);

    QList<Account> m_accounts;
    QList<SavedQuery> m_savedQueries;
    ComposeSettings m_compose;

    /// Where saveSavedQueries() writes, remembered from load().
    QString m_queriesPath;

    /// Top-level keys of queries.json this build does not understand.
    QJsonObject m_queriesUnknown;

    /// Set when the file was refused for its version. Blocks the save, so a
    /// document from a newer build is never overwritten with less than it held.
    bool m_queriesRefused = false;

    QString m_syncCommand;
    QString m_syncLog;
    int m_toolbarIconSize = 24;
    QString m_notmuchConfig;
    QString m_dateFormat;
    QString m_language;
    qreal m_messageZoom = 1.0;
    bool m_completionOnFocus = false;
    int m_markReadDelayMs = 2000;
    int m_autoSyncDelayMs = 2000;
    SyncOnExit m_syncOnExit = SyncOnExit::Ask;
    QList<CompletionEntry> m_extraMimetypes;
    QString m_startupQuery = QStringLiteral("Unread");

    /// Empty means "All accounts", which is the same convention every other
    /// account key here follows.
    QString m_startupAccount;

    /// Whether startup_query came from the config rather than being the
    /// built-in default. Only a name the user wrote is worth reporting when
    /// it matches no saved query.
    bool m_startupQueryWasSet = false;
    QStringList m_warnings;
    QStringList m_problems;
};