diff options
| -rw-r--r-- | src/config.cpp | 83 | ||||
| -rw-r--r-- | src/config.h | 56 | ||||
| -rw-r--r-- | src/types.h | 41 | ||||
| -rw-r--r-- | tests/test_config.cpp | 105 | ||||
| -rw-r--r-- | translations/qtmaildir_it_IT.ts | 16 |
5 files changed, 301 insertions, 0 deletions
diff --git a/src/config.cpp b/src/config.cpp index a2d1cec..09869b9 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -23,6 +23,8 @@ // so this reports the same numbers rather than keeping a second copy. #include "messageview.h" +#include <algorithm> + #include <QDateTime> #include <QDir> #include <QFile> @@ -469,6 +471,12 @@ void Config::load(const QString &path) account.inbox = settings.value(QStringLiteral("inbox")).toString().trimmed(); + // Optional, and its absence IS the receive-only state: see the field + // comment in config.h. Run without a shell, so trimming here is only + // whitespace hygiene, never a quoting concern. + account.sendCommand = + settings.value(QStringLiteral("send_command")).toString().trimmed(); + // Both optional, and both describe this account's chip in the thread // list. An account tag is a different taxonomy from a functional one, // saying which mailbox a thread arrived in rather than what state it @@ -516,6 +524,29 @@ void Config::load(const QString &path) m_accounts.append(account); } + settings.beginGroup(QStringLiteral("compose")); + // value(key, default) throughout rather than testing contains(): an + // absent key and a key set to its default must behave identically, and + // send_delay_ms = 0 is a REAL setting meaning "send at once" that a + // zero-test would mistake for unset. + m_compose.quotePosition = + settings.value(QStringLiteral("quote_position"), QStringLiteral("above")) + .toString().compare(QStringLiteral("below"), Qt::CaseInsensitive) == 0 + ? ComposeSettings::QuotePosition::Below + : ComposeSettings::QuotePosition::Above; + m_compose.sendHtml = + settings.value(QStringLiteral("send_html"), true).toBool(); + m_compose.autosaveIntervalMs = + settings.value(QStringLiteral("autosave_interval_ms"), 30000).toInt(); + m_compose.sendDelayMs = + settings.value(QStringLiteral("send_delay_ms"), 5000).toInt(); + m_compose.defaultAccount = + settings.value(QStringLiteral("default_account")).toString().trimmed(); + m_compose.attachmentWarnBytes = + settings.value(QStringLiteral("attachment_warn_bytes"), qint64(26214400)) + .toLongLong(); + settings.endGroup(); + loadSavedQueries(path, settings); // Checked here rather than where startup_query is read: the saved queries @@ -534,6 +565,48 @@ void Config::load(const QString &path) m_startupAccount.clear(); } + // default_account is validated here, once the accounts are parsed. A + // named account that cannot send is reported: the user named an account + // and expects mail to come from it, unlike an installation where no + // account can send at all, which is a valid read-only setup and not + // warned about below. + if (!m_compose.defaultAccount.isEmpty()) { + const auto named = std::find_if( + m_accounts.cbegin(), m_accounts.cend(), + [this](const Account &a) { return a.key == m_compose.defaultAccount; }); + + if (named == m_accounts.cend()) { + addProblem( + tr("[compose] default_account names '%1', which is not a " + "configured account. A new message will pick a sending " + "account by the usual rules.") + .arg(m_compose.defaultAccount)); + } else if (!named->canSend()) { + addProblem( + tr("[compose] default_account names '%1', which has no " + "send_command and cannot send. A new message will pick a " + "sending account by the usual rules.") + .arg(m_compose.defaultAccount)); + } + } + + for (const Account &account : m_accounts) { + if (!account.canSend()) + continue; + if (account.sent.isEmpty()) { + addProblem( + tr("Account '%1' can send but configures no `sent` folder, so " + "no local copy of sent mail is filed.") + .arg(account.key)); + } + if (account.drafts.isEmpty()) { + addProblem( + tr("Account '%1' can send but configures no `drafts` folder, " + "so the composer runs without draft protection.") + .arg(account.key)); + } + } + // Asks whether the resolved query matched on EITHER a name or a generator, // rather than comparing the name alone. Comparing names warned about a // config that was working: `startup_query = Inbox` resolves through the @@ -948,6 +1021,16 @@ SavedQuery Config::startupSavedQuery() const return builtinFilter(QStringLiteral("unread")); } +QList<Account> Config::sendingAccounts() const +{ + QList<Account> sending; + for (const Account &account : m_accounts) { + if (account.canSend()) + sending.append(account); + } + return sending; +} + Account Config::account(const QString &key) const { for (const Account &a : m_accounts) { diff --git a/src/config.h b/src/config.h index ede5dea..51fc1ee 100644 --- a/src/config.h +++ b/src/config.h @@ -67,6 +67,24 @@ struct Account /// 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 @@ -186,6 +204,35 @@ struct SavedQuery 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 @@ -207,6 +254,14 @@ public: 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) @@ -443,6 +498,7 @@ private: QList<Account> m_accounts; QList<SavedQuery> m_savedQueries; + ComposeSettings m_compose; /// Where saveSavedQueries() writes, remembered from load(). QString m_queriesPath; diff --git a/src/types.h b/src/types.h index f4d387a..0c6532e 100644 --- a/src/types.h +++ b/src/types.h @@ -239,6 +239,47 @@ struct DatabaseStats int tags = -1; ///< Distinct tag names in the database. }; +/// What opens a composer. Built by MainWindow, consumed by ComposeWindow. +/// +/// Built from the DATABASE, never from the model. The model's data comes from +/// the query, so a row whose state has not been re-queried carries stale +/// values, and a reply built from a stale row would carry the wrong +/// recipients. This is the same rule Restore already follows. +struct ComposeContext +{ + enum class Kind { New, Reply, ReplyAll, Forward }; + + QString accountKey; ///< Which account sends. Resolved by ComposeContext's rules. + Kind kind = Kind::New; + QString originalPath; ///< The .eml being replied to or forwarded. Empty for New. + QString inReplyTo; ///< Message-ID of the original. + QStringList references; ///< The original's References plus its Message-ID. + QStringList to; ///< Pre-filled, the user's own addresses already stripped. + QStringList cc; + QString subject; ///< Re:/Fwd: prefixed, an existing prefix not doubled. + QString quotedBody; ///< The >-prefixed original. Empty when the action does not quote. + bool seedHtml = false; ///< Did the original carry a text/html part. + QStringList attachments; ///< Carried forward for Forward, empty otherwise. +}; + +/// What the composer produces, consumed by MessageBuilder. +/// +/// In-Reply-To and References are NOT optional. Without them a reply appears +/// as an orphan thread in the sender's own client. +struct OutgoingMessage +{ + QString accountKey; + QStringList to; + QStringList cc; + QStringList bcc; + QString subject; + QString markdownBody; ///< The source text, exactly as typed. + bool sendHtml = false; ///< The composer's per-message toggle. + QStringList attachments; ///< Local paths, read at build time. + QString inReplyTo; + QStringList references; +}; + Q_DECLARE_METATYPE(ThreadSummary) Q_DECLARE_METATYPE(MessageRef) Q_DECLARE_METATYPE(MessageNode) diff --git a/tests/test_config.cpp b/tests/test_config.cpp index ea5c363..2df4c40 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -121,6 +121,11 @@ private slots: void anAccountWithoutATrashFolderWarns(); void theTrashFilterComposesPerAccount(); void theTrashFilterMatchesNothingWithoutAFolder(); + void anAccountWithoutASendCommandIsReceiveOnly(); + void composeSettingsDefaultWhenTheSectionIsAbsent(); + void aZeroSendDelayIsHonouredRatherThanTreatedAsUnset(); + void aDefaultAccountThatCannotSendIsWarnedAbout(); + void anInstallationWhereNoAccountCanSendIsNotWarnedAbout(); }; static QString writeIni(const QTemporaryDir &dir, const QString &body) @@ -2313,5 +2318,105 @@ void TestConfig::aGeneratedEntryWritesNoRedundantKeys() "flat must come back from the generator, not from the file"); } +void TestConfig::anAccountWithoutASendCommandIsReceiveOnly() +{ + // The capability IS the command's presence, and nothing else expresses + // it: not a receive_only flag, not an empty-string special case. + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral( + "[account.work]\n" + "maildir=work\n" + "trash=Trash\n" + "send_command=msmtp -a work -t\n" + "\n" + "[account.listsonly]\n" + "maildir=listsonly\n" + "trash=Trash\n"))); + + const Account work = config.account(QStringLiteral("work")); + const Account listsonly = config.account(QStringLiteral("listsonly")); + QVERIFY2(work.canSend(), "an account with send_command must be able to send"); + QVERIFY2(!listsonly.canSend(), + "an account with no send_command must not report it can send"); + + const QList<Account> sending = config.sendingAccounts(); + QCOMPARE(sending.size(), 1); + QCOMPARE(sending.first().key, QStringLiteral("work")); +} + +void TestConfig::composeSettingsDefaultWhenTheSectionIsAbsent() +{ + // A config that has never heard of this feature must produce working + // defaults rather than zeros. + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral("[general]\n"))); + + const ComposeSettings compose = config.compose(); + QVERIFY2(compose.quotePosition == ComposeSettings::QuotePosition::Above, + "default quote position must be Above"); + QVERIFY2(compose.sendHtml, "default send_html must be true"); + QCOMPARE(compose.autosaveIntervalMs, 30000); + QCOMPARE(compose.sendDelayMs, 5000); + QCOMPARE(compose.attachmentWarnBytes, qint64(26214400)); + QVERIFY(compose.defaultAccount.isEmpty()); +} + +void TestConfig::aZeroSendDelayIsHonouredRatherThanTreatedAsUnset() +{ + // Zero is a real setting meaning "send at once", and it is exactly the + // value an absent key would produce if the default were applied by + // testing for zero. + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral( + "[compose]\n" + "send_delay_ms=0\n"))); + + QCOMPARE(config.compose().sendDelayMs, 0); + QVERIFY2(config.compose().sendDelayMs != 5000, + "zero send_delay_ms was replaced by the default"); +} + +void TestConfig::aDefaultAccountThatCannotSendIsWarnedAbout() +{ + // Follows the pattern that already warns about an unresolvable + // startup_query: the setting is not silently corrected because a user + // who named an account expects mail to come from it. + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral( + "[compose]\n" + "default_account=listsonly\n" + "\n" + "[account.listsonly]\n" + "maildir=listsonly\n" + "trash=Trash\n"))); + + const QString joined = config.warnings().join(QLatin1Char('\n')); + QVERIFY2(joined.contains(QStringLiteral("listsonly")), + qPrintable(QStringLiteral("no warning named listsonly: %1").arg(joined))); +} + +void TestConfig::anInstallationWhereNoAccountCanSendIsNotWarnedAbout() +{ + // A read-only installation is VALID; warning about it would train the + // user to ignore warnings. + QTemporaryDir dir; + Config config; + config.load(writeIni(dir, QStringLiteral( + "[account.work]\n" + "maildir=work\n" + "trash=Trash\n"))); + + QVERIFY(config.sendingAccounts().isEmpty()); + for (const QString &warning : config.warnings()) { + QVERIFY2(!warning.contains(QStringLiteral("send"), Qt::CaseInsensitive), + qPrintable(QStringLiteral("unexpected sending-related warning: %1") + .arg(warning))); + } +} + QTEST_MAIN(TestConfig) #include "test_config.moc" diff --git a/translations/qtmaildir_it_IT.ts b/translations/qtmaildir_it_IT.ts index 68b42f7..edc9d5e 100644 --- a/translations/qtmaildir_it_IT.ts +++ b/translations/qtmaildir_it_IT.ts @@ -52,6 +52,22 @@ <translation>L'account iniziale '%1' non è un account configurato; si parte da tutti gli account.</translation> </message> <message> + <source>[compose] default_account names '%1', which is not a configured account. A new message will pick a sending account by the usual rules.</source> + <translation>[compose] default_account indica '%1', che non è un account configurato. Un nuovo messaggio sceglierà un account di invio secondo le regole abituali.</translation> + </message> + <message> + <source>[compose] default_account names '%1', which has no send_command and cannot send. A new message will pick a sending account by the usual rules.</source> + <translation>[compose] default_account indica '%1', che non ha un send_command e non può inviare. Un nuovo messaggio sceglierà un account di invio secondo le regole abituali.</translation> + </message> + <message> + <source>Account '%1' can send but configures no `sent` folder, so no local copy of sent mail is filed.</source> + <translation>L'account '%1' può inviare ma non configura una cartella 'sent', quindi non viene archiviata alcuna copia locale della posta inviata.</translation> + </message> + <message> + <source>Account '%1' can send but configures no `drafts` folder, so the composer runs without draft protection.</source> + <translation>L'account '%1' può inviare ma non configura una cartella 'drafts', quindi il compositore funziona senza protezione delle bozze.</translation> + </message> + <message> <source>Startup query '%1' is not a saved query; opening '%2' instead.</source> <translation>La ricerca iniziale '%1' non è una ricerca salvata; verrà aperta '%2'.</translation> </message> |
