diff options
| -rw-r--r-- | src/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | src/composewindow.cpp | 851 | ||||
| -rw-r--r-- | src/composewindow.h | 223 | ||||
| -rw-r--r-- | tests/test_mainwindow.cpp | 1154 | ||||
| -rw-r--r-- | translations/qtmaildir_it_IT.ts | 143 |
5 files changed, 2372 insertions, 0 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 83981b2..2cebfef 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -20,6 +20,7 @@ add_library(qtmaildir_lib STATIC tagchip.cpp tagcolors.cpp senddialog.cpp + composewindow.cpp savequerydialog.cpp tagdialog.cpp tagrules.cpp diff --git a/src/composewindow.cpp b/src/composewindow.cpp new file mode 100644 index 0000000..95b0a7b --- /dev/null +++ b/src/composewindow.cpp @@ -0,0 +1,851 @@ +/* + * 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. + */ + +#include "composewindow.h" + +#include "draftstore.h" +#include "messagebuilder.h" +#include "messagesender.h" +#include "senddialog.h" + +#include <QAction> +#include <QCheckBox> +#include <QCloseEvent> +#include <QComboBox> +#include <QDir> +#include <QFile> +#include <QFileDialog> +#include <QFileInfo> +#include <QFormLayout> +#include <QHBoxLayout> +#include <QKeySequence> +#include <QLabel> +#include <QLineEdit> +#include <QListWidget> +#include <QMessageBox> +#include <QPlainTextEdit> +#include <QPushButton> +#include <QTextCursor> +#include <QTimer> +#include <QToolBar> +#include <QVBoxLayout> +#include <QWidget> + +namespace { + +/// Splits a comma-separated recipient field into addresses. +/// +/// Splitting on commas is WRONG for a raw header, which is why +/// ComposeContextBuilder::parseAddressHeader parses instead. It is right here +/// and only here: this is a field the user typed, and the composer's own +/// rendering of it joins with ", ". A display name containing a comma has to +/// be quoted by the user, exactly as it has to be in the wire format, and +/// MessageBuilder is what turns each entry into a mailbox. +QStringList splitRecipients(const QString &text) +{ + QStringList out; + const QStringList parts = text.split(QLatin1Char(','), Qt::SkipEmptyParts); + for (const QString &part : parts) { + const QString trimmed = part.trimmed(); + if (!trimmed.isEmpty()) + out.append(trimmed); + } + return out; +} + +/// Everything about a message the user can change, as one comparable string. +/// +/// Joined with a character no field can contain, because concatenating them +/// bare lets a change move a boundary without changing the whole: a subject +/// "ab" with body "c" and a subject "a" with body "bc" would produce the same +/// string and the second edit would never be saved. A unit separator (U+001F) +/// cannot be typed into a QLineEdit or a QPlainTextEdit and cannot appear in a +/// file path. +QString fingerprintOf(const OutgoingMessage &message) +{ + const QChar sep(QChar(0x1F)); + return message.accountKey + sep + message.to.join(sep) + sep + + message.cc.join(sep) + sep + message.bcc.join(sep) + sep + + message.subject + sep + message.markdownBody + sep + + (message.sendHtml ? QStringLiteral("1") : QStringLiteral("0")) + sep + + message.attachments.join(sep); +} + +} // namespace + +ComposeWindow::ComposeWindow(const ComposeContext &context, + const Config &config, const QString &mailRoot, + QWidget *parent) + : QMainWindow(parent) + , m_context(context) + , m_config(config) + , m_mailRoot(mailRoot) + , m_attachments(context.attachments) +{ + // A window in its own right, not a child dialog: it must appear in the + // task switcher and be reachable while the main window is used. Passing a + // parent still makes Qt treat it as a window because of Qt::Window, which + // QMainWindow carries. + setAttribute(Qt::WA_DeleteOnClose); + setWindowTitle(tr("Compose")); + + // A sensible default. NOT restored and NOT saved; see the header. + resize(760, 640); + + // BEFORE buildUi(), and this ordering is load-bearing rather than + // stylistic. buildUi() connects every field to markDirty(), and seeding + // then fills those fields, so markDirty() runs during construction and + // calls m_autosaveTimer->start(). Created afterwards, that is a null + // dereference on the first seeded field, which is every composer. + m_autosaveTimer = new QTimer(this); + m_autosaveTimer->setObjectName(QStringLiteral("autosave")); + m_autosaveTimer->setSingleShot(true); + m_autosaveTimer->setInterval(m_config.compose().autosaveIntervalMs); + connect(m_autosaveTimer, &QTimer::timeout, this, &ComposeWindow::autosave); + + m_sender = new MessageSender(this); + + buildUi(); + buildFormatToolbar(); + seedFields(); + seedBody(); + refreshAttachmentList(); + + // Seeding is not an edit. Every field was just filled from the context, so + // the widgets have emitted their change signals and left the window dirty + // before the user has typed anything; a composer opened and closed at once + // would then write a draft nobody asked for. The timer is stopped as well + // as the flag cleared, since markDirty() started it. + m_dirty = false; + m_autosaveTimer->stop(); +} + +Account ComposeWindow::currentAccount() const +{ + // The dropdown is the authority once the window is open: the context + // chooses the initial account and the user may then change it, and every + // build after that must use what the From field shows. Reading + // m_context.accountKey here instead would send from the seeded account + // however the dropdown was set, with the interface saying otherwise. + if (m_from && m_from->currentIndex() >= 0) { + const QString key = m_from->currentData().toString(); + if (!key.isEmpty()) + return m_config.account(key); + } + return m_config.account(m_context.accountKey); +} + +void ComposeWindow::buildUi() +{ + auto *central = new QWidget(this); + central->setObjectName(QStringLiteral("composeCentral")); + auto *layout = new QVBoxLayout(central); + + // The failed-save banner, above everything: a warning that must survive + // until it is dealt with does not belong below the fold. Hidden until + // there is something to say. + m_banner = new QLabel(central); + m_banner->setObjectName(QStringLiteral("draftBanner")); + m_banner->setWordWrap(true); + // PlainText explicitly. The text carries a filesystem error string and a + // path, neither of which is ours, and a QLabel guesses under AutoText. + m_banner->setTextFormat(Qt::PlainText); + m_banner->hide(); + layout->addWidget(m_banner); + + auto *form = new QFormLayout; + + m_from = new QComboBox(central); + m_from->setObjectName(QStringLiteral("from")); + form->addRow(tr("From:"), m_from); + + m_to = new QLineEdit(central); + m_to->setObjectName(QStringLiteral("to")); + form->addRow(tr("To:"), m_to); + + m_cc = new QLineEdit(central); + m_cc->setObjectName(QStringLiteral("cc")); + form->addRow(tr("Cc:"), m_cc); + + m_bcc = new QLineEdit(central); + m_bcc->setObjectName(QStringLiteral("bcc")); + form->addRow(tr("Bcc:"), m_bcc); + + m_subject = new QLineEdit(central); + m_subject->setObjectName(QStringLiteral("subject")); + form->addRow(tr("Subject:"), m_subject); + + layout->addLayout(form); + + // Labelled for what it does, a formatted copy riding along with the plain + // text, rather than "HTML", which reads as an either/or that it is not. + m_sendHtml = new QCheckBox(tr("Also send a formatted copy"), central); + m_sendHtml->setObjectName(QStringLiteral("sendHtml")); + m_sendHtml->setToolTip( + tr("Sends the message as plain text with a formatted version " + "alongside it. The plain text is what you typed.")); + layout->addWidget(m_sendHtml); + + m_body = new QPlainTextEdit(central); + m_body->setObjectName(QStringLiteral("body")); + layout->addWidget(m_body, 1); + + m_attachmentList = new QListWidget(central); + m_attachmentList->setObjectName(QStringLiteral("attachments")); + m_attachmentList->setMaximumHeight(90); + m_attachmentList->hide(); + layout->addWidget(m_attachmentList); + + // The send-failure pane, in the shape MainWindow's sync log already has: + // a header with a Close button and a read-only QPlainTextEdit under it. A + // QPlainTextEdit has no close affordance of its own, so the two travel + // together as one widget. + m_sendLogPane = new QWidget(central); + m_sendLogPane->setObjectName(QStringLiteral("sendLogPane")); + auto *logLayout = new QVBoxLayout(m_sendLogPane); + logLayout->setContentsMargins(0, 0, 0, 0); + logLayout->setSpacing(2); + + auto *logHeader = new QHBoxLayout; + logHeader->addWidget(new QLabel(tr("Send output"), m_sendLogPane)); + logHeader->addStretch(); + auto *closeLog = new QPushButton(tr("Close"), m_sendLogPane); + closeLog->setObjectName(QStringLiteral("closeSendLog")); + connect(closeLog, &QPushButton::clicked, m_sendLogPane, &QWidget::hide); + logHeader->addWidget(closeLog); + logLayout->addLayout(logHeader); + + m_sendLog = new QPlainTextEdit(m_sendLogPane); + m_sendLog->setObjectName(QStringLiteral("sendLog")); + m_sendLog->setReadOnly(true); + m_sendLog->setMaximumHeight(140); + logLayout->addWidget(m_sendLog); + + m_sendLogPane->hide(); + layout->addWidget(m_sendLogPane); + + setCentralWidget(central); + + // Every field marks the buffer dirty. The subject and the recipients are + // part of the message as much as the body is, and a draft that saved the + // body but not the address it was going to would be worse than none. + connect(m_body, &QPlainTextEdit::textChanged, this, + &ComposeWindow::markDirty); + for (QLineEdit *field : { m_to, m_cc, m_bcc, m_subject }) + connect(field, &QLineEdit::textChanged, this, &ComposeWindow::markDirty); + connect(m_sendHtml, &QCheckBox::toggled, this, &ComposeWindow::markDirty); + connect(m_from, &QComboBox::currentIndexChanged, this, + &ComposeWindow::markDirty); +} + +void ComposeWindow::buildFormatToolbar() +{ + m_formatToolbar = addToolBar(tr("Formatting")); + m_formatToolbar->setObjectName(QStringLiteral("formatToolbar")); + + // A QAction parented to THIS WINDOW, not registered in KeyMap. Its + // shortcut is therefore scoped to the composer: Qt dispatches a + // WindowShortcut to the active window only, so the main window's Ctrl+B is + // untouched and the two namespaces stay apart. These six do not + // participate in item 132's reachability rule for the same reason. + const auto addFormat = [this](const QString &name, const QString &text, + const QString &token, + const QKeySequence &shortcut) { + QAction *action = m_formatToolbar->addAction(text); + action->setObjectName(name); + if (!shortcut.isEmpty()) + action->setShortcut(shortcut); + connect(action, &QAction::triggered, this, + [this, token]() { applyFormat(token); }); + }; + + addFormat(QStringLiteral("format_bold"), tr("Bold"), + QStringLiteral("**"), QKeySequence(QStringLiteral("Ctrl+B"))); + addFormat(QStringLiteral("format_italic"), tr("Italic"), + QStringLiteral("*"), QKeySequence(QStringLiteral("Ctrl+I"))); + addFormat(QStringLiteral("format_code"), tr("Code"), + QStringLiteral("`"), QKeySequence(QStringLiteral("Ctrl+`"))); + // No shortcut, per the spec's table. + addFormat(QStringLiteral("format_strike"), tr("Strikethrough"), + QStringLiteral("~~"), QKeySequence()); + + // Link and Quote are not wraps and cannot go through applyFormat(). + QAction *link = m_formatToolbar->addAction(tr("Link")); + link->setObjectName(QStringLiteral("format_link")); + link->setShortcut(QKeySequence(QStringLiteral("Ctrl+K"))); + connect(link, &QAction::triggered, this, [this]() { + const QTextCursor cursor = m_body->textCursor(); + applyEdit(MarkdownFormat::link(m_body->toPlainText(), + cursor.selectionStart(), + cursor.selectionEnd())); + }); + + QAction *quote = m_formatToolbar->addAction(tr("Quote")); + quote->setObjectName(QStringLiteral("format_quote")); + connect(quote, &QAction::triggered, this, [this]() { + const QTextCursor cursor = m_body->textCursor(); + applyEdit(MarkdownFormat::quote(m_body->toPlainText(), + cursor.selectionStart(), + cursor.selectionEnd())); + }); + + m_formatToolbar->addSeparator(); + + m_attachAction = m_formatToolbar->addAction(tr("Attach...")); + m_attachAction->setObjectName(QStringLiteral("compose_attach")); + connect(m_attachAction, &QAction::triggered, this, [this]() { + const QStringList chosen = QFileDialog::getOpenFileNames( + this, tr("Attach files")); + for (const QString &path : chosen) + attachFile(path); + }); + + m_detachAction = m_formatToolbar->addAction(tr("Remove attachment")); + m_detachAction->setObjectName(QStringLiteral("compose_detach")); + connect(m_detachAction, &QAction::triggered, this, [this]() { + const int row = m_attachmentList->currentRow(); + if (row < 0 || row >= m_attachments.size()) + return; + m_attachments.removeAt(row); + refreshAttachmentList(); + markDirty(); + }); + + m_sendAction = m_formatToolbar->addAction(tr("Send")); + m_sendAction->setObjectName(QStringLiteral("compose_send")); + m_sendAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+Return"))); + connect(m_sendAction, &QAction::triggered, this, &ComposeWindow::send); +} + +void ComposeWindow::seedFields() +{ + // Only accounts that can send. An account without a send_command is + // receive-only by construction, and offering it in a From field would + // produce a message that cannot be sent from the account it says it is + // from. + const QList<Account> senders = m_config.sendingAccounts(); + for (const Account &account : senders) { + const QString label = account.name.isEmpty() + ? account.address + : account.name + QStringLiteral(" <") + + account.address + QLatin1Char('>'); + m_from->addItem(label, account.key); + } + const int index = m_from->findData(m_context.accountKey); + if (index >= 0) + m_from->setCurrentIndex(index); + + m_to->setText(m_context.to.join(QStringLiteral(", "))); + m_cc->setText(m_context.cc.join(QStringLiteral(", "))); + m_subject->setText(m_context.subject); + + // New and Forward seed from [compose] send_html; Reply and Reply-all seed + // from whether the original carried a text/html part, ignoring the config + // value. An HTML part in the original is a fact about the sender's + // software, not a guess about their taste. + const bool isReply = m_context.kind == ComposeContext::Kind::Reply + || m_context.kind == ComposeContext::Kind::ReplyAll; + m_sendHtml->setChecked(isReply ? m_context.seedHtml + : m_config.compose().sendHtml); +} + +void ComposeWindow::seedBody() +{ + if (m_context.quotedBody.isEmpty()) + return; + + // Applied when the window opens and never again. The buffer is text the + // user owns after that, and there is deliberately no live toggle: + // tracking "my text" and "the quote" as separate pieces to make a toggle + // reversible is machinery for a case answered by closing the composer and + // reopening it. + if (m_config.compose().quotePosition + == ComposeSettings::QuotePosition::Above) { + // The quote first, then a blank line for the reply to be typed into. + m_body->setPlainText(m_context.quotedBody + QStringLiteral("\n\n")); + } else { + m_body->setPlainText(QStringLiteral("\n\n") + m_context.quotedBody); + } + + // The cursor at the very top in both cases: with the quote below, the + // blank lines the reply goes into are at the top; with it above, the user + // scrolls past what they are answering, which is what quoting above means. + m_body->moveCursor(QTextCursor::Start); + + // The seeded quote is not an edit the user made, so it must not survive as + // an undo step: one Ctrl+Z on a fresh composer would otherwise wipe the + // quote and read as the buffer losing its content. + m_body->document()->clearUndoRedoStacks(); +} + +void ComposeWindow::refreshAttachmentList() +{ + m_attachmentList->clear(); + for (const QString &path : m_attachments) + m_attachmentList->addItem(QFileInfo(path).fileName()); + m_attachmentList->setVisible(!m_attachments.isEmpty()); +} + +bool ComposeWindow::attachmentNeedsWarning(qint64 size) const +{ + const qint64 limit = m_config.compose().attachmentWarnBytes; + // A limit of zero or less disables the warning outright. Treating it as a + // threshold would warn about every attachment including an empty one, + // which is the opposite of what turning a warning off means. + return limit > 0 && size > limit; +} + +/// A byte count as a figure a person reads, with one decimal below 10 units. +/// +/// Integer MB division is what this replaces and it produced "'x' is 0 MB. +/// Many mail servers refuse messages above about 0 MB.", which is what any +/// attachment_warn_bytes under a megabyte reads as. The unit steps down as +/// well, so a small configured limit is stated in KB rather than as zero of a +/// larger unit. +QString ComposeWindow::humanSize(qint64 bytes) +{ + constexpr qint64 kKb = 1024; + constexpr qint64 kMb = 1024 * 1024; + + if (bytes >= kMb) { + const double mb = double(bytes) / double(kMb); + // One decimal only while the figure is small enough for it to say + // something; 26.2 MB is informative, 1234.6 MB is noise. + return mb < 10.0 ? QObject::tr("%1 MB").arg(mb, 0, 'f', 1) + : QObject::tr("%1 MB").arg(qRound(mb)); + } + if (bytes >= kKb) { + const double kb = double(bytes) / double(kKb); + return kb < 10.0 ? QObject::tr("%1 KB").arg(kb, 0, 'f', 1) + : QObject::tr("%1 KB").arg(qRound(kb)); + } + return QObject::tr("%1 bytes").arg(bytes); +} + +void ComposeWindow::attachFile(const QString &path) +{ + const QFileInfo info(path); + + if (attachmentNeedsWarning(info.size())) { + const qint64 limit = m_config.compose().attachmentWarnBytes; + const auto answer = QMessageBox::question( + this, tr("Large attachment"), + tr("'%1' is %2. Many mail servers refuse messages above about " + "%3. Attach it anyway?") + .arg(info.fileName(), humanSize(info.size()), + humanSize(limit)), + QMessageBox::Yes | QMessageBox::No); + if (answer != QMessageBox::Yes) + return; + } + + m_attachments.append(path); + refreshAttachmentList(); + markDirty(); +} + +OutgoingMessage ComposeWindow::currentMessage() const +{ + OutgoingMessage message; + message.accountKey = currentAccount().key; + message.to = splitRecipients(m_to->text()); + message.cc = splitRecipients(m_cc->text()); + message.bcc = splitRecipients(m_bcc->text()); + message.subject = m_subject->text(); + message.markdownBody = m_body->toPlainText(); + message.sendHtml = m_sendHtml->isChecked(); + message.attachments = m_attachments; + message.inReplyTo = m_context.inReplyTo; + message.references = m_context.references; + return message; +} + +void ComposeWindow::applyEdit(const MarkdownFormat::Edit &edit) +{ + // A QTextCursor replacement rather than setPlainText(), and this is a + // correction of the plan's draft. Measured under the offscreen platform: + // setPlainText() DESTROYS the document's undo stack (isUndoAvailable goes + // from true to false) and resets the cursor to position 0, so every + // toolbar press would throw away everything the user could undo. A + // document-wide select and insertText inside one edit block leaves undo + // available, collapses to a SINGLE undo step, and emits textChanged once. + QTextCursor cursor = m_body->textCursor(); + cursor.beginEditBlock(); + cursor.select(QTextCursor::Document); + cursor.insertText(edit.text); + cursor.endEditBlock(); + + // Restore the selection the transformation asked for. The cursor is left + // at the end of the inserted text, so without this every button press + // sends it to the bottom of the message; the empty-selection case relies + // on it to land BETWEEN the tokens, which is the property a user notices + // immediately when it is wrong. + // + // Clamped rather than trusted: QTextCursor::setPosition() past the end + // warns on stderr and silently clamps, so a stale or arithmetic position + // would produce noise rather than an error. MarkdownFormat clamps its own + // output too, so this is a second line rather than the only one. + const int length = m_body->toPlainText().length(); + const int start = qBound(0, edit.selectionStart, length); + const int end = qBound(start, edit.selectionEnd, length); + + QTextCursor restored = m_body->textCursor(); + restored.setPosition(start); + restored.setPosition(end, QTextCursor::KeepAnchor); + m_body->setTextCursor(restored); + m_body->setFocus(); +} + +void ComposeWindow::applyFormat(const QString &token) +{ + const QTextCursor cursor = m_body->textCursor(); + applyEdit(MarkdownFormat::wrap(m_body->toPlainText(), + cursor.selectionStart(), + cursor.selectionEnd(), token)); +} + +void ComposeWindow::markDirty() +{ + m_dirty = true; + // Debounced: the timer restarts on every keystroke, so a write happens + // once the user has paused, not once per character. Every autosave + // produces a Maildir write that mbsync uploads, which is what the debounce + // and the dirty check together keep to a few revisions per message. + m_autosaveTimer->start(); +} + +void ComposeWindow::autosave() +{ + if (!m_dirty) + return; + saveDraftNow(); +} + +bool ComposeWindow::saveDraftNow() +{ + const Account account = currentAccount(); + if (account.drafts.isEmpty()) { + // Configured without a drafts folder. Warned about at startup; there + // is nothing to do here and nothing to report a second time. Reported + // as success because nothing failed: a false here would make the quit + // path offer a retry that cannot change anything. + return true; + } + + const OutgoingMessage message = currentMessage(); + + // The dirty CHECK, not just the flag: an unchanged message means no file + // is written and no sync is provoked. Every autosave produces a Maildir + // write that mbsync uploads, so this and the debounce together are what + // keep a message to a few revisions rather than dozens. + // + // Checked BEFORE the build, and on the message rather than on the bytes. + // The plan's draft compared built.bytes, which can never match: GMime is + // given a fresh Date and Message-ID on every build, so two builds of an + // unchanged message differ. That check would have read as working while + // writing a file on every debounce. Doing it first also skips the + // blocking build entirely for the no-change case, which is the common one. + const QString fingerprint = fingerprintOf(message); + if (!m_savedFingerprint.isEmpty() && fingerprint == m_savedFingerprint) { + m_dirty = false; + return true; + } + + // MessageBuilder::build() is SYNCHRONOUS and can block: a large attachment + // is read and base64-encoded on this thread, which is the GUI thread. A + // debounce firing with a 25MB attachment therefore stalls typing for as + // long as the read takes. Deliberately not moved to a thread: nothing here + // crosses the worker boundary, and a second threading model for one call + // is worse than the stall. If someone is measuring a composer freeze, this + // line is where to look. + const MessageBuilder::Result built = MessageBuilder::build(message, account); + if (!built.ok()) { + m_saveFailed = true; + m_banner->setText(tr("The draft could not be saved: %1").arg(built.error)); + m_banner->show(); + return false; + } + + const QString folder = QDir(m_mailRoot).absoluteFilePath( + account.maildir + QLatin1Char('/') + account.drafts); + + const DraftStore::Result written = + DraftStore::write(folder, built.bytes, QStringLiteral("D"), m_draftPath); + + if (!written.ok()) { + // A PERSISTENT banner, not a modal and not a status-bar line that + // fades. A modal mid-sentence is hostile while the user is typing, but + // the warning must survive until it is dealt with, because the quit + // path's honesty depends on it. + m_saveFailed = true; + m_banner->setText( + tr("The draft could not be saved: %1").arg(written.error)); + m_banner->show(); + return false; + } + + m_draftPath = written.path; + m_savedFingerprint = fingerprint; + m_dirty = false; + m_saveFailed = false; + m_banner->hide(); + return true; +} + +void ComposeWindow::setInputsEnabled(bool enabled) +{ + // Every input for the WHOLE operation, countdown included. The message + // must not change between the user pressing Send and the bytes being + // built. The send-failure pane is deliberately left alone: it is read-only + // and disabling it would make the stderr it carries unreadable. + m_to->setEnabled(enabled); + m_cc->setEnabled(enabled); + m_bcc->setEnabled(enabled); + m_subject->setEnabled(enabled); + m_from->setEnabled(enabled); + m_body->setReadOnly(!enabled); + m_sendHtml->setEnabled(enabled); + m_attachmentList->setEnabled(enabled); + m_formatToolbar->setEnabled(enabled); +} + +void ComposeWindow::showSendFailure(const QString &stderrText) +{ + m_sendLog->setPlainText(stderrText.isEmpty() + ? tr("The send command reported no output.") + : stderrText); + m_sendLogPane->show(); +} + +void ComposeWindow::send() +{ + // Refused outright while a send operation is up, countdown included. + // setInputsEnabled(false) disables the toolbar the Send action lives on + // and SendDialog is window-modal, so a user cannot reach this twice; the + // guard covers the programmatic route, where a second call would put a + // second dialog over the first and start a send MessageSender then + // refuses, leaving a popup with no result coming for it. + if (m_sendInFlight) + return; + m_sendInFlight = true; + + const Account account = currentAccount(); + + if (!account.canSend()) { + QMessageBox::warning( + this, tr("Cannot send"), + tr("The account '%1' has no send command configured.") + .arg(account.key)); + m_sendInFlight = false; + return; + } + + const MessageBuilder::Result built = + MessageBuilder::build(currentMessage(), account); + if (!built.ok()) { + // A missing attachment lands here, before anything runs. + QMessageBox::warning(this, tr("Cannot send"), built.error); + m_sendInFlight = false; + return; + } + + // Every input is disabled for the WHOLE operation, countdown included. + setInputsEnabled(false); + + auto *dialog = new SendDialog(m_config.compose().sendDelayMs, this); + + connect(dialog, &SendDialog::undone, this, [this, dialog]() { + // Nothing reached a server. The composer returns exactly as it was, + // editable, popup gone, nothing sent. + // + // deleteLater(), never delete: this runs SYNCHRONOUSLY inside + // SendDialog::undo(), which emits undone() and then calls reject() on + // itself (senddialog.cpp), so the dialog is still on the stack here. + // This is CLAUDE.md's "a modal dialog must close BEFORE the action it + // asked for runs" arriving from the other side, and deleteLater is + // what makes it safe: it posts a deletion event rather than freeing + // the object the caller is about to keep using. A plain delete here + // would return into a destroyed SendDialog's reject(). + m_sendInFlight = false; + setInputsEnabled(true); + dialog->deleteLater(); + }); + + connect(dialog, &SendDialog::committed, this, + [this, dialog, built, account]() { + // No setStage(Sending) here: SendDialog::commit() sets it before + // emitting committed(), so doing it again would be a second owner of + // the same state. + + // Qt::SingleShotConnection IS REQUIRED HERE. m_sender is a long-lived + // member, so a bare connect() beside each send() accumulates a + // permanent receiver per send. Send, fail, correct the recipient, send + // again, and the second result runs BOTH lambdas: the first still + // holds the FIRST message's `built` and `account` by value, so it + // files a sent copy of the wrong message and calls accept() on a + // dialog it already deleteLater()'d. MessageSender's m_reported guard + // cannot prevent this: it collapses two QProcess signals into one + // emit, and this is one emit reaching many receivers. Measured in + // test_messagesender.cpp::aPerSendConnectionMustBeSingleShot, where + // the bare shape delivers 3 results for 2 sends and the single-shot + // shape delivers 2. + const QMetaObject::Connection resultConnection = connect( + m_sender, &MessageSender::finished, this, + [this, dialog, built, account](bool sent, const QString &error) { + m_sendInFlight = false; + + if (!sent) { + dialog->accept(); + dialog->deleteLater(); + setInputsEnabled(true); + + // The draft stays, and it must be the draft of what was just + // attempted. send() builds from the widgets without saving, so + // the revision on disk is whatever the last debounce wrote: + // edit, send, fail, close, and the user gets the OLDER text + // back, having watched their correction be sent. No retry + // loop, but the text that failed to go is kept. + saveDraftNow(); + + showSendFailure(error); + return; + } + + dialog->setStage(SendDialog::Stage::FilingSentCopy); + bool sentCopyFailed = false; + QString sentCopyError; + + if (!account.sent.isEmpty()) { + const QString folder = QDir(m_mailRoot).absoluteFilePath( + account.maildir + QLatin1Char('/') + account.sent); + const DraftStore::Result filed = + DraftStore::write(folder, built.bytes, QStringLiteral("S")); + if (!filed.ok()) { + sentCopyFailed = true; + sentCopyError = filed.error; + } + } + + dialog->setStage(SendDialog::Stage::RemovingDraft); + if (!m_draftPath.isEmpty()) { + QFile::remove(m_draftPath); + m_draftPath.clear(); + } + + dialog->accept(); + dialog->deleteLater(); + + if (sentCopyFailed) { + // A MODAL, never a status-bar line, and never reported as a + // send failure. The message went; reporting otherwise makes + // someone send it twice. This is the one failure in the whole + // design that produces a silent divergence between what the + // recipient received and what the local archive shows, and + // nobody discovers a missing sent copy by noticing a line that + // appeared for a few seconds. + QMessageBox::warning( + this, tr("Sent, but not filed"), + tr("The message was sent, but the copy could not be " + "written to '%1' for account '%2':\n\n%3\n\n" + "The message HAS been sent. Do not send it again.") + .arg(account.sent, account.key, sentCopyError)); + } + + // The composer closes either way: the message went, and holding a + // composer open for a message already sent invites sending it + // twice. m_finished stops closeEvent() saving a draft for a + // message that is gone, and stops it refusing the close. + m_finished = true; + m_dirty = false; + close(); + }, Qt::SingleShotConnection); + + if (!m_sender->send(account.sendCommand, built.bytes)) { + // Refused before any process started, so no finished() will ever + // arrive and the single-shot connection above would sit there for + // good. Disconnected here rather than left, since the next send + // would then have two receivers, which is exactly the defect the + // flag exists to prevent. + // + // THE HANDLE, not disconnect(m_sender, &finished, this, nullptr). + // That form drops every finished receiver on this object, so one + // connection added anywhere else would be killed here silently, + // and the failure it produces is not a wrong value but silence: a + // send whose result nobody processes, leaving the popup on + // "Sending...", the composer disabled, and no error anywhere. + // + // UNTESTED, and deliberately so rather than by omission. This + // branch is currently UNREACHABLE: MessageSender::send() returns + // false only for an empty command or a command already running, + // and canSend() rejects the first while m_sendInFlight rejects the + // second before either can arrive here. QSettings also unquotes + // every INI value, so no configured string survives trimming yet + // splits to nothing. A test would have to reach past the public + // surface to provoke it, and a test that cannot fail is worse than + // none. Kept because it costs nothing and stops being dead the + // moment send() grows a third refusal, which is the shape an + // outbox drain loop would add. + m_sendInFlight = false; + disconnect(resultConnection); + dialog->accept(); + dialog->deleteLater(); + setInputsEnabled(true); + showSendFailure(tr("The send command could not be started.")); + } + }); + + dialog->open(); +} + +void ComposeWindow::closeEvent(QCloseEvent *event) +{ + // Refused for the WHOLE send, countdown included, and the countdown half + // is the one easily lost. A guard that starts at commit leaves the five + // seconds before it unprotected: closing then destroys this window, takes + // the parented SendDialog down with it, and committed() never fires, so + // the user pressed Send, watched a countdown, and believes the mail went. + // After commit the reason is the one MessageSender's destructor + // documents: a live SMTP conversation abandoned is an outcome nobody can + // report truthfully. + // + // Both windows close themselves when the operation ends, so refusing here + // strands nothing. + if (m_sendInFlight && !m_finished) { + event->ignore(); + return; + } + + // The last-moment autosave, and the reason it is here rather than in the + // quit path: the debounce means a composer closed inside its interval has + // unwritten text, and WA_DeleteOnClose destroys the window immediately + // after this. Without this call, typing a paragraph and pressing the + // window manager's X inside thirty seconds loses it silently, with no + // prompt and no write, which is exactly the loss the autosave design + // exists to prevent. + // + // Its failure is deliberately NOT allowed to refuse the close. A window + // that will not close because it cannot save is worse than one that closes + // having said so: the banner is already up from saveDraftNow(), and the + // quit path reads lastSaveFailed() to escalate. Task 12 owns that dialog; + // this call is what makes there be something to escalate ABOUT. + if (m_dirty && !m_finished) + saveDraftNow(); + + emit closed(this); + QMainWindow::closeEvent(event); +} diff --git a/src/composewindow.h b/src/composewindow.h new file mode 100644 index 0000000..99803d7 --- /dev/null +++ b/src/composewindow.h @@ -0,0 +1,223 @@ +/* + * 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 <QMainWindow> +#include <QStringList> + +#include "config.h" +#include "formattoolbar.h" // MarkdownFormat::Edit is used by value below, and + // a type nested in a namespace cannot be + // forward-declared from outside it. +#include "types.h" + +class QAction; +class QCheckBox; +class QComboBox; +class QLabel; +class QLineEdit; +class QListWidget; +class QPlainTextEdit; +class QTimer; +class QToolBar; +class QWidget; + +class MessageSender; + +/// One draft. A separate top-level window, several open at once. +/// +/// A QMainWindow rather than a dialog: a modal dialog cannot consult another +/// message while writing, which is most of what replying is, and taking over +/// the message pane fights the pane that exists to show what is being replied +/// to. +/// +/// NO GEOMETRY RESTORE and no geometry save. CLAUDE.md records what +/// saveGeometry does under a tiling compositor: it stores normalGeometry, the +/// compositor owns the tile, and the restore is correct while looking broken. +/// A whole session went into that once. The composer opens at a sensible +/// default size and the compositor places it. +/// +/// It contains no MIME and no process logic: a composer bug and a MIME bug are +/// found in different files. Everything it does with a message goes through +/// MessageBuilder, DraftStore, MessageSender, MarkdownFormat and SendDialog. +class ComposeWindow : public QMainWindow +{ + Q_OBJECT + +public: + /// \p mailRoot is the Maildir root, passed in rather than derived. + /// + /// There is NO Config::maildirPath(). The root comes from + /// notmuch_config_get(NOTMUCH_CONFIG_MAIL_ROOT), wrapped by mailRootOf() + /// which is file-static inside notmuchworker.cpp and needs the database + /// handle. Item 124 records why this matters: notmuch can split the index + /// from the mail, and under that layout database.path is the INDEX + /// directory. Composing a destination from the wrong root would write + /// drafts and sent copies into the Xapian tree. MainWindow already + /// receives the root from the worker; it passes it here. + ComposeWindow(const ComposeContext &context, const Config &config, + const QString &mailRoot, QWidget *parent = nullptr); + + /// True when the buffer has changed since the last successful autosave. + /// The quit path asks every open composer this. + bool hasUnsavedEdits() const { return m_dirty; } + + /// True when the LAST autosave attempt failed. Escalated to its own + /// dialog on the way out, because saving is what is already not working + /// and quitting therefore loses that text. + bool lastSaveFailed() const { return m_saveFailed; } + + /// Writes the current buffer to the drafts folder now. Returns false and + /// leaves the banner up on failure. + /// + /// Returns TRUE when the account configures no drafts folder: nothing was + /// written and nothing failed, and reporting a failure would make the quit + /// path offer a retry for a state no retry can change. The composer + /// running without draft protection is warned about at startup instead. + bool saveDraftNow(); + + /// What the composer would send or save right now. + /// + /// Public so a test can assert on the message the widgets produce without + /// building MIME, and so the quit path can be reasoned about from values. + OutgoingMessage currentMessage() const; + + /// The paths currently attached, in the order they were attached. + QStringList attachments() const { return m_attachments; } + + /// Attaches \p path, asking first when it is larger than + /// [compose] attachment_warn_bytes. + /// + /// A warning rather than a refusal: the limit belongs to the recipient's + /// server, which this application cannot know, so the user decides. + void attachFile(const QString &path); + + /// A byte count as a figure a person reads. + /// + /// Static and public so the formatting is testable without a modal. The + /// integer MB division this replaces produced "0 MB" for any + /// attachment_warn_bytes under a megabyte, in both halves of the same + /// sentence. + static QString humanSize(qint64 bytes); + + /// Whether \p size would raise the large-attachment question. + /// + /// Split out so the threshold is testable without a modal. A limit of zero + /// or less disables the warning outright rather than warning about + /// everything. + bool attachmentNeedsWarning(qint64 size) const; + +signals: + /// The composer finished with its message, one way or another, and the + /// registry should forget it. + /// + /// Emitted from the close path, so a registry connected to it can drop its + /// pointer before WA_DeleteOnClose destroys the window. + void closed(ComposeWindow *window); + +protected: + /// The one place the registry is told, whichever route closes the window. + void closeEvent(QCloseEvent *event) override; + +private: + void buildUi(); + void buildFormatToolbar(); + void seedFields(); + void seedBody(); + void refreshAttachmentList(); + void setInputsEnabled(bool enabled); + void showSendFailure(const QString &stderrText); + void applyEdit(const MarkdownFormat::Edit &edit); + void markDirty(); + void autosave(); + void send(); + void applyFormat(const QString &token); + Account currentAccount() const; + + ComposeContext m_context; + Config m_config; + QString m_mailRoot; + QStringList m_attachments; + + QLineEdit *m_to = nullptr; + QLineEdit *m_cc = nullptr; + QLineEdit *m_bcc = nullptr; + QLineEdit *m_subject = nullptr; + QComboBox *m_from = nullptr; + QPlainTextEdit *m_body = nullptr; + QCheckBox *m_sendHtml = nullptr; + QLabel *m_banner = nullptr; + QListWidget *m_attachmentList = nullptr; + QWidget *m_sendLogPane = nullptr; + QPlainTextEdit *m_sendLog = nullptr; + QToolBar *m_formatToolbar = nullptr; + QAction *m_sendAction = nullptr; + QAction *m_attachAction = nullptr; + QAction *m_detachAction = nullptr; + + QTimer *m_autosaveTimer = nullptr; + MessageSender *m_sender = nullptr; + + QString m_draftPath; ///< The revision on disk, unlinked on the next write. + + /// A fingerprint of the message the last successful save wrote, for the + /// dirty CHECK. + /// + /// NOT the built bytes, and that is a correction of the plan's draft. + /// MessageBuilder generates a fresh Date and Message-ID on every build + /// (measured, messagebuilder.cpp around the g_mime_message_set_date call), + /// so two builds of an unchanged message never compare equal and a check + /// on the bytes can never fire. It would read as working while writing a + /// file, and an mbsync upload, on every debounce. + QString m_savedFingerprint; + bool m_dirty = false; + bool m_saveFailed = false; + + /// True from the moment Send is pressed until the operation ends, however + /// it ends: the countdown, the command, the sent copy. + /// + /// ONE flag, covering the whole operation, and an earlier revision had two + /// because a narrower "committed and running" flag reads as the honest + /// thing to guard a live SMTP conversation with. It is not: every question + /// this window has to answer while sending has the same answer through the + /// countdown as after it. A close during the countdown destroys the + /// parented SendDialog and committed() never fires, so the user watches a + /// countdown for a message that is never sent, and a second Send during + /// the countdown opens a second popup. Splitting the two left the narrower + /// flag written in three places and read in none. + bool m_sendInFlight = false; + + /// Set once the message has gone, so the close that follows a successful + /// send is neither refused nor made to write a draft. + /// + /// The close-REFUSAL half is load-bearing: m_sendInFlight is cleared in + /// the same handler, and without m_finished the composer's own close would + /// depend on that clear having already happened, which is a race rather + /// than a guarantee. + /// + /// The last-moment-SAVE half is deliberately redundant, and it is worth + /// saying so rather than letting the next reader mistake it for load + /// bearing: the send handler already clears m_dirty, so either condition + /// alone stops the save. Measured, each survives the other's removal and + /// only dropping both puts the draft of an already-sent message back on + /// disk. Kept because the two say different things, "nothing to write" and + /// "this window is done", and a future path that finishes without clearing + /// m_dirty would otherwise resurrect a sent message's draft silently. + bool m_finished = false; +}; diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp index 0ad45ee..cab6eae 100644 --- a/tests/test_mainwindow.cpp +++ b/tests/test_mainwindow.cpp @@ -50,6 +50,13 @@ #include "messageview.h" #include "notmuchworker.h" #include "carddelegate.h" +#include "composewindow.h" +#include "senddialog.h" +#include "messagesender.h" +#include <QCheckBox> +#include <QPlainTextEdit> +#include <QPointer> +#include <QListWidget> #include "cardlayout.h" #include <QImage> @@ -392,6 +399,37 @@ private slots: void theRefreshAfterARestoreLeavesUndoIntact(); void deletingOutsideTheTrashViewLeavesTheRowInPlace(); + // ComposeWindow, item 123. These need a window but no worker: the composer + // never touches NotmuchWorker, it reads its context from the value struct + // MainWindow hands it, so a Config written to a temporary INI is the whole + // fixture. + void aComposerOpensClean(); + void typingMarksTheComposerDirty(); + void anAutosaveWritesADraftAndClearsTheDirtyFlag(); + void anUnwritableDraftsFolderRaisesThePersistentBanner(); + void aSuccessfulSaveClearsTheBanner(); + void anAccountWithoutADraftsFolderReportsNoFailure(); + void aRewrittenDraftUnlinksThePreviousRevision(); + void theComposerBuildsTheMessageItsWidgetsShow(); + void theFromDropdownDecidesWhichAccountSends(); + void aFormatEditPreservesTheUndoStack(); + void aFormatEditRestoresTheSelectionItAsksFor(); + void aFormatEditOnAnEmptySelectionLandsBetweenTheTokens(); + void theAttachmentWarningRespectsTheConfiguredThreshold(); + void aDisabledAttachmentWarningWarnsAboutNothing(); + void theQuotePositionDecidesWhereTheQuoteLands(); + void theSeededQuoteIsNotAnUndoStep(); + void aReplySeedsTheHtmlToggleFromTheOriginal(); + void aNewMessageSeedsTheHtmlToggleFromConfig(); + void disablingInputsCoversEveryFieldAndTheToolbar(); + void aFailedSendCanBeRetriedWithoutFilingTheWrongCopy(); + void anUnchangedMessageIsNotWrittenAgain(); + void closingInsideTheDebounceStillSavesTheDraft(); + void closingAfterASendWritesNoFurtherDraft(); + void aCloseDuringTheCountdownIsRefused(); + void aFailedSendKeepsTheTextThatFailedToGo(); + void aSmallSizeLimitIsNotDescribedAsZeroMegabytes(); + private: /// Owns the throwaway lock table init() points every test at. A pointer /// rather than a value because it is rebuilt per test, and QTemporaryDir @@ -10770,4 +10808,1120 @@ void TestMainWindow::deletingOutsideTheTrashViewLeavesTheRowInPlace() QCOMPARE(model->rowCount(QModelIndex()), 1); } +// --------------------------------------------------------------------------- +// ComposeWindow, item 123. +// +// The composer owns widgets and nothing else here does, which is why its cases +// live in this file. What is asserted is deliberately NOT what it looks like: +// the autosave dirty check, the banner state, the message its widgets produce, +// the format edits and the seeding rules, all of which are observable without +// a painter. CLAUDE.md's "Rendering probes lie" section covers why counting +// pixels here would prove nothing. +// --------------------------------------------------------------------------- + +namespace { + +/// A Config written to a temporary INI, plus a Maildir root to write into. +/// +/// No notmuch database and no worker: the composer never touches +/// NotmuchWorker, so building one would only cost every case a `notmuch new`. +/// The mail root is passed to ComposeWindow explicitly, exactly as MainWindow +/// passes what the worker reported (item 124: it is NOT database.path). +class ComposeFixture +{ +public: + /// `drafts` and `sent` are written only when non-empty, so a test can + /// build the account-without-a-drafts-folder case by passing an empty + /// string rather than by needing a second fixture. + /// `secondAccount` writes a SECOND sending account, which is what makes + /// the From dropdown have something to choose between. Off by default: + /// every other case here wants exactly one, so a two-account fixture + /// everywhere would let a test pass by picking the only entry there is. + bool build(const QString &drafts = QStringLiteral("Drafts"), + const QString &sent = QStringLiteral("Sent"), + const QString &extraCompose = QString(), + bool secondAccount = false) + { + if (!m_confDir.isValid() || !m_mailDir.isValid()) + return false; + + const QString path = m_confDir.filePath(QStringLiteral("qtmaildir.conf")); + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) + return false; + { + QTextStream out(&file); + // QSettings reads `/` in a section name as a group separator, so + // the section is [account.acct], never [account/acct]. + out << "[account.acct]\n" + << "name=Test User\n" + << "address=user@example.org\n" + << "maildir=acct\n" + << "trash=Trash\n"; + if (!drafts.isEmpty()) + out << "drafts=" << drafts << "\n"; + if (!sent.isEmpty()) + out << "sent=" << sent << "\n"; + // A command that exists and does nothing. canSend() is what the + // From dropdown filters on, so an account without this one line + // would not appear in it at all. + out << "send_command=/bin/true\n"; + if (secondAccount) { + out << "\n[account.other]\n" + << "name=Other User\n" + << "address=other@example.org\n" + << "maildir=other\n" + << "trash=Trash\n" + << "drafts=Drafts\n" + << "sent=Sent\n" + << "send_command=/bin/true\n"; + } + out << "\n[compose]\n"; + if (!extraCompose.isEmpty()) + out << extraCompose << "\n"; + } + file.close(); + + m_config.load(path); + return true; + } + + const Config &config() const { return m_config; } + QString mailRoot() const { return m_mailDir.path(); } + + /// The account's drafts folder, as the composer will resolve it. + QString draftsCur() const + { + return m_mailDir.path() + QStringLiteral("/acct/Drafts/cur"); + } + + /// The second account's drafts folder. + QString otherDraftsCur() const + { + return m_mailDir.path() + QStringLiteral("/other/Drafts/cur"); + } + + /// How many message files sit in the drafts folder. + int draftCount() const + { + return QDir(draftsCur(), {}, QDir::Name, QDir::Files).count(); + } + +private: + QTemporaryDir m_confDir; + QTemporaryDir m_mailDir; + Config m_config; +}; + +/// A minimal New-message context for the fixture's one account. +ComposeContext newContext() +{ + ComposeContext context; + context.accountKey = QStringLiteral("acct"); + context.kind = ComposeContext::Kind::New; + return context; +} + +} // namespace + +void TestMainWindow::aComposerOpensClean() +{ + ComposeFixture fixture; + QVERIFY(fixture.build()); + + ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot()); + + // Seeding fills every field, which emits every field's change signal. A + // composer that counted those as edits would autosave a draft nobody + // asked for, and would tell the quit path there is unsaved work in a + // window the user opened and closed without typing. + QVERIFY(!window.hasUnsavedEdits()); + QVERIFY(!window.lastSaveFailed()); + + auto *timer = window.findChild<QTimer *>(QStringLiteral("autosave")); + QVERIFY2(timer, "no autosave timer: the window was never built"); + QVERIFY2(!timer->isActive(), + "seeding armed the autosave timer, so a untouched composer writes"); +} + +void TestMainWindow::typingMarksTheComposerDirty() +{ + ComposeFixture fixture; + QVERIFY(fixture.build()); + + ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot()); + auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body")); + QVERIFY(body); + + QVERIFY(!window.hasUnsavedEdits()); + body->setPlainText(QStringLiteral("Some text.")); + QVERIFY(window.hasUnsavedEdits()); + + // The subject is part of the message as much as the body is: a draft that + // saved the body but not the address it was going to would be worse than + // none. + ComposeWindow second(newContext(), fixture.config(), fixture.mailRoot()); + auto *subject = second.findChild<QLineEdit *>(QStringLiteral("subject")); + QVERIFY(subject); + QVERIFY(!second.hasUnsavedEdits()); + subject->setText(QStringLiteral("A subject")); + QVERIFY(second.hasUnsavedEdits()); +} + +void TestMainWindow::anAutosaveWritesADraftAndClearsTheDirtyFlag() +{ + ComposeFixture fixture; + QVERIFY(fixture.build()); + + ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot()); + auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body")); + QVERIFY(body); + body->setPlainText(QStringLiteral("Draft body.")); + QVERIFY(window.hasUnsavedEdits()); + + QVERIFY2(window.saveDraftNow(), "the draft write reported failure"); + + QCOMPARE(fixture.draftCount(), 1); + QVERIFY2(!window.hasUnsavedEdits(), + "the flag survived a successful save, so the quit path would ask"); + QVERIFY(!window.lastSaveFailed()); + + // The bytes really are the message, not an empty file: the draft is + // byte-identical to what would be sent, which is the property the one + // built message exists for. + const QStringList files = + QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList(); + QCOMPARE(files.size(), 1); + QFile written(fixture.draftsCur() + QLatin1Char('/') + files.first()); + QVERIFY(written.open(QIODevice::ReadOnly)); + const QByteArray bytes = written.readAll(); + QVERIFY2(bytes.contains("Draft body."), "the draft does not carry the body"); + // Written with the Maildir draft flag, not left bare. + QVERIFY2(files.first().endsWith(QStringLiteral(":2,D")), + qPrintable(QStringLiteral("wrong maildir flags: ") + files.first())); +} + +void TestMainWindow::anUnwritableDraftsFolderRaisesThePersistentBanner() +{ + ComposeFixture fixture; + QVERIFY(fixture.build()); + + ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot()); + auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body")); + QVERIFY(body); + body->setPlainText(QStringLiteral("Draft body.")); + + // A FILE where the folder must go. mkpath then fails, which is a real + // failure mode and needs no permission games that root would defeat. + const QString accountDir = fixture.mailRoot() + QStringLiteral("/acct"); + QVERIFY(QDir().mkpath(accountDir)); + QFile blocker(accountDir + QStringLiteral("/Drafts")); + QVERIFY(blocker.open(QIODevice::WriteOnly)); + blocker.write("not a directory"); + blocker.close(); + + QVERIFY2(!window.saveDraftNow(), "an unwritable folder reported success"); + + auto *banner = window.findChild<QLabel *>(QStringLiteral("draftBanner")); + QVERIFY2(banner, "no banner widget"); + QVERIFY2(!banner->text().isEmpty(), "the banner says nothing"); + QVERIFY2(window.lastSaveFailed(), + "lastSaveFailed() is false after a failed write, so the quit " + "path would let the text go"); + QVERIFY2(window.hasUnsavedEdits(), + "a failed save cleared the dirty flag, which claims the text is " + "safe on disk when it is not"); +} + +void TestMainWindow::aSuccessfulSaveClearsTheBanner() +{ + ComposeFixture fixture; + QVERIFY(fixture.build()); + + ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot()); + auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body")); + QVERIFY(body); + body->setPlainText(QStringLiteral("First.")); + + const QString accountDir = fixture.mailRoot() + QStringLiteral("/acct"); + QVERIFY(QDir().mkpath(accountDir)); + QFile blocker(accountDir + QStringLiteral("/Drafts")); + QVERIFY(blocker.open(QIODevice::WriteOnly)); + blocker.close(); + + QVERIFY(!window.saveDraftNow()); + QVERIFY(window.lastSaveFailed()); + + // Remove the obstruction and save again. The banner must go: a warning + // that stays after the thing it warned about is fixed teaches the user to + // ignore warnings, which is the second lesson in the TagRules entry. + QVERIFY(QFile::remove(accountDir + QStringLiteral("/Drafts"))); + body->setPlainText(QStringLiteral("Second.")); + + QVERIFY2(window.saveDraftNow(), "the retry failed"); + QVERIFY2(!window.lastSaveFailed(), "lastSaveFailed() stayed set"); + + auto *banner = window.findChild<QLabel *>(QStringLiteral("draftBanner")); + QVERIFY(banner); + QVERIFY2(banner->isHidden(), "the banner is still up after a good save"); +} + +void TestMainWindow::anAccountWithoutADraftsFolderReportsNoFailure() +{ + ComposeFixture fixture; + // No drafts key at all: a real configuration, warned about at startup. + QVERIFY(fixture.build(QString())); + + ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot()); + auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body")); + QVERIFY(body); + body->setPlainText(QStringLiteral("Nowhere to save this.")); + + // Nothing was written and nothing failed. Reporting a failure here would + // make the quit path offer a retry for a state no retry can change. + QVERIFY2(window.saveDraftNow(), + "a missing drafts folder was reported as a save failure"); + QVERIFY2(!window.lastSaveFailed(), "the banner state was set"); + + auto *banner = window.findChild<QLabel *>(QStringLiteral("draftBanner")); + QVERIFY(banner); + QVERIFY(banner->isHidden()); +} + +void TestMainWindow::aRewrittenDraftUnlinksThePreviousRevision() +{ + ComposeFixture fixture; + QVERIFY(fixture.build()); + + ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot()); + auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body")); + QVERIFY(body); + + body->setPlainText(QStringLiteral("Revision one.")); + QVERIFY(window.saveDraftNow()); + QCOMPARE(fixture.draftCount(), 1); + + body->setPlainText(QStringLiteral("Revision two.")); + QVERIFY(window.saveDraftNow()); + + // ONE file, not two. Maildir has no in-place edit, so a draft rewritten + // every thirty seconds would otherwise accumulate one file per pause, and + // every one of them is a message mbsync uploads. + QCOMPARE(fixture.draftCount(), 1); + + const QStringList files = + QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList(); + QFile written(fixture.draftsCur() + QLatin1Char('/') + files.first()); + QVERIFY(written.open(QIODevice::ReadOnly)); + const QByteArray bytes = written.readAll(); + QVERIFY2(bytes.contains("Revision two."), "the surviving file is the old one"); +} + +void TestMainWindow::theComposerBuildsTheMessageItsWidgetsShow() +{ + ComposeFixture fixture; + QVERIFY(fixture.build()); + + ComposeContext context = newContext(); + context.kind = ComposeContext::Kind::Reply; + context.inReplyTo = QStringLiteral("original@example.org"); + context.references = { QStringLiteral("root@example.org"), + QStringLiteral("original@example.org") }; + context.to = { QStringLiteral("one@example.org") }; + context.subject = QStringLiteral("Re: a subject"); + + ComposeWindow window(context, fixture.config(), fixture.mailRoot()); + + auto *cc = window.findChild<QLineEdit *>(QStringLiteral("cc")); + auto *bcc = window.findChild<QLineEdit *>(QStringLiteral("bcc")); + auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body")); + QVERIFY(cc && bcc && body); + + // A field the user typed, split on commas. That is wrong for a RAW header + // and right here: this is the composer's own rendering, which joins with + // ", ". + cc->setText(QStringLiteral("two@example.org, three@example.org")); + bcc->setText(QStringLiteral(" four@example.org ")); + body->setPlainText(QStringLiteral("The body.")); + + const OutgoingMessage message = window.currentMessage(); + QCOMPARE(message.accountKey, QStringLiteral("acct")); + QCOMPARE(message.to, QStringList{ QStringLiteral("one@example.org") }); + QCOMPARE(message.cc, (QStringList{ QStringLiteral("two@example.org"), + QStringLiteral("three@example.org") })); + // Trimmed, or the whitespace reaches the wire as part of the address. + QCOMPARE(message.bcc, QStringList{ QStringLiteral("four@example.org") }); + QCOMPARE(message.subject, QStringLiteral("Re: a subject")); + QCOMPARE(message.markdownBody, QStringLiteral("The body.")); + + // NOT optional. Without them a reply appears as an orphan thread in the + // sender's own client, which is invisible locally. + QCOMPARE(message.inReplyTo, QStringLiteral("original@example.org")); + QCOMPARE(message.references.size(), 2); + QCOMPARE(message.references.last(), QStringLiteral("original@example.org")); +} + +void TestMainWindow::theFromDropdownDecidesWhichAccountSends() +{ + // TWO sending accounts, because a dropdown with one entry cannot be + // changed and a test against it passes whether the code reads the dropdown + // or the context. The first revision of this test did exactly that: it + // asserted count() == 1 and then re-asserted a property another case + // already covers, and a mutation making currentAccount() read + // m_context.accountKey survived it. + ComposeFixture fixture; + QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"), + QString(), /*secondAccount=*/true)); + + ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot()); + auto *from = window.findChild<QComboBox *>(QStringLiteral("from")); + QVERIFY2(from, "no From dropdown"); + + // Both sending accounts are offered, seeded to the context's. + QCOMPARE(from->count(), 2); + QCOMPARE(from->currentData().toString(), QStringLiteral("acct")); + QCOMPARE(window.currentMessage().accountKey, QStringLiteral("acct")); + + // Now change it. The dropdown is the authority once the window is open: + // reading the context here would send from the seeded account while the + // interface said otherwise. + const int other = from->findData(QStringLiteral("other")); + QVERIFY2(other >= 0, "the second account is not in the dropdown"); + from->setCurrentIndex(other); + + QCOMPARE(window.currentMessage().accountKey, QStringLiteral("other")); + + // And the choice reaches the DRAFT's destination, not just the value: + // a draft is written into the sending account's own folder, so a composer + // that read the context would file it under the wrong account. + auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body")); + QVERIFY(body); + body->setPlainText(QStringLiteral("From the other account.")); + QVERIFY(window.saveDraftNow()); + + QCOMPARE(QDir(fixture.otherDraftsCur(), {}, QDir::Name, QDir::Files).count(), + 1u); + QCOMPARE(QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).count(), 0u); +} + +void TestMainWindow::aFormatEditPreservesTheUndoStack() +{ + ComposeFixture fixture; + QVERIFY(fixture.build()); + + ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot()); + auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body")); + QVERIFY(body); + + // Typed through a cursor, which is what makes it an undoable edit; + // setPlainText() would not be one. + QTextCursor typing = body->textCursor(); + typing.insertText(QStringLiteral("hello")); + QVERIFY(body->document()->isUndoAvailable()); + + QTextCursor selection = body->textCursor(); + selection.setPosition(0); + selection.setPosition(5, QTextCursor::KeepAnchor); + body->setTextCursor(selection); + + auto *bold = window.findChild<QAction *>(QStringLiteral("format_bold")); + QVERIFY2(bold, "no bold action"); + bold->trigger(); + + QCOMPARE(body->toPlainText(), QStringLiteral("**hello**")); + + // The property the plan's setPlainText() draft would have lost. Measured + // in a standalone probe: setPlainText() takes isUndoAvailable from true to + // false, so every toolbar press would throw away everything the user could + // undo. + QVERIFY2(body->document()->isUndoAvailable(), + "the format edit destroyed the undo stack"); + + // And it is ONE undo step, not one per character: a whole-document + // replacement inside an edit block collapses to a single entry, so one + // Ctrl+Z takes the tokens off and leaves the typed word. + body->undo(); + QCOMPARE(body->toPlainText(), QStringLiteral("hello")); +} + +void TestMainWindow::aFormatEditRestoresTheSelectionItAsksFor() +{ + ComposeFixture fixture; + QVERIFY(fixture.build()); + + ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot()); + auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body")); + QVERIFY(body); + body->setPlainText(QStringLiteral("hello world")); + + // A BACKWARDS selection, anchor after the cursor, which is what a + // right-to-left drag produces and an ordinary gesture. Measured against a + // real widget: selectionStart()/selectionEnd() come back normalised even + // then, so the anchor's side does not reach MarkdownFormat. + QTextCursor selection = body->textCursor(); + selection.setPosition(5); + selection.setPosition(0, QTextCursor::KeepAnchor); + body->setTextCursor(selection); + QCOMPARE(body->textCursor().selectionStart(), 0); + QCOMPARE(body->textCursor().selectionEnd(), 5); + + auto *italic = window.findChild<QAction *>(QStringLiteral("format_italic")); + QVERIFY(italic); + italic->trigger(); + + QCOMPARE(body->toPlainText(), QStringLiteral("*hello* world")); + + // The selection is preserved precisely so a second press can apply a + // SECOND token to the same words, bold then italic without reselecting. + QCOMPARE(body->textCursor().selectedText(), QStringLiteral("hello")); + + auto *bold = window.findChild<QAction *>(QStringLiteral("format_bold")); + QVERIFY(bold); + bold->trigger(); + QCOMPARE(body->toPlainText(), QStringLiteral("***hello*** world")); +} + +void TestMainWindow::aFormatEditOnAnEmptySelectionLandsBetweenTheTokens() +{ + ComposeFixture fixture; + QVERIFY(fixture.build()); + + ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot()); + auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body")); + QVERIFY(body); + body->setPlainText(QStringLiteral("ab")); + + QTextCursor cursor = body->textCursor(); + cursor.setPosition(1); + body->setTextCursor(cursor); + + auto *bold = window.findChild<QAction *>(QStringLiteral("format_bold")); + QVERIFY(bold); + bold->trigger(); + + QCOMPARE(body->toPlainText(), QStringLiteral("a****b")); + + // The property a user notices immediately when it is wrong, and the one + // invisible to a test that only compares the resulting text: typing must + // continue INSIDE the pair, not after it. + QCOMPARE(body->textCursor().position(), 3); + QVERIFY(!body->textCursor().hasSelection()); + + QTextCursor typing = body->textCursor(); + typing.insertText(QStringLiteral("x")); + QCOMPARE(body->toPlainText(), QStringLiteral("a**x**b")); +} + +void TestMainWindow::theAttachmentWarningRespectsTheConfiguredThreshold() +{ + ComposeFixture fixture; + QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"), + QStringLiteral("attachment_warn_bytes=1000"))); + + ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot()); + + // The threshold, not the modal. The question itself needs a user, so what + // is asserted is the predicate that decides whether to ask. + QVERIFY2(!window.attachmentNeedsWarning(999), "warned below the limit"); + QVERIFY2(!window.attachmentNeedsWarning(1000), + "warned AT the limit, which is not above it"); + QVERIFY2(window.attachmentNeedsWarning(1001), "did not warn above the limit"); +} + +void TestMainWindow::aDisabledAttachmentWarningWarnsAboutNothing() +{ + ComposeFixture fixture; + QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"), + QStringLiteral("attachment_warn_bytes=0"))); + + ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot()); + + // Zero means off, not "warn about everything". Read as a threshold it + // would question an empty file, which is the opposite of what turning a + // warning off means. + QVERIFY(!window.attachmentNeedsWarning(0)); + QVERIFY(!window.attachmentNeedsWarning(1)); + QVERIFY(!window.attachmentNeedsWarning(100LL * 1024 * 1024)); +} + +void TestMainWindow::theQuotePositionDecidesWhereTheQuoteLands() +{ + const QString quote = QStringLiteral("> the original"); + + { + ComposeFixture above; + QVERIFY(above.build(QStringLiteral("Drafts"), QStringLiteral("Sent"), + QStringLiteral("quote_position=above"))); + ComposeContext context = newContext(); + context.kind = ComposeContext::Kind::Reply; + context.quotedBody = quote; + + ComposeWindow window(context, above.config(), above.mailRoot()); + auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body")); + QVERIFY(body); + QVERIFY2(body->toPlainText().startsWith(quote), + "quote_position=above did not put the quote first"); + } + + { + ComposeFixture below; + QVERIFY(below.build(QStringLiteral("Drafts"), QStringLiteral("Sent"), + QStringLiteral("quote_position=below"))); + ComposeContext context = newContext(); + context.kind = ComposeContext::Kind::Reply; + context.quotedBody = quote; + + ComposeWindow window(context, below.config(), below.mailRoot()); + auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body")); + QVERIFY(body); + QVERIFY2(body->toPlainText().endsWith(quote), + "quote_position=below did not put the quote last"); + QVERIFY2(!body->toPlainText().startsWith(quote), + "the quote is at the top under quote_position=below"); + } +} + +void TestMainWindow::theSeededQuoteIsNotAnUndoStep() +{ + ComposeFixture fixture; + QVERIFY(fixture.build()); + + ComposeContext context = newContext(); + context.kind = ComposeContext::Kind::Reply; + context.quotedBody = QStringLiteral("> the original"); + + ComposeWindow window(context, fixture.config(), fixture.mailRoot()); + auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body")); + QVERIFY(body); + QVERIFY(!body->toPlainText().isEmpty()); + + // The seeded quote is not an edit the user made. One Ctrl+Z on a fresh + // composer must not wipe it, which reads as the buffer losing its content. + // + // Worth knowing before judging this test dead weight: removing + // clearUndoRedoStacks() alone leaves it GREEN, because setPlainText() + // already leaves undo unavailable. The line it guards becomes load-bearing + // the moment seedBody() stops using setPlainText, which is a change with + // reasons to happen: applyEdit() switched to a QTextCursor replacement for + // exactly the undo-stack property this asserts, and a later revision + // seeding the quote the same way would put it on the stack. The combined + // mutation (seed through a cursor AND drop the clear) does kill this. + QVERIFY2(!body->document()->isUndoAvailable(), + "the seeded quote is on the undo stack"); +} + +void TestMainWindow::aReplySeedsTheHtmlToggleFromTheOriginal() +{ + ComposeFixture fixture; + // Config says yes; the original says no. The original wins for a reply: + // an HTML part in it is a fact about the sender's software, not a guess + // about their taste. + QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"), + QStringLiteral("send_html=true"))); + + ComposeContext context = newContext(); + context.kind = ComposeContext::Kind::Reply; + context.seedHtml = false; + + ComposeWindow window(context, fixture.config(), fixture.mailRoot()); + auto *toggle = window.findChild<QCheckBox *>(QStringLiteral("sendHtml")); + QVERIFY2(toggle, "no send-html toggle"); + QVERIFY2(!toggle->isChecked(), + "a reply seeded from config rather than from the original"); + + // And the other way round, so the test cannot pass by always reading + // false: a plain-text config with an HTML original still offers HTML. + ComposeFixture plain; + QVERIFY(plain.build(QStringLiteral("Drafts"), QStringLiteral("Sent"), + QStringLiteral("send_html=false"))); + ComposeContext htmlReply = newContext(); + htmlReply.kind = ComposeContext::Kind::ReplyAll; + htmlReply.seedHtml = true; + + ComposeWindow second(htmlReply, plain.config(), plain.mailRoot()); + auto *secondToggle = + second.findChild<QCheckBox *>(QStringLiteral("sendHtml")); + QVERIFY(secondToggle); + QVERIFY2(secondToggle->isChecked(), + "a reply-all ignored an HTML original"); +} + +void TestMainWindow::aNewMessageSeedsTheHtmlToggleFromConfig() +{ + ComposeFixture off; + QVERIFY(off.build(QStringLiteral("Drafts"), QStringLiteral("Sent"), + QStringLiteral("send_html=false"))); + + // seedHtml is deliberately TRUE here and must be ignored: a New message + // has no original to take evidence from, so a composer reading it would be + // reading a field nothing filled in. + ComposeContext context = newContext(); + context.seedHtml = true; + + ComposeWindow window(context, off.config(), off.mailRoot()); + auto *toggle = window.findChild<QCheckBox *>(QStringLiteral("sendHtml")); + QVERIFY(toggle); + QVERIFY2(!toggle->isChecked(), "a New message ignored [compose] send_html"); + + ComposeFixture on; + QVERIFY(on.build(QStringLiteral("Drafts"), QStringLiteral("Sent"), + QStringLiteral("send_html=true"))); + ComposeContext forward = newContext(); + forward.kind = ComposeContext::Kind::Forward; + forward.seedHtml = false; + + ComposeWindow second(forward, on.config(), on.mailRoot()); + auto *secondToggle = + second.findChild<QCheckBox *>(QStringLiteral("sendHtml")); + QVERIFY(secondToggle); + QVERIFY2(secondToggle->isChecked(), + "a Forward seeded from the original rather than from config"); +} + +void TestMainWindow::disablingInputsCoversEveryFieldAndTheToolbar() +{ + ComposeFixture fixture; + // Zero delay: the countdown is skipped and the send commits at once, which + // is the state the inputs must already be disabled in. + QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"), + QStringLiteral("send_delay_ms=0"))); + + ComposeContext context = newContext(); + context.to = { QStringLiteral("someone@example.org") }; + + // Heap-allocated and tracked with a QPointer, because ComposeWindow sets + // WA_DeleteOnClose and this case really does complete a send: the window + // deletes itself on the way out, so a stack instance would be destroyed + // twice. Every other case here stays on the stack, since none of them + // closes. + QPointer<ComposeWindow> window = + new ComposeWindow(context, fixture.config(), fixture.mailRoot()); + auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body")); + QVERIFY(body); + body->setPlainText(QStringLiteral("Text.")); + + auto *toolbar = window->findChild<QToolBar *>(QStringLiteral("formatToolbar")); + auto *to = window->findChild<QLineEdit *>(QStringLiteral("to")); + auto *subject = window->findChild<QLineEdit *>(QStringLiteral("subject")); + auto *from = window->findChild<QComboBox *>(QStringLiteral("from")); + auto *toggle = window->findChild<QCheckBox *>(QStringLiteral("sendHtml")); + QVERIFY(toolbar && to && subject && from && toggle); + + QVERIFY(to->isEnabled()); + QVERIFY(!body->isReadOnly()); + + auto *sendAction = window->findChild<QAction *>(QStringLiteral("compose_send")); + QVERIFY2(sendAction, "no send action"); + sendAction->trigger(); + + // The message must not change between pressing Send and the bytes being + // built, so every input goes down for the WHOLE operation, countdown + // included. The body is made read-only rather than disabled, so its text + // stays selectable and legible while the send runs. + QVERIFY2(!to->isEnabled(), "the To field is still editable during a send"); + QVERIFY2(!subject->isEnabled(), "the subject is still editable"); + QVERIFY2(!from->isEnabled(), "the account can still be changed"); + QVERIFY2(!toggle->isEnabled(), "the HTML toggle can still be flipped"); + QVERIFY2(body->isReadOnly(), "the body is still writable during a send"); + QVERIFY2(!toolbar->isEnabled(), "the formatting toolbar is still live"); + auto *attachments = + window->findChild<QListWidget *>(QStringLiteral("attachments")); + QVERIFY(attachments); + QVERIFY2(!attachments->isEnabled(), + "the attachment list is still live during a send"); + + // /bin/true is the fixture's send command, so the send succeeds and the + // composer closes itself: the message went, and holding a composer open + // for a message already sent invites sending it twice. Waited on rather + // than asserted immediately, since the process is handed to the event loop + // and nothing here blocks on it. WA_DeleteOnClose then destroys the + // window, which is what the QPointer observes. + QTRY_VERIFY_WITH_TIMEOUT(window.isNull(), 15000); + + // And the sent copy really was filed, which is the stage after the send + // and the one whose failure the design treats as the worst outcome here. + const QString sentCur = + fixture.mailRoot() + QStringLiteral("/acct/Sent/cur"); + QCOMPARE(QDir(sentCur, {}, QDir::Name, QDir::Files).count(), 1u); +} + +void TestMainWindow::aFailedSendCanBeRetriedWithoutFilingTheWrongCopy() +{ + ComposeFixture fixture; + QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"), + QStringLiteral("send_delay_ms=0"))); + + // A stub whose outcome is switched by a sentinel file, so ONE configured + // command can fail and then succeed. It appends its stdin to a log, which + // is what makes the delivery count observable: the defect this guards + // against files a sent copy of the FIRST message when the second finishes, + // and a receiver count is the only thing that shows it. + QTemporaryDir stubDir; + QVERIFY(stubDir.isValid()); + const QString sentinel = stubDir.filePath(QStringLiteral("succeed")); + const QString stub = stubDir.filePath(QStringLiteral("send.sh")); + { + QFile script(stub); + QVERIFY(script.open(QIODevice::WriteOnly | QIODevice::Text)); + QTextStream out(&script); + out << "#!/bin/sh\n" + << "cat >> " << stubDir.filePath(QStringLiteral("stdin.log")) << "\n" + << "[ -f " << sentinel << " ] || { echo 'refused' >&2; exit 1; }\n" + << "exit 0\n"; + } + QVERIFY(QFile::setPermissions( + stub, QFileDevice::ReadOwner | QFileDevice::WriteOwner + | QFileDevice::ExeOwner)); + + // A FRESH Config, not a copy of the fixture's reloaded: Config::load() + // does not clear what a previous load put there, so a copy keeps the + // fixture's /bin/true and this test would silently exercise a command that + // always succeeds. Measured, and it produced a green nothing. + Config config; + { + const QString path = QStringLiteral("%1/retry.conf").arg(stubDir.path()); + QFile file(path); + QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text)); + QTextStream out(&file); + out << "[account.acct]\n" + << "name=Test User\n" + << "address=user@example.org\n" + << "maildir=acct\n" + << "trash=Trash\n" + << "drafts=Drafts\n" + << "sent=Sent\n" + << "send_command=" << stub << "\n" + << "\n[compose]\n" + << "send_delay_ms=0\n"; + file.close(); + config.load(path); + } + + ComposeContext context = newContext(); + context.to = { QStringLiteral("someone@example.org") }; + + QPointer<ComposeWindow> window = + new ComposeWindow(context, config, fixture.mailRoot()); + auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body")); + auto *sendAction = window->findChild<QAction *>(QStringLiteral("compose_send")); + QVERIFY(body && sendAction); + + body->setPlainText(QStringLiteral("FIRST attempt.")); + sendAction->trigger(); + + // The failure re-enables the composer intact and shows the stderr; the + // window stays open and the draft stays. + auto *pane = window->findChild<QWidget *>(QStringLiteral("sendLogPane")); + QVERIFY(pane); + QTRY_VERIFY_WITH_TIMEOUT(!pane->isHidden(), 15000); + + QVERIFY2(!window.isNull(), "a failed send closed the composer"); + QVERIFY2(body->isEnabled() && !body->isReadOnly(), + "a failed send left the composer disabled"); + + // Correct the message and send again, this time succeeding. Without + // Qt::SingleShotConnection on the per-send connect, the first send's + // lambda is still attached: the second result runs BOTH, and the first + // still holds the FIRST message's bytes, so it files a sent copy of the + // wrong message and acts on a dialog it already destroyed. + QFile marker(sentinel); + QVERIFY(marker.open(QIODevice::WriteOnly)); + marker.close(); + + body->setPlainText(QStringLiteral("SECOND attempt.")); + sendAction->trigger(); + + QTRY_VERIFY_WITH_TIMEOUT(window.isNull(), 15000); + + // Exactly ONE sent copy, and it is the second message. Two files, or one + // carrying the first attempt, is the accumulated-receiver defect. + const QString sentCur = fixture.mailRoot() + QStringLiteral("/acct/Sent/cur"); + const QStringList filed = + QDir(sentCur, {}, QDir::Name, QDir::Files).entryList(); + QCOMPARE(filed.size(), 1); + + QFile copy(sentCur + QLatin1Char('/') + filed.first()); + QVERIFY(copy.open(QIODevice::ReadOnly)); + const QByteArray bytes = copy.readAll(); + QVERIFY2(bytes.contains("SECOND attempt."), + "the filed copy is not the message that was sent"); + QVERIFY2(!bytes.contains("FIRST attempt."), + "the filed copy is the FIRST message, which never went"); +} + +void TestMainWindow::anUnchangedMessageIsNotWrittenAgain() +{ + ComposeFixture fixture; + QVERIFY(fixture.build()); + + ComposeWindow window(newContext(), fixture.config(), fixture.mailRoot()); + auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body")); + QVERIFY(body); + + body->setPlainText(QStringLiteral("Once.")); + QVERIFY(window.saveDraftNow()); + QCOMPARE(fixture.draftCount(), 1); + + const QStringList first = + QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList(); + QCOMPARE(first.size(), 1); + + // Nothing has changed, so nothing is written. Every autosave produces a + // Maildir write that mbsync uploads, so this check and the debounce + // together are what keep a message to a few revisions rather than dozens. + // + // The FILENAME is what shows it: DraftStore always generates a fresh name + // and unlinks the previous one, so a redundant write leaves exactly one + // file too, and a count alone cannot tell a skipped write from a repeated + // one. Two runs of this test asserting only on the count would pass + // against no check at all. + QVERIFY2(window.saveDraftNow(), "the redundant save reported failure"); + QCOMPARE(fixture.draftCount(), 1); + const QStringList second = + QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList(); + QCOMPARE(second, first); + + // And a real change still writes: a check that skipped everything would + // pass the assertion above and lose the user's text. + body->setPlainText(QStringLiteral("Twice.")); + QVERIFY(window.saveDraftNow()); + const QStringList third = + QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList(); + QCOMPARE(third.size(), 1); + QVERIFY2(third != first, "a changed message was not written"); +} + +void TestMainWindow::closingInsideTheDebounceStillSavesTheDraft() +{ + ComposeFixture fixture; + // A debounce far longer than this test, so the timer provably never fires + // and the only thing that can write is the close itself. + QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"), + QStringLiteral("autosave_interval_ms=600000"))); + + // Heap-allocated: WA_DeleteOnClose destroys the window on the way out, so + // a stack instance would be destroyed twice. + QPointer<ComposeWindow> window = + new ComposeWindow(newContext(), fixture.config(), fixture.mailRoot()); + auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body")); + QVERIFY(body); + + body->setPlainText(QStringLiteral("A paragraph typed and not yet saved.")); + QVERIFY(window->hasUnsavedEdits()); + + // The timer has NOT fired. Asserted rather than assumed: if it had, the + // draft below would prove nothing about the close path. + auto *timer = window->findChild<QTimer *>(QStringLiteral("autosave")); + QVERIFY(timer); + QVERIFY2(timer->isActive(), "the debounce is not running"); + QCOMPARE(fixture.draftCount(), 0); + + // The window manager's X button, which is the route that reaches + // closeEvent. Typing a paragraph and pressing it inside the debounce + // interval must not lose the text. + window->close(); + QTRY_VERIFY_WITH_TIMEOUT(window.isNull(), 5000); + + QCOMPARE(fixture.draftCount(), 1); + const QStringList files = + QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList(); + QCOMPARE(files.size(), 1); + QFile written(fixture.draftsCur() + QLatin1Char('/') + files.first()); + QVERIFY(written.open(QIODevice::ReadOnly)); + QVERIFY2(written.readAll().contains("A paragraph typed and not yet saved."), + "the close wrote a draft that is not the text that was typed"); +} + +void TestMainWindow::closingAfterASendWritesNoFurtherDraft() +{ + ComposeFixture fixture; + QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"), + QStringLiteral("send_delay_ms=0"))); + + ComposeContext context = newContext(); + context.to = { QStringLiteral("someone@example.org") }; + + QPointer<ComposeWindow> window = + new ComposeWindow(context, fixture.config(), fixture.mailRoot()); + auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body")); + auto *sendAction = window->findChild<QAction *>(QStringLiteral("compose_send")); + QVERIFY(body && sendAction); + + body->setPlainText(QStringLiteral("Text that is about to be sent.")); + + // A draft on disk first, so the send's removal of it is observable and the + // close-path save has something it could wrongly put back. + QVERIFY(window->saveDraftNow()); + QCOMPARE(fixture.draftCount(), 1); + + // Now edit again WITHOUT saving, so m_dirty is true at the moment the + // send completes. This is what makes the m_finished guard load-bearing: + // without it the close that follows a successful send would write a draft + // for a message already sent, restoring the file the send just unlinked. + body->setPlainText(QStringLiteral("Text that is about to be sent, edited.")); + QVERIFY(window->hasUnsavedEdits()); + + sendAction->trigger(); + QTRY_VERIFY_WITH_TIMEOUT(window.isNull(), 15000); + + // The message went, so the drafts folder is EMPTY. A draft left behind is + // a message the user sees waiting to be finished when it has already been + // delivered. + QCOMPARE(fixture.draftCount(), 0); + + // And the sent copy is there, so this is a completed send rather than a + // send that never happened leaving nothing behind either way. + const QString sentCur = fixture.mailRoot() + QStringLiteral("/acct/Sent/cur"); + QCOMPARE(QDir(sentCur, {}, QDir::Name, QDir::Files).count(), 1u); +} + +void TestMainWindow::aCloseDuringTheCountdownIsRefused() +{ + ComposeFixture fixture; + // A countdown long enough to close inside. The default is 5000; this is + // the window the guard exists for and it must be provably still open when + // the close is attempted. + QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"), + QStringLiteral("send_delay_ms=30000"))); + + ComposeContext context = newContext(); + context.to = { QStringLiteral("someone@example.org") }; + + QPointer<ComposeWindow> window = + new ComposeWindow(context, fixture.config(), fixture.mailRoot()); + auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body")); + auto *sendAction = window->findChild<QAction *>(QStringLiteral("compose_send")); + QVERIFY(body && sendAction); + body->setPlainText(QStringLiteral("Sent after a countdown.")); + + sendAction->trigger(); + + // Still counting down: the popup is up and nothing has been sent. The + // sent folder is the evidence, since it is written only after the command + // succeeds. + auto *dialog = window->findChild<SendDialog *>(); + QVERIFY2(dialog, "no send popup"); + QVERIFY2(!dialog->isCommitted(), "the countdown already committed"); + + // Close during the countdown. Refused: accepting it would destroy this + // window, take the parented SendDialog down with it, and committed() would + // never fire. The user pressed Send, watched a countdown, and would + // believe the mail went. + window->close(); + + // Given a moment for a deletion event to be delivered if one was posted, + // then asserted still alive. An immediate check would pass against a + // deleteLater() already queued. + QTest::qWait(300); + QVERIFY2(!window.isNull(), + "the close was accepted during the countdown, so the send was " + "silently abandoned after the user pressed Send"); + QVERIFY2(window->isVisible() || !window.isNull(), "the window went away"); + + // The send never happened, which is the point: nothing was filed. + const QString sentCur = fixture.mailRoot() + QStringLiteral("/acct/Sent/cur"); + QCOMPARE(QDir(sentCur, {}, QDir::Name, QDir::Files).count(), 0u); + + // Cleaned up by hand, since the window refuses to close while the popup is + // up and the test must not leak it into the next case. + delete window; +} + +void TestMainWindow::aFailedSendKeepsTheTextThatFailedToGo() +{ + ComposeFixture fixture; + QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"), + QStringLiteral("send_delay_ms=0"))); + + QTemporaryDir stubDir; + QVERIFY(stubDir.isValid()); + const QString stub = stubDir.filePath(QStringLiteral("fail.sh")); + { + QFile script(stub); + QVERIFY(script.open(QIODevice::WriteOnly | QIODevice::Text)); + QTextStream out(&script); + out << "#!/bin/sh\ncat > /dev/null\necho 'refused' >&2\nexit 1\n"; + } + QVERIFY(QFile::setPermissions( + stub, QFileDevice::ReadOwner | QFileDevice::WriteOwner + | QFileDevice::ExeOwner)); + + Config config; + { + const QString path = stubDir.filePath(QStringLiteral("fail.conf")); + QFile file(path); + QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Text)); + QTextStream out(&file); + out << "[account.acct]\n" + << "name=Test User\naddress=user@example.org\n" + << "maildir=acct\ntrash=Trash\ndrafts=Drafts\nsent=Sent\n" + << "send_command=" << stub << "\n" + << "\n[compose]\nsend_delay_ms=0\n"; + file.close(); + config.load(path); + } + + ComposeContext context = newContext(); + context.to = { QStringLiteral("someone@example.org") }; + + QPointer<ComposeWindow> window = + new ComposeWindow(context, config, fixture.mailRoot()); + auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body")); + auto *sendAction = window->findChild<QAction *>(QStringLiteral("compose_send")); + QVERIFY(body && sendAction); + + // An OLD revision on disk, then an edit that is not saved. send() builds + // from the widgets without saving, so without the fix the file left behind + // after the failure is the old text: the user watches their correction be + // sent, sees it fail, and gets the uncorrected version back. + body->setPlainText(QStringLiteral("The ORIGINAL text.")); + QVERIFY(window->saveDraftNow()); + QCOMPARE(fixture.draftCount(), 1); + + body->setPlainText(QStringLiteral("The CORRECTED text.")); + sendAction->trigger(); + + auto *pane = window->findChild<QWidget *>(QStringLiteral("sendLogPane")); + QVERIFY(pane); + QTRY_VERIFY_WITH_TIMEOUT(!pane->isHidden(), 15000); + QVERIFY2(!window.isNull(), "a failed send closed the composer"); + + // Exactly one draft, and it is the text that was attempted. + QCOMPARE(fixture.draftCount(), 1); + const QStringList files = + QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList(); + QCOMPARE(files.size(), 1); + QFile written(fixture.draftsCur() + QLatin1Char('/') + files.first()); + QVERIFY(written.open(QIODevice::ReadOnly)); + const QByteArray bytes = written.readAll(); + QVERIFY2(bytes.contains("The CORRECTED text."), + "the draft kept after a failed send is not what was attempted"); + QVERIFY2(!bytes.contains("The ORIGINAL text."), + "the draft kept after a failed send is the PRE-EDIT revision"); + + delete window; +} + +void TestMainWindow::aSmallSizeLimitIsNotDescribedAsZeroMegabytes() +{ + // Integer MB division made every figure under a megabyte read as "0 MB", + // in BOTH halves of the same sentence: "'x' is 0 MB. Many mail servers + // refuse messages above about 0 MB." + QVERIFY2(!ComposeWindow::humanSize(500 * 1024).contains(QStringLiteral("0 MB")), + "half a megabyte is described as 0 MB"); + QVERIFY2(!ComposeWindow::humanSize(1000).contains(QStringLiteral("0 MB")), + "a kilobyte is described as 0 MB"); + + // The unit steps down rather than reporting zero of a larger one. + QVERIFY(ComposeWindow::humanSize(500 * 1024).contains(QStringLiteral("KB"))); + QVERIFY(ComposeWindow::humanSize(512).contains(QStringLiteral("bytes"))); + + // A decimal while the figure is small enough for it to say something, so + // 26 MB and 26.2 MB are not the same string. + QVERIFY(ComposeWindow::humanSize(26214400).contains(QStringLiteral("MB"))); + QVERIFY2(ComposeWindow::humanSize(1024 * 1024 * 3 / 2) + .contains(QStringLiteral(".")), + "1.5 MB lost its decimal"); +} + #include "test_mainwindow.moc" diff --git a/translations/qtmaildir_it_IT.ts b/translations/qtmaildir_it_IT.ts index 83ac087..b2d96eb 100644 --- a/translations/qtmaildir_it_IT.ts +++ b/translations/qtmaildir_it_IT.ts @@ -2,6 +2,137 @@ <!DOCTYPE TS> <TS version="2.1" language="it_IT"> <context> + <name>ComposeWindow</name> + <message> + <source>Compose</source> + <translation>Componi</translation> + </message> + <message> + <source>From:</source> + <translation>Da:</translation> + </message> + <message> + <source>To:</source> + <translation>A:</translation> + </message> + <message> + <source>Cc:</source> + <translation>Cc:</translation> + </message> + <message> + <source>Bcc:</source> + <translation>Ccn:</translation> + </message> + <message> + <source>Subject:</source> + <translation>Oggetto:</translation> + </message> + <message> + <source>Also send a formatted copy</source> + <translation>Invia anche una copia formattata</translation> + </message> + <message> + <source>Sends the message as plain text with a formatted version alongside it. The plain text is what you typed.</source> + <translation>Invia il messaggio come testo semplice con accanto una versione formattata. Il testo semplice è quello che hai scritto.</translation> + </message> + <message> + <source>Send output</source> + <translation>Output dell’invio</translation> + </message> + <message> + <source>Close</source> + <translation>Chiudi</translation> + </message> + <message> + <source>Formatting</source> + <translation>Formattazione</translation> + </message> + <message> + <source>Bold</source> + <translation>Grassetto</translation> + </message> + <message> + <source>Italic</source> + <translation>Corsivo</translation> + </message> + <message> + <source>Code</source> + <translation>Codice</translation> + </message> + <message> + <source>Strikethrough</source> + <translation>Barrato</translation> + </message> + <message> + <source>Link</source> + <translation>Collegamento</translation> + </message> + <message> + <source>Quote</source> + <translation>Citazione</translation> + </message> + <message> + <source>Attach...</source> + <translation>Allega...</translation> + </message> + <message> + <source>Attach files</source> + <translation>Allega file</translation> + </message> + <message> + <source>Remove attachment</source> + <translation>Rimuovi allegato</translation> + </message> + <message> + <source>Send</source> + <translation>Invia</translation> + </message> + <message> + <source>Large attachment</source> + <translation>Allegato di grandi dimensioni</translation> + </message> + <message> + <source>'%1' is %2. Many mail servers refuse messages above about %3. Attach it anyway?</source> + <translation>'%1' occupa %2. Molti server di posta rifiutano messaggi oltre i %3 circa. Allegarlo comunque?</translation> + </message> + <message> + <source>The draft could not be saved: %1</source> + <translation>Non è stato possibile salvare la bozza: %1</translation> + </message> + <message> + <source>The send command reported no output.</source> + <translation>Il comando di invio non ha prodotto alcun output.</translation> + </message> + <message> + <source>Cannot send</source> + <translation>Impossibile inviare</translation> + </message> + <message> + <source>The account '%1' has no send command configured.</source> + <translation>L’account '%1' non ha un comando di invio configurato.</translation> + </message> + <message> + <source>Sent, but not filed</source> + <translation>Inviato, ma non archiviato</translation> + </message> + <message> + <source>The message was sent, but the copy could not be written to '%1' for account '%2': + +%3 + +The message HAS been sent. Do not send it again.</source> + <translation>Il messaggio è stato inviato, ma non è stato possibile scrivere la copia in '%1' per l’account '%2': + +%3 + +Il messaggio È stato inviato. Non inviarlo di nuovo.</translation> + </message> + <message> + <source>The send command could not be started.</source> + <translation>Non è stato possibile avviare il comando di invio.</translation> + </message> +</context> +<context> <name>Config</name> <message> <source>Language '%1' is not a locale name; using the system language. Expected something like 'it' or 'it_IT'.</source> @@ -1329,6 +1460,18 @@ <source>Cannot write to %1: %2</source> <translation>Impossibile scrivere su %1: %2</translation> </message> + <message> + <source>%1 MB</source> + <translation>%1 MB</translation> + </message> + <message> + <source>%1 KB</source> + <translation>%1 KB</translation> + </message> + <message> + <source>%1 bytes</source> + <translation>%1 byte</translation> + </message> </context> <context> <name>QueryCompleter</name> |
