diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-21 10:12:26 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-21 10:12:26 +0200 |
| commit | 6488810c779970094b86079c8688d83d8529fab0 (patch) | |
| tree | e9ae7fc96d82cb6728d95c99958a947c18748788 /src/messagesender.cpp | |
| parent | 9b1b371856c148dc668587254c51134ca9d4b605 (diff) | |
| download | qtmaildir-6488810c779970094b86079c8688d83d8529fab0.tar.gz qtmaildir-6488810c779970094b86079c8688d83d8529fab0.zip | |
feat(compose): hand outgoing mail to the send command, item 123
MessageSender runs the configured command with the message on stdin and
judges the result by its exit status alone. Nothing here waits on the
event loop, so a send does not block the GUI thread; a 1.6MB payload was
probed through a reading stub without deadlocking the pipe buffer.
The command is split and passed to QProcess as a program and an argument
list, never through a shell. A test asserts that by giving the command
shell metacharacters and checking that the marker file a shell would have
created does not exist, so the property fails a mutation rather than
resting on a comment.
Four corrections to the plan's draft. splitCommand handles double quotes
only, so a single-quoted argument splits wrongly and the header now says
so. A crashing command delivers finished(11, CrashExit) and would have
been reported as "exited with status 11", so a crash branch was added. A
command that exits without draining a large stdin emits WriteError before
finished(), which the draft handled correctly and by luck, untested. And
an empty send_command is checked after trimming.
Two contract gaps found in review, both about what this class promises
rather than what it does.
The exactly-once guarantee covers the EMIT, not what a caller receives: a
long-lived sender plus a connect() inside each send accumulates
receivers, and the second result then runs the first send's lambda too,
filing a sent copy of the wrong message. The header now scopes the
promise and requires Qt::SingleShotConnection. The plan's Task 11 call
site already had that flag, sixty-nine lines below the connect and
outside anything a reader would see, so the plan gained a note where
someone retyping it will read it.
And destruction mid-send killed the command with no report, announced
only by a Qt warning: a live SMTP conversation abandoned, possibly
partially delivered, while the user believes it was cancelled. The
destructor now closes stdin, waits a bounded five seconds, and only then
kills. It emits nothing either way, because the outcome after a kill is
genuinely unknown and reporting "not sent" for a message that may have
gone out is the mailsync.sh mistake pointing the other way. Claiming
m_reported before kill() is what makes that true, since kill() delivers
finished(CrashExit), which would otherwise emit exactly that untruth.
No timeout on the send itself: killing a slow but working send is worse
than waiting. Task 10 owns the popup, and deliberately offers no cancel
after commit, so this class promises none either.
Also refreshes the translations Task 5 left out. That gap was invisible
because test_translations builds its rows from the .ts file, so a string
that never entered it is never asserted on.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QP2g3b3kuLx6AYFCNEz6UR
Diffstat (limited to 'src/messagesender.cpp')
| -rw-r--r-- | src/messagesender.cpp | 197 |
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)); +} |
