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