From 9b1b371856c148dc668587254c51134ca9d4b605 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 21 Aug 2026 09:36:04 +0200 Subject: feat(compose): save drafts atomically into a Maildir, item 123 DraftStore::write() renders a built message into /cur with the given Maildir flags, writing through QSaveFile and unlinking the previous revision only after the new file is in place. Two orderings here are load-bearing and both are covered by a test that was checked against the mutation that breaks it. The unlink runs only after the write has succeeded, so a failed save leaves the previous revision intact rather than losing both. Provoking that failure needs care: the plan's version used an unwritable path where mkpath() fails and the function returns before reaching either the write or the unlink, so a mutation moving the unlink up survived it. The test uses an existing but read-only cur/ instead, where the failure lands at the write. And the size comparison stays ahead of commit() in the condition, because QSaveFile::commit() returns true after a short write and renames the truncated bytes into place: measured, write 4096 of 65536 with commit reporting true and the file left in the listing. What leaves the directory empty is the short-circuit returning before commit() is reached, after which ~QSaveFile() discards the uncommitted scratch file. Reducing the condition to !file.commit() looks like a simplification and writes a truncated draft into cur/, where notmuch would index it and mbsync would upload it. Resolving the mail root stays the caller's job, per item 124; this takes an absolute folder path and composes nothing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QP2g3b3kuLx6AYFCNEz6UR --- src/draftstore.cpp | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 src/draftstore.cpp (limited to 'src/draftstore.cpp') diff --git a/src/draftstore.cpp b/src/draftstore.cpp new file mode 100644 index 0000000..d458bff --- /dev/null +++ b/src/draftstore.cpp @@ -0,0 +1,82 @@ +/* + * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs + * Copyright (C) 2026 Danilo M. + * + * 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 "draftstore.h" + +#include "maildirname.h" + +#include +#include +#include +#include + +DraftStore::Result DraftStore::write(const QString &folderPath, + const QByteArray &bytes, + const QString &flags, + const QString &previousPath) +{ + Result result; + + if (folderPath.isEmpty()) { + result.error = QObject::tr("No folder was configured to write to."); + return result; + } + + // cur/, never new/. A file in new/ is re-announced as fresh mail by every + // reader of the Maildir, so an autosaved draft would arrive as a new + // message on every revision. + const QString curPath = folderPath + QStringLiteral("/cur"); + if (!QDir().mkpath(curPath)) { + result.error = QObject::tr("Cannot create the folder %1.").arg(curPath); + return result; + } + + // A FRESH name, with no previous one to preserve flags from: a draft is + // newly composed, and MessageBuilder's bytes carry no filename. The flags + // are appended here instead. + const QString name = MaildirName::fresh(QString()) + + QStringLiteral(":2,") + flags; + const QString target = curPath + QLatin1Char('/') + name; + + // QSaveFile: writes to a temporary and renames into place, so a reader + // never sees a half-written message. mbsync and notmuch both watch this + // directory. + QSaveFile file(target); + if (!file.open(QIODevice::WriteOnly)) { + result.error = QObject::tr("Cannot write to %1: %2") + .arg(target, file.errorString()); + return result; + } + + if (file.write(bytes) != bytes.size() || !file.commit()) { + result.error = QObject::tr("Cannot write to %1: %2") + .arg(target, file.errorString()); + return result; + } + + result.path = target; + + // AFTER the new file is safely in place, never before: unlinking first + // would lose the draft entirely if the write then failed. A failure to + // remove the old revision is not reported as a failure of the write, + // because the new revision IS on disk; the cost is one stale file. + if (!previousPath.isEmpty() && previousPath != target) + QFile::remove(previousPath); + + return result; +} -- cgit v1.2.3