summaryrefslogtreecommitdiffstats
path: root/src/messagesender.cpp
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-23 21:15:13 +0200
committerDanilo M. <danix@danix.xyz>2026-08-23 21:15:13 +0200
commitfabcf080652c6e5d57bf234be5e100769a9b965b (patch)
tree0de4222c1e2aab58c38d34c9e0e3c37c68298cc8 /src/messagesender.cpp
parentc50bea78e036518ce1a2a3eb899bbb5e305affea (diff)
parentddcae8d02ef46db522b3cf6c228196c7a66a6432 (diff)
downloadqtmaildir-fabcf080652c6e5d57bf234be5e100769a9b965b.tar.gz
qtmaildir-fabcf080652c6e5d57bf234be5e100769a9b965b.zip
Merge branch 'compose-and-send': composing and sending mail
Item 123, built over 2026-08-20 to 2026-08-23 in thirteen tasks against docs/superpowers/specs/2026-08-20-compose-and-send-design.md. The application writes mail now. A composer window per message, markdown as the body, drafts autosaving into the account's Maildir, and sending through a per-account command on stdin rather than any network protocol of this program's own. A countdown with an Undo stands between pressing Send and the command running. Two things came in alongside it. The notmuch auto-tagging hooks moved here from the retiring `mailctl` project and learned that mail this application files itself never arrived, so sent mail and drafts stop appearing in the inbox. And the v1/v2 language is retired: semver on the user-visible surface is the rule, and those labels described a split that composing made obsolete. Hand tested against a fake send command rather than a real one, deliberately: New, Reply and Forward all produce correct messages, a forwarded attachment survives intact, and the sent copy is filed. That testing found the two defects fixed on this branch, and both were invisible to the suite: a composer orphaned by quitting the main window, and every sent message tagged `inbox`. Twenty-two defects were found in the plan document's own draft code while building it, which is why CLAUDE.md says to treat every code block in a plan as a draft.
Diffstat (limited to 'src/messagesender.cpp')
-rw-r--r--src/messagesender.cpp197
1 files changed, 197 insertions, 0 deletions
diff --git a/src/messagesender.cpp b/src/messagesender.cpp
new file mode 100644
index 0000000..f336028
--- /dev/null
+++ b/src/messagesender.cpp
@@ -0,0 +1,197 @@
+/*
+ * 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 "messagesender.h"
+
+MessageSender::MessageSender(QObject *parent)
+ : QObject(parent)
+{
+ // Separate channels, unlike MailSync's MergedChannels: there is no log
+ // pane to fill here, and stderr alone is what a failure has to report.
+ // Merging them would put the command's ordinary chatter into the error
+ // message shown for a rejected send.
+ m_process.setProcessChannelMode(QProcess::SeparateChannels);
+
+ connect(&m_process, &QProcess::finished,
+ this, &MessageSender::handleFinished);
+ connect(&m_process, &QProcess::errorOccurred,
+ this, &MessageSender::handleError);
+}
+
+MessageSender::~MessageSender()
+{
+ if (m_process.state() == QProcess::NotRunning)
+ return;
+
+ // A send is a live SMTP conversation and abandoning one has a genuinely
+ // unknown outcome, so give the command a bounded chance to finish rather
+ // than killing it outright. Measured: without this, a one-second command
+ // destroyed 100ms in is killed and its work does not complete, announced
+ // only by a Qt warning on stderr. With it, the same command completes and
+ // the destructor costs the ~1s the command actually needed.
+ //
+ // The write channel is closed first because the command may still be
+ // reading: a command blocked on stdin would otherwise never reach EOF and
+ // would burn the whole timeout for no reason.
+ m_process.closeWriteChannel();
+ if (m_process.waitForFinished(kShutdownWaitMs))
+ return;
+
+ // Still running. A destructor cannot block a quitting application forever,
+ // so the process is killed deliberately here rather than by ~QProcess.
+ //
+ // NOTHING IS EMITTED. The outcome after a kill is unknown: the message may
+ // have been fully delivered, partially delivered, or not sent at all, and
+ // this class reports two outcomes only. Emitting finished(false, ...) would
+ // report "not sent" for a message that may well have been, which is the
+ // mailsync.sh mistake pointing the other way. Emitting finished(true, ...)
+ // would be worse. A caller that must know has to keep this object alive
+ // until finished() arrives.
+ //
+ // Claiming the report BEFORE the kill is what makes that true, and it is
+ // not optional: kill() makes QProcess deliver finished(CrashExit), which
+ // reaches handleFinished and would emit exactly the untruthful "not sent"
+ // this comment forbids. Measured, by a test that failed against the
+ // version without these two lines. This is also the one place m_reported
+ // does live work, rather than the defence-in-depth it is on the signal
+ // paths.
+ m_reported = true;
+ m_process.kill();
+ m_process.waitForFinished(kShutdownWaitMs);
+}
+
+bool MessageSender::isRunning() const
+{
+ return m_process.state() != QProcess::NotRunning;
+}
+
+bool MessageSender::send(const QString &command, const QByteArray &bytes)
+{
+ if (command.trimmed().isEmpty() || isRunning())
+ return false;
+
+ // splitCommand gives an argument list; running through a shell would make
+ // every recipient address, display name and config value a potential
+ // injection point. QProcess hands the list to execve, so a `;` or a
+ // `$(...)` in the configured command is a literal argument with nothing to
+ // interpret it. Note that splitCommand strips DOUBLE quotes only.
+ //
+ // Nothing from the message reaches the argument list at all: the command
+ // reads its recipients from the message's own headers, which is what `-t`
+ // means in the documented example.
+ const QStringList parts = QProcess::splitCommand(command);
+ if (parts.isEmpty())
+ return false;
+
+ m_command = command;
+ m_reported = false;
+
+ m_process.setProgram(parts.first());
+ m_process.setArguments(parts.mid(1));
+
+ // Deliberately no waitForStarted(): this runs on the GUI thread and the
+ // interface must stay responsive while a send is in flight. A failed
+ // launch arrives via errorOccurred(FailedToStart) instead, which QProcess
+ // emits INSTEAD OF finished() rather than before it (measured).
+ m_process.start();
+
+ // Written after start() and before the process has necessarily launched,
+ // which is safe: QProcess buffers and drains as the reader consumes.
+ // Measured with a 320KB payload against a `cat` stub, which arrived
+ // byte-identical, so a message with an attachment does not deadlock on the
+ // 64KB pipe buffer.
+ m_process.write(bytes);
+
+ // The message goes on stdin and the channel is closed, so a command
+ // reading to EOF terminates. Without closeWriteChannel() a command like
+ // `cat` waits forever and the popup never leaves its Sending stage.
+ m_process.closeWriteChannel();
+
+ return true;
+}
+
+void MessageSender::handleFinished(int exitCode, QProcess::ExitStatus status)
+{
+ // errorOccurred may already have reported this failure. Reporting twice
+ // would close the popup and then act on a second result.
+ //
+ // This guard IS load-bearing, on exactly one path: the destructor sets
+ // m_reported before kill(), because kill() makes QProcess deliver
+ // finished(CrashExit) and without the flag this handler would emit a
+ // "not sent" for a message whose fate is genuinely unknown. A test fails
+ // against its removal.
+ //
+ // On the two signal paths it is defence in depth and currently cannot
+ // fire: handleError is filtered to FailedToStart, and FailedToStart is
+ // never followed by finished() (measured). An instrumented run of the
+ // whole suite recorded zero hits there, including on the crash and
+ // write-error paths that DO emit both signals. It stays because the day
+ // someone widens handleError to report another error, the double report is
+ // silent and costs a duplicate sent copy.
+ if (m_reported)
+ return;
+ m_reported = true;
+
+ // The exit status is the only authority. Nothing is inferred from what the
+ // command printed: mailsync.sh records what a wrong answer here costs, and
+ // a send reported as succeeding files a sent copy for a message that never
+ // left the machine.
+ const bool sent = status == QProcess::NormalExit && exitCode == 0;
+ if (sent) {
+ emit finished(true, QString());
+ return;
+ }
+
+ // Exit 75 is deliberately NOT special. See the header.
+ QString error = QString::fromUtf8(m_process.readAllStandardError()).trimmed();
+ if (error.isEmpty()) {
+ // A failure with a blank explanation gives the user nothing to act on,
+ // so the status stands in for the reason the command did not give.
+ error = status == QProcess::CrashExit
+ ? tr("The send command crashed.")
+ : tr("The send command exited with status %1 and said nothing.")
+ .arg(exitCode);
+ }
+ emit finished(false, error);
+}
+
+void MessageSender::handleError(QProcess::ProcessError error)
+{
+ // QProcess emits errorOccurred(FailedToStart) INSTEAD OF finished(), so
+ // without this the caller waits forever. Measured on Qt 6.11 for both a
+ // missing binary and a non-executable file: one errorOccurred, no
+ // finished().
+ //
+ // Every other error IS followed by finished() and is left to it, which is
+ // not merely tidiness. A command that exits without draining a large stdin
+ // emits errorOccurred(WriteError) and then finished() with the command's
+ // real exit code and its real stderr; reporting the write error here would
+ // replace the server's own rejection message with a plumbing detail, and
+ // reporting it as well as finished() would deliver two results for one
+ // message.
+ if (error != QProcess::FailedToStart)
+ return;
+ if (m_reported)
+ return;
+ m_reported = true;
+
+ emit finished(false,
+ tr("The send command '%1' could not be started. Check that "
+ "the path is correct and the file is executable.")
+ .arg(m_command));
+}