From ccf6f436c00d95c2caa696d583ec23d667ff3e60 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Thu, 20 Aug 2026 18:34:15 +0200 Subject: refactor(maildir): extract freshMaildirName for reuse, item 123 DraftStore needs the same filename generation moveMessages() already has, and duplicating it would duplicate a correctness property rather than a convenience: the comment records that carrying mbsync's ,U= infix across a folder boundary produced 'Maildir error: duplicate UID' on real mail. A pure move with no behaviour change, committed on its own so a bisect can tell it apart from the feature that needed it. The function gains its own tests, including the UID-infix case that previously had none. --- src/CMakeLists.txt | 1 + src/maildirname.cpp | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/maildirname.h | 41 ++++++++++++++++++++++++++ src/notmuchworker.cpp | 64 ++--------------------------------------- 4 files changed, 125 insertions(+), 61 deletions(-) create mode 100644 src/maildirname.cpp create mode 100644 src/maildirname.h (limited to 'src') diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 6108696..12168a5 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -11,6 +11,7 @@ add_library(qtmaildir_lib STATIC marks.cpp carddelegate.cpp notmuchworker.cpp + maildirname.cpp tagchip.cpp tagcolors.cpp savequerydialog.cpp diff --git a/src/maildirname.cpp b/src/maildirname.cpp new file mode 100644 index 0000000..6263aec --- /dev/null +++ b/src/maildirname.cpp @@ -0,0 +1,80 @@ +/* + * 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 "maildirname.h" + +#include +#include +#include + +namespace MaildirName { + +/// A fresh Maildir filename for a message being moved between folders, +/// preserving only its `:2,` suffix. +/// +/// mbsync's manual is explicit about why this exists, under "the more +/// efficient default UID mapping scheme": "it is important that the MUA +/// renames files when moving them between Maildir folders", and "the general +/// expectation is that a completely new filename is generated as if the +/// message was new". +/// +/// The `,U=` infix mbsync writes is its per-folder IMAP UID. Carrying it +/// into another folder makes it a claim about a folder the file is no longer +/// in; moving a message out and back then reinserts a UID the server has +/// since reassigned, and mbsync refuses the folder with `Maildir error: +/// duplicate UID`. Measured on real mail, four collisions in one folder from +/// a single move-and-restore. +/// +/// The FLAGS are kept, deliberately, and that is not a contradiction of +/// "as if the message was new". They record seen, flagged and replied, and +/// `maildir.synchronize_flags` is true, so notmuch reads them back as tags: +/// dropping them would mark every deleted message unread and lose Important +/// on the way to the trash. Only the unique part is regenerated. +QString fresh(const QString &oldName) +{ + // The `:2,` suffix, when there is one. `info` is everything from the + // separator on, so an empty-flag `:2,` is preserved as faithfully as + // `:2,FS`. + QString info; + const int sep = oldName.indexOf(QStringLiteral(":2,")); + if (sep >= 0) + info = oldName.mid(sep); + + // The conventional left-to-right unique part: time, a per-process counter, + // the pid, the host. The counter is what makes two messages moved in the + // same second distinct, which a timestamp alone does not guarantee. + static quint64 counter = 0; + const qint64 now = QDateTime::currentSecsSinceEpoch(); + const QString host = QHostInfo::localHostName().isEmpty() + ? QStringLiteral("localhost") + : QHostInfo::localHostName(); + + return QStringLiteral("%1.M%2P%3Q%4.%5%6") + .arg(now) + .arg(QDateTime::currentMSecsSinceEpoch() % 1000) + .arg(QCoreApplication::applicationPid()) + .arg(++counter) + // A `/` or a `:` in a hostname would break the path or the flag + // separator. Neither is legal in a hostname, so this is belt and + // braces rather than a known case. + .arg(QString(host).replace(QLatin1Char('/'), QLatin1Char('_')) + .replace(QLatin1Char(':'), QLatin1Char('_'))) + .arg(info); +} + +} // namespace MaildirName diff --git a/src/maildirname.h b/src/maildirname.h new file mode 100644 index 0000000..f24bc71 --- /dev/null +++ b/src/maildirname.h @@ -0,0 +1,41 @@ +/* + * 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. + */ + +#pragma once + +#include + +/// Maildir filename generation, shared by every path that writes a message +/// file: NotmuchWorker::moveMessages() and DraftStore. +/// +/// A namespace rather than a class; there is no state beyond a counter. +namespace MaildirName { + +/// A fresh, unique Maildir filename, preserving \p oldName's flag suffix. +/// +/// A FRESH name, never a reuse. mbsync writes a `,U=` infix that is +/// meaningful only within one folder, and carrying it across a folder +/// boundary produced "Maildir error: duplicate UID" on real mail. Only the +/// `:2,` flag suffix is carried, because the flags describe the message +/// rather than its position. +/// +/// Pass an empty string for a message that has no previous name, which is +/// what a newly composed draft is. +QString fresh(const QString &oldName); + +} // namespace MaildirName diff --git a/src/notmuchworker.cpp b/src/notmuchworker.cpp index d0274cd..8c28ec5 100644 --- a/src/notmuchworker.cpp +++ b/src/notmuchworker.cpp @@ -20,16 +20,15 @@ #include -#include #include #include #include #include -#include #include #include +#include "maildirname.h" #include "mimeparser.h" #include "nmraii.h" @@ -687,63 +686,6 @@ void NotmuchWorker::applyTags(const TagChange &change) emit tagsApplied(change); } -namespace { - -/// A fresh Maildir filename for a message being moved between folders, -/// preserving only its `:2,` suffix. -/// -/// mbsync's manual is explicit about why this exists, under "the more -/// efficient default UID mapping scheme": "it is important that the MUA -/// renames files when moving them between Maildir folders", and "the general -/// expectation is that a completely new filename is generated as if the -/// message was new". -/// -/// The `,U=` infix mbsync writes is its per-folder IMAP UID. Carrying it -/// into another folder makes it a claim about a folder the file is no longer -/// in; moving a message out and back then reinserts a UID the server has -/// since reassigned, and mbsync refuses the folder with `Maildir error: -/// duplicate UID`. Measured on real mail, four collisions in one folder from -/// a single move-and-restore. -/// -/// The FLAGS are kept, deliberately, and that is not a contradiction of -/// "as if the message was new". They record seen, flagged and replied, and -/// `maildir.synchronize_flags` is true, so notmuch reads them back as tags: -/// dropping them would mark every deleted message unread and lose Important -/// on the way to the trash. Only the unique part is regenerated. -QString freshMaildirName(const QString &oldName) -{ - // The `:2,` suffix, when there is one. `info` is everything from the - // separator on, so an empty-flag `:2,` is preserved as faithfully as - // `:2,FS`. - QString info; - const int sep = oldName.indexOf(QStringLiteral(":2,")); - if (sep >= 0) - info = oldName.mid(sep); - - // The conventional left-to-right unique part: time, a per-process counter, - // the pid, the host. The counter is what makes two messages moved in the - // same second distinct, which a timestamp alone does not guarantee. - static quint64 counter = 0; - const qint64 now = QDateTime::currentSecsSinceEpoch(); - const QString host = QHostInfo::localHostName().isEmpty() - ? QStringLiteral("localhost") - : QHostInfo::localHostName(); - - return QStringLiteral("%1.M%2P%3Q%4.%5%6") - .arg(now) - .arg(QDateTime::currentMSecsSinceEpoch() % 1000) - .arg(QCoreApplication::applicationPid()) - .arg(++counter) - // A `/` or a `:` in a hostname would break the path or the flag - // separator. Neither is legal in a hostname, so this is belt and - // braces rather than a known case. - .arg(QString(host).replace(QLatin1Char('/'), QLatin1Char('_')) - .replace(QLatin1Char(':'), QLatin1Char('_'))) - .arg(info); -} - -} // namespace - void NotmuchWorker::moveMessages(const QStringList &messageIds, const QString &destFolder) { @@ -822,11 +764,11 @@ void NotmuchWorker::moveMessages(const QStringList &messageIds, continue; } - // A FRESH name, never the old one. See freshMaildirName(): carrying + // A FRESH name, never the old one. See MaildirName::fresh(): carrying // the `,U=` infix across a folder boundary is what produced // `Maildir error: duplicate UID` on real mail. const QString to = destDir + QLatin1Char('/') - + freshMaildirName(QFileInfo(from).fileName()); + + MaildirName::fresh(QFileInfo(from).fileName()); if (!QFile::rename(from, to)) { emit errorOccurred(QStringLiteral("Cannot move %1 to %2") -- cgit v1.2.3