aboutsummaryrefslogtreecommitdiffstats
path: root/src/maildirname.cpp
blob: 9f827fc55cb25002664b23a1250c863581c3399d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
/*
 * 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 "maildirname.h"

#include <QCoreApplication>
#include <QDateTime>
#include <QDir>
#include <QFileInfo>
#include <QHostInfo>

namespace MaildirName {

/// A fresh Maildir filename for a message being moved between folders,
/// preserving only its `:2,<flags>` 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=<n>` 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);
}

QString resolveRenamed(const QString &path)
{
    if (path.isEmpty())
        return QString();

    // The ordinary case, and the overwhelmingly common one: nothing was
    // renamed. One stat, then out.
    if (QFileInfo::exists(path))
        return path;

    const QFileInfo info(path);
    const QString name = info.fileName();

    // The unique part mbsync preserves. `<stem>:2,D` becomes
    // `<stem>,U=5:2,D`, so the stem ends at whichever of `,` or `:` comes
    // first. A name carrying neither is all stem.
    int cut = name.size();
    for (const QChar separator : { QLatin1Char(','), QLatin1Char(':') }) {
        const int at = name.indexOf(separator);
        if (at >= 0 && at < cut)
            cut = at;
    }
    const QString stem = name.left(cut);
    if (stem.isEmpty())
        return QString();

    // One directory, never a recursive walk: a rename keeps the file where it
    // was, and a file that changed FOLDERS is a different question that only
    // the message id can answer (see NotmuchWorker::moveMessages(), item 162).
    const QDir dir(info.absolutePath());
    if (!dir.exists())
        return QString();

    QString found;
    const QFileInfoList entries =
        dir.entryInfoList(QDir::Files | QDir::NoDotAndDotDot);
    for (const QFileInfo &entry : entries) {
        const QString candidate = entry.fileName();
        // Anchored on the stem AND on what follows it, so `...Q2` cannot match
        // `...Q23`: the next character must begin the infix or the flags.
        if (!candidate.startsWith(stem))
            continue;
        const QString rest = candidate.mid(stem.size());
        if (!rest.isEmpty() && !rest.startsWith(QLatin1Char(','))
            && !rest.startsWith(QLatin1Char(':'))) {
            continue;
        }

        // Two files sharing a stem cannot happen in a correct Maildir. Refuse
        // rather than guess: the caller reports "gone", which is honest, where
        // a guess could open, move or delete the wrong message.
        if (!found.isEmpty())
            return QString();
        found = entry.absoluteFilePath();
    }

    return found;
}

}  // namespace MaildirName