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