1 /*
2     SPDX-FileCopyrightText: 2009 Bertjan Broeksem <broeksema@kde.org>
3 
4     SPDX-License-Identifier: LGPL-2.0-or-later
5 */
6 
7 #include "mboxresource.h"
8 #include "mboxresource_debug.h"
9 
10 #include <Akonadi/AttributeFactory>
11 #include <Akonadi/ChangeRecorder>
12 #include <Akonadi/CollectionFetchJob>
13 #include <Akonadi/CollectionModifyJob>
14 #include <Akonadi/ItemFetchScope>
15 #include <Akonadi/KMime/MessageFlags>
16 #include <Akonadi/SpecialCollectionAttribute>
17 
18 #include <KMbox/MBox>
19 
20 #include <KMime/Message>
21 
22 #include "compactpage.h"
23 #include "deleteditemsattribute.h"
24 #include "lockmethodpage.h"
25 #include "settingsadaptor.h"
26 
27 #include <QDBusConnection>
28 
29 using namespace Akonadi;
30 
collectionId(const QString & remoteItemId)31 static Collection::Id collectionId(const QString &remoteItemId)
32 {
33     // [CollectionId]::[RemoteCollectionId]::[Offset]
34     const QStringList lst = remoteItemId.split(QStringLiteral("::"));
35     Q_ASSERT(lst.size() == 3);
36     return lst.first().toLongLong();
37 }
38 
mboxFile(const QString & remoteItemId)39 static QString mboxFile(const QString &remoteItemId)
40 {
41     // [CollectionId]::[RemoteCollectionId]::[Offset]
42     const QStringList lst = remoteItemId.split(QStringLiteral("::"));
43     Q_ASSERT(lst.size() == 3);
44     return lst.at(1);
45 }
46 
itemOffset(const QString & remoteItemId)47 static quint64 itemOffset(const QString &remoteItemId)
48 {
49     // [CollectionId]::[RemoteCollectionId]::[Offset]
50     const QStringList lst = remoteItemId.split(QStringLiteral("::"));
51     Q_ASSERT(lst.size() == 3);
52     return lst.last().toULongLong();
53 }
54 
MboxResource(const QString & id)55 MboxResource::MboxResource(const QString &id)
56     : SingleFileResource<Settings>(id)
57 {
58     new SettingsAdaptor(mSettings);
59     QDBusConnection::sessionBus().registerObject(QStringLiteral("/Settings"), mSettings, QDBusConnection::ExportAdaptors);
60 
61     const QStringList mimeTypes{QStringLiteral("message/rfc822")};
62     setSupportedMimetypes(mimeTypes, QStringLiteral("message-rfc822"));
63     // Register the list of deleted items as an attribute of the collection.
64     AttributeFactory::registerAttribute<DeletedItemsAttribute>();
65     setName(mSettings->displayName());
66 }
67 
~MboxResource()68 MboxResource::~MboxResource()
69 {
70     delete mMBox;
71 }
72 
rootCollection() const73 Collection MboxResource::rootCollection() const
74 {
75     // Maildir only has a single collection so we treat it as an inbox
76     auto col = SingleFileResource<Settings>::rootCollection();
77     col.attribute<Akonadi::SpecialCollectionAttribute>(Akonadi::Collection::AddIfMissing)->setCollectionType("inbox");
78     return col;
79 }
80 
retrieveItems(const Akonadi::Collection & col)81 void MboxResource::retrieveItems(const Akonadi::Collection &col)
82 {
83     Q_UNUSED(col)
84     if (!mMBox) {
85         cancelTask();
86         return;
87     }
88     if (mMBox->fileName().isEmpty()) {
89         Q_EMIT status(NotConfigured, i18nc("@info:status", "MBox not configured."));
90         return;
91     }
92 
93     reloadFile();
94 
95     KMBox::MBoxEntry::List entryList;
96     if (const auto attr = col.attribute<DeletedItemsAttribute>()) {
97         entryList = mMBox->entries(attr->deletedItemEntries());
98     } else { // No deleted items (yet)
99         entryList = mMBox->entries();
100     }
101     mMBox->lock(); // Lock the file so that it doesn't get locked for every
102     // readEntryHeaders() call.
103 
104     Item::List items;
105     const QString colId = QString::number(col.id());
106     const QString colRid = col.remoteId();
107     double count = 1;
108     const int entryListSize(entryList.size());
109     items.reserve(entryListSize);
110     for (const KMBox::MBoxEntry &entry : std::as_const(entryList)) {
111         // TODO: Use cache policy to see what actually has to been set as payload.
112         //       Currently most views need a minimal amount of information so the
113         //       Items get Envelopes as payload.
114         auto mail = new KMime::Message();
115         mail->setHead(KMime::CRLFtoLF(mMBox->readMessageHeaders(entry)));
116         mail->parse();
117 
118         Item item;
119         item.setRemoteId(colId + QLatin1String("::") + colRid + QLatin1String("::") + QString::number(entry.messageOffset()));
120         item.setMimeType(QStringLiteral("message/rfc822"));
121         item.setSize(entry.messageSize());
122         item.setPayload(KMime::Message::Ptr(mail));
123         Akonadi::MessageFlags::copyMessageFlags(*mail, item);
124         Q_EMIT percent(count++ / entryListSize);
125         items << item;
126     }
127 
128     mMBox->unlock(); // Now we have the items, unlock
129 
130     itemsRetrieved(items);
131 }
132 
retrieveItems(const Akonadi::Item::List & items,const QSet<QByteArray> & parts)133 bool MboxResource::retrieveItems(const Akonadi::Item::List &items, const QSet<QByteArray> &parts)
134 {
135     Q_UNUSED(parts)
136 
137     if (!mMBox) {
138         Q_EMIT error(i18n("MBox not loaded."));
139         return false;
140     }
141     if (mMBox->fileName().isEmpty()) {
142         Q_EMIT status(NotConfigured, i18nc("@info:status", "MBox not configured."));
143         return false;
144     }
145 
146     Akonadi::Item::List rv;
147     rv.reserve(items.count());
148     for (const auto &item : items) {
149         const QString rid = item.remoteId();
150         const quint64 offset = itemOffset(rid);
151         KMime::Message *mail = mMBox->readMessage(KMBox::MBoxEntry(offset));
152         if (!mail) {
153             Q_EMIT error(i18n("Failed to read message with uid '%1'.", rid));
154             return false;
155         }
156 
157         Item i(item);
158         i.setPayload(KMime::Message::Ptr(mail));
159         Akonadi::MessageFlags::copyMessageFlags(*mail, i);
160         rv.push_back(i);
161     }
162     itemsRetrieved(rv);
163     return true;
164 }
165 
aboutToQuit()166 void MboxResource::aboutToQuit()
167 {
168     if (!mSettings->readOnly()) {
169         writeFile();
170     }
171     mSettings->save();
172 }
173 
itemAdded(const Akonadi::Item & item,const Akonadi::Collection & collection)174 void MboxResource::itemAdded(const Akonadi::Item &item, const Akonadi::Collection &collection)
175 {
176     if (!mMBox) {
177         cancelTask(i18n("MBox not loaded."));
178         return;
179     }
180     if (mMBox->fileName().isEmpty()) {
181         Q_EMIT status(NotConfigured, i18nc("@info:status", "MBox not configured."));
182         return;
183     }
184 
185     // we can only deal with mail
186     if (!item.hasPayload<KMime::Message::Ptr>()) {
187         cancelTask(i18n("Only email messages can be added to the MBox resource."));
188         return;
189     }
190 
191     const KMBox::MBoxEntry entry = mMBox->appendMessage(item.payload<KMime::Message::Ptr>());
192     if (!entry.isValid()) {
193         cancelTask(i18n("Mail message not added to the MBox."));
194         return;
195     }
196 
197     scheduleWrite();
198     const QString rid =
199         QString::number(collection.id()) + QLatin1String("::") + collection.remoteId() + QLatin1String("::") + QString::number(entry.messageOffset());
200 
201     Item i(item);
202     i.setRemoteId(rid);
203 
204     changeCommitted(i);
205 }
206 
itemChanged(const Akonadi::Item & item,const QSet<QByteArray> & parts)207 void MboxResource::itemChanged(const Akonadi::Item &item, const QSet<QByteArray> &parts)
208 {
209     if (parts.contains("PLD:RFC822")) {
210         qCDebug(MBOXRESOURCE_LOG) << itemOffset(item.remoteId());
211         // Only complete messages can be stored in a MBox file. Because all messages
212         // are stored in one single file we do an ItemDelete and an ItemCreate to
213         // prevent that whole file must been rewritten.
214         auto fetchJob = new CollectionFetchJob(Collection(collectionId(item.remoteId())), CollectionFetchJob::Base);
215 
216         connect(fetchJob, &CollectionFetchJob::result, this, &MboxResource::onCollectionFetch);
217 
218         mCurrentItemDeletions.insert(fetchJob, item);
219 
220         fetchJob->start();
221         return;
222     }
223 
224     changeProcessed();
225 }
226 
itemRemoved(const Akonadi::Item & item)227 void MboxResource::itemRemoved(const Akonadi::Item &item)
228 {
229     auto fetchJob = new CollectionFetchJob(Collection(collectionId(item.remoteId())), CollectionFetchJob::Base);
230 
231     if (!fetchJob->exec()) {
232         cancelTask(i18n("Could not fetch the collection: %1", fetchJob->errorString()));
233         return;
234     }
235 
236     Q_ASSERT(fetchJob->collections().size() == 1);
237     Collection mboxCollection = fetchJob->collections().at(0);
238     auto attr = mboxCollection.attribute<DeletedItemsAttribute>(Akonadi::Collection::AddIfMissing);
239 
240     if (mSettings->compactFrequency() == Settings::per_x_messages && mSettings->messageCount() == static_cast<uint>(attr->offsetCount() + 1)) {
241         qCDebug(MBOXRESOURCE_LOG) << "Compacting mbox file";
242         mMBox->purge(attr->deletedItemEntries() << KMBox::MBoxEntry(itemOffset(item.remoteId())));
243         scheduleWrite();
244         mboxCollection.removeAttribute<DeletedItemsAttribute>();
245     } else {
246         attr->addDeletedItemOffset(itemOffset(item.remoteId()));
247     }
248 
249     auto modifyJob = new CollectionModifyJob(mboxCollection);
250     if (!modifyJob->exec()) {
251         cancelTask(modifyJob->errorString());
252         return;
253     }
254 
255     changeProcessed();
256 }
257 
handleHashChange()258 void MboxResource::handleHashChange()
259 {
260     Q_EMIT warning(
261         i18n("The MBox file was changed by another program. "
262              "A copy of the new file was made and pending changes "
263              "are appended to that copy. To prevent this from happening "
264              "use locking and make sure that all programs accessing the mbox "
265              "use the same locking method."));
266 }
267 
readFromFile(const QString & fileName)268 bool MboxResource::readFromFile(const QString &fileName)
269 {
270     delete mMBox;
271     mMBox = new KMBox::MBox();
272 
273     switch (mSettings->lockfileMethod()) {
274     case Settings::procmail:
275         mMBox->setLockType(KMBox::MBox::ProcmailLockfile);
276         mMBox->setLockFile(mSettings->lockfile());
277         break;
278     case Settings::mutt_dotlock:
279         mMBox->setLockType(KMBox::MBox::MuttDotlock);
280         break;
281     case Settings::mutt_dotlock_privileged:
282         mMBox->setLockType(KMBox::MBox::MuttDotlockPrivileged);
283         break;
284     }
285 
286     return mMBox->load(QUrl::fromLocalFile(fileName).toLocalFile());
287 }
288 
writeToFile(const QString & fileName)289 bool MboxResource::writeToFile(const QString &fileName)
290 {
291     if (!mMBox->save(fileName)) {
292         Q_EMIT error(i18n("Failed to save mbox file to %1", fileName));
293         return false;
294     }
295 
296     // HACK: When writeToFile is called with another file than with which the mbox
297     // was loaded we assume that a backup is made as result of the fileChanged slot
298     // in SingleFileResourceBase. The problem is that SingleFileResource assumes that
299     // the implementing resources can save/retrieve the data from before the file
300     // change we have a problem at this point in the mbox resource. Therefore we
301     // copy the original file and append pending changes to it but also add an extra
302     // '\n' to make sure that the hashes differ and the user gets notified. Normally
303     // if this happens the user should make use of locking in all applications that
304     // use the mbox file.
305     if (fileName != mMBox->fileName()) {
306         QFile file(fileName);
307         file.open(QIODevice::WriteOnly);
308         file.seek(file.size());
309         file.write("\n");
310     }
311 
312     return true;
313 }
314 
315 /// Private slots
316 
onCollectionFetch(KJob * job)317 void MboxResource::onCollectionFetch(KJob *job)
318 {
319     Q_ASSERT(mCurrentItemDeletions.contains(job));
320     const Item item = mCurrentItemDeletions.take(job);
321 
322     if (job->error()) {
323         cancelTask(job->errorString());
324         return;
325     }
326 
327     auto fetchJob = dynamic_cast<CollectionFetchJob *>(job);
328     Q_ASSERT(fetchJob);
329     Q_ASSERT(fetchJob->collections().size() == 1);
330 
331     Collection mboxCollection = fetchJob->collections().at(0);
332     auto attr = mboxCollection.attribute<DeletedItemsAttribute>(Akonadi::Collection::AddIfMissing);
333     attr->addDeletedItemOffset(itemOffset(item.remoteId()));
334 
335     auto modifyJob = new CollectionModifyJob(mboxCollection);
336     mCurrentItemDeletions.insert(modifyJob, item);
337     connect(modifyJob, &CollectionModifyJob::result, this, &MboxResource::onCollectionModify);
338     modifyJob->start();
339 }
340 
onCollectionModify(KJob * job)341 void MboxResource::onCollectionModify(KJob *job)
342 {
343     Q_ASSERT(mCurrentItemDeletions.contains(job));
344     const Item item = mCurrentItemDeletions.take(job);
345 
346     if (job->error()) {
347         // Failed to store the offset of a deleted item in the DeletedItemsAttribute
348         // of the collection. In this case we shouldn't try to store the modified
349         // item.
350         cancelTask(
351             i18n("Failed to update the changed item because the old item "
352                  "could not be deleted Reason: %1",
353                  job->errorString()));
354         return;
355     }
356 
357     Collection c(collectionId(item.remoteId()));
358     c.setRemoteId(mboxFile(item.remoteId()));
359 
360     itemAdded(item, c);
361 }
362 
363 AKONADI_RESOURCE_MAIN(MboxResource)
364