aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/CMakeLists.txt1
-rw-r--r--src/composewindow.cpp851
-rw-r--r--src/composewindow.h223
-rw-r--r--tests/test_mainwindow.cpp1154
-rw-r--r--translations/qtmaildir_it_IT.ts143
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>&apos;%1&apos; is %2. Many mail servers refuse messages above about %3. Attach it anyway?</source>
+ <translation>&apos;%1&apos; 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 &apos;%1&apos; has no send command configured.</source>
+ <translation>L’account &apos;%1&apos; 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 &apos;%1&apos; for account &apos;%2&apos;:
+
+%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 &apos;%1&apos; per l’account &apos;%2&apos;:
+
+%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 &apos;%1&apos; is not a locale name; using the system language. Expected something like &apos;it&apos; or &apos;it_IT&apos;.</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>