1 /*
2
3 SPDX-FileCopyrightText: 2009 Klarälvdalens Datakonsult AB, a KDAB Group company <info@kdab.com>
4
5 SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
6 */
7
8 #include "backupjob.h"
9
10 #include "mailcommon_debug.h"
11 #include <Akonadi/CollectionDeleteJob>
12 #include <Akonadi/CollectionFetchJob>
13 #include <Akonadi/CollectionFetchScope>
14 #include <Akonadi/ItemFetchJob>
15 #include <Akonadi/ItemFetchScope>
16 #include <PimCommon/BroadcastStatus>
17
18 #include <KMime/Message>
19
20 #include <KIO/Global>
21 #include <KLocalizedString>
22 #include <KMessageBox>
23 #include <KTar>
24 #include <KZip>
25
26 #include <QFileInfo>
27 #include <QTimer>
28
29 using namespace MailCommon;
30 static const mode_t archivePerms = S_IFREG | 0644;
31
BackupJob(QWidget * parent)32 BackupJob::BackupJob(QWidget *parent)
33 : QObject(parent)
34 , mArchiveTime(QDateTime::currentDateTime())
35 , mRootFolder(0)
36 , mParentWidget(parent)
37 , mCurrentFolder(Akonadi::Collection())
38 {
39 }
40
~BackupJob()41 BackupJob::~BackupJob()
42 {
43 mPendingFolders.clear();
44 delete mArchive;
45 mArchive = nullptr;
46 }
47
setRootFolder(const Akonadi::Collection & rootFolder)48 void BackupJob::setRootFolder(const Akonadi::Collection &rootFolder)
49 {
50 mRootFolder = rootFolder;
51 }
52
setRealPath(const QString & path)53 void BackupJob::setRealPath(const QString &path)
54 {
55 mRealPath = path;
56 }
57
setSaveLocation(const QUrl & savePath)58 void BackupJob::setSaveLocation(const QUrl &savePath)
59 {
60 mMailArchivePath = savePath;
61 }
62
setArchiveType(ArchiveType type)63 void BackupJob::setArchiveType(ArchiveType type)
64 {
65 mArchiveType = type;
66 }
67
setDeleteFoldersAfterCompletion(bool deleteThem)68 void BackupJob::setDeleteFoldersAfterCompletion(bool deleteThem)
69 {
70 mDeleteFoldersAfterCompletion = deleteThem;
71 }
72
setRecursive(bool recursive)73 void BackupJob::setRecursive(bool recursive)
74 {
75 mRecursive = recursive;
76 }
77
queueFolders(const Akonadi::Collection & root)78 bool BackupJob::queueFolders(const Akonadi::Collection &root)
79 {
80 mPendingFolders.append(root);
81 if (mRecursive) {
82 // FIXME: Get rid of the exec()
83 // We could do a recursive CollectionFetchJob, but we only fetch the first level
84 // and then recurse manually. This is needed because a recursive fetch doesn't
85 // sort the collections the way we want. We need all first level children to be
86 // in the mPendingFolders list before all second level children, so that the
87 // directories for the first level are written before the directories in the
88 // second level, in the archive file.
89 auto *job = new Akonadi::CollectionFetchJob(root, Akonadi::CollectionFetchJob::FirstLevel);
90 job->fetchScope().setAncestorRetrieval(Akonadi::CollectionFetchScope::All);
91 job->exec();
92 if (job->error()) {
93 qCWarning(MAILCOMMON_LOG) << job->errorString();
94 abort(i18n("Unable to retrieve folder list."));
95 return false;
96 }
97
98 const Akonadi::Collection::List lstCols = job->collections();
99 for (const Akonadi::Collection &collection : lstCols) {
100 if (!queueFolders(collection)) {
101 return false;
102 }
103 }
104 }
105 mAllFolders = mPendingFolders;
106 return true;
107 }
108
hasChildren(const Akonadi::Collection & collection) const109 bool BackupJob::hasChildren(const Akonadi::Collection &collection) const
110 {
111 for (const Akonadi::Collection &curCol : std::as_const(mAllFolders)) {
112 if (collection == curCol.parentCollection()) {
113 return true;
114 }
115 }
116 return false;
117 }
118
cancelJob()119 void BackupJob::cancelJob()
120 {
121 abort(i18n("The operation was canceled by the user."));
122 }
123
abort(const QString & errorMessage)124 void BackupJob::abort(const QString &errorMessage)
125 {
126 // We could be called this twice, since killing the current job below will
127 // cause the job to fail, and that will call abort()
128 if (mAborted) {
129 return;
130 }
131
132 mAborted = true;
133 if (mCurrentFolder.isValid()) {
134 mCurrentFolder = Akonadi::Collection();
135 }
136
137 if (mArchive && mArchive->isOpen()) {
138 mArchive->close();
139 }
140
141 if (mCurrentJob) {
142 mCurrentJob->kill();
143 mCurrentJob = nullptr;
144 }
145
146 if (mProgressItem) {
147 mProgressItem->setComplete();
148 mProgressItem = nullptr;
149 // The progressmanager will delete it
150 }
151 QString text = i18n("Failed to archive the folder '%1'.", mRootFolder.name());
152 text += QLatin1Char('\n') + errorMessage;
153 Q_EMIT error(text);
154 if (mDisplayMessageBox) {
155 KMessageBox::sorry(mParentWidget, text, i18n("Archiving failed"));
156 }
157 deleteLater();
158 // Clean up archive file here?
159 }
160
finish()161 void BackupJob::finish()
162 {
163 if (mArchive->isOpen()) {
164 if (!mArchive->close()) {
165 abort(i18n("Unable to finalize the archive file."));
166 return;
167 }
168 }
169
170 const QString archivingStr(i18n("Archiving finished"));
171 PimCommon::BroadcastStatus::instance()->setStatusMsg(archivingStr);
172
173 if (mProgressItem) {
174 mProgressItem->setStatus(archivingStr);
175 mProgressItem->setComplete();
176 mProgressItem = nullptr;
177 }
178
179 QFileInfo archiveFileInfo(mMailArchivePath.path());
180 QString text = i18n(
181 "Archiving folder '%1' successfully completed. "
182 "The archive was written to the file '%2'.",
183 mRealPath.isEmpty() ? mRootFolder.name() : mRealPath,
184 mMailArchivePath.path());
185 text += QLatin1Char('\n')
186 + i18np("1 message of size %2 was archived.",
187 "%1 messages with the total size of %2 were archived.",
188 mArchivedMessages,
189 KIO::convertSize(mArchivedSize));
190 text += QLatin1Char('\n') + i18n("The archive file has a size of %1.", KIO::convertSize(archiveFileInfo.size()));
191 if (mDisplayMessageBox) {
192 KMessageBox::information(mParentWidget, text, i18n("Archiving finished"));
193 }
194
195 if (mDeleteFoldersAfterCompletion) {
196 // Some safety checks first...
197 if (archiveFileInfo.exists() && (mArchivedSize > 0 || mArchivedMessages == 0)) {
198 // Sorry for any data loss!
199 new Akonadi::CollectionDeleteJob(mRootFolder);
200 }
201 }
202 Q_EMIT backupDone(text);
203 deleteLater();
204 }
205
archiveNextMessage()206 void BackupJob::archiveNextMessage()
207 {
208 if (mAborted) {
209 return;
210 }
211
212 if (mPendingMessages.isEmpty()) {
213 qCDebug(MAILCOMMON_LOG) << "===> All messages done in folder " << mCurrentFolder.name();
214 archiveNextFolder();
215 return;
216 }
217
218 const Akonadi::Item item = mPendingMessages.takeFirst();
219 qCDebug(MAILCOMMON_LOG) << "Fetching item with ID" << item.id() << "for folder" << mCurrentFolder.name();
220
221 mCurrentJob = new Akonadi::ItemFetchJob(item);
222 mCurrentJob->fetchScope().fetchFullPayload(true);
223 connect(mCurrentJob, &Akonadi::ItemFetchJob::result, this, &BackupJob::itemFetchJobResult);
224 }
225
processMessage(const Akonadi::Item & item)226 void BackupJob::processMessage(const Akonadi::Item &item)
227 {
228 if (mAborted) {
229 return;
230 }
231
232 const auto message = item.payload<KMime::Message::Ptr>();
233 qCDebug(MAILCOMMON_LOG) << "Processing message with subject " << message->subject(false);
234 const QByteArray messageData = message->encodedContent();
235 const qint64 messageSize = messageData.size();
236 const QString messageName = QString::number(item.id());
237 const QString fileName = pathForCollection(mCurrentFolder) + QLatin1String("/cur/") + messageName;
238
239 // PORT ME: user and group!
240 qCDebug(MAILCOMMON_LOG) << "AKONDI PORT: disabled code here!";
241 if (!mArchive->writeFile(fileName, messageData, archivePerms, QStringLiteral("user"), QStringLiteral("group"), mArchiveTime, mArchiveTime, mArchiveTime)) {
242 abort(i18n("Failed to write a message into the archive folder '%1'.", mCurrentFolder.name()));
243 return;
244 }
245
246 ++mArchivedMessages;
247 mArchivedSize += messageSize;
248
249 // Use a singleshot timer, otherwise the job started in archiveNextMessage()
250 // will hang
251 QTimer::singleShot(0, this, &BackupJob::archiveNextMessage);
252 }
253
itemFetchJobResult(KJob * job)254 void BackupJob::itemFetchJobResult(KJob *job)
255 {
256 if (mAborted) {
257 return;
258 }
259
260 Q_ASSERT(job == mCurrentJob);
261 mCurrentJob = nullptr;
262
263 if (job->error()) {
264 Q_ASSERT(mCurrentFolder.isValid());
265 qCWarning(MAILCOMMON_LOG) << job->errorString();
266 abort(i18n("Downloading a message in folder '%1' failed.", mCurrentFolder.name()));
267 } else {
268 auto fetchJob = qobject_cast<Akonadi::ItemFetchJob *>(job);
269 Q_ASSERT(fetchJob);
270 Q_ASSERT(fetchJob->items().size() == 1);
271 processMessage(fetchJob->items().constFirst());
272 }
273 }
274
writeDirHelper(const QString & directoryPath)275 bool BackupJob::writeDirHelper(const QString &directoryPath)
276 {
277 // PORT ME: Correct user/group
278 qCDebug(MAILCOMMON_LOG) << "AKONDI PORT: Disabled code here!";
279 return mArchive->writeDir(directoryPath, QStringLiteral("user"), QStringLiteral("group"), 040755, mArchiveTime, mArchiveTime, mArchiveTime);
280 }
281
collectionName(const Akonadi::Collection & collection) const282 QString BackupJob::collectionName(const Akonadi::Collection &collection) const
283 {
284 for (const Akonadi::Collection &curCol : std::as_const(mAllFolders)) {
285 if (curCol == collection) {
286 return curCol.name();
287 }
288 }
289 Q_ASSERT(false);
290 return QString();
291 }
292
pathForCollection(const Akonadi::Collection & collection) const293 QString BackupJob::pathForCollection(const Akonadi::Collection &collection) const
294 {
295 QString fullPath = collectionName(collection);
296 Akonadi::Collection curCol = collection.parentCollection();
297 if (collection != mRootFolder) {
298 Q_ASSERT(curCol.isValid());
299 while (curCol != mRootFolder) {
300 fullPath.prepend(QLatin1Char('.') + collectionName(curCol) + QLatin1String(".directory/"));
301 curCol = curCol.parentCollection();
302 }
303 Q_ASSERT(curCol == mRootFolder);
304 fullPath.prepend(QLatin1Char('.') + collectionName(curCol) + QLatin1String(".directory/"));
305 }
306 return fullPath;
307 }
308
subdirPathForCollection(const Akonadi::Collection & collection) const309 QString BackupJob::subdirPathForCollection(const Akonadi::Collection &collection) const
310 {
311 QString path = pathForCollection(collection);
312 const int parentDirEndIndex = path.lastIndexOf(collection.name());
313 Q_ASSERT(parentDirEndIndex != -1);
314 path.truncate(parentDirEndIndex);
315 path.append(QLatin1Char('.') + collection.name() + QLatin1String(".directory"));
316 return path;
317 }
318
archiveNextFolder()319 void BackupJob::archiveNextFolder()
320 {
321 if (mAborted) {
322 return;
323 }
324
325 if (mPendingFolders.isEmpty()) {
326 finish();
327 return;
328 }
329
330 mCurrentFolder = mPendingFolders.takeAt(0);
331 qCDebug(MAILCOMMON_LOG) << "===> Archiving next folder: " << mCurrentFolder.name();
332 const QString archivingStr(i18n("Archiving folder %1", mCurrentFolder.name()));
333 if (mProgressItem) {
334 mProgressItem->setStatus(archivingStr);
335 }
336 PimCommon::BroadcastStatus::instance()->setStatusMsg(archivingStr);
337
338 const QString folderName = mCurrentFolder.name();
339 bool success = true;
340 if (hasChildren(mCurrentFolder)) {
341 if (!writeDirHelper(subdirPathForCollection(mCurrentFolder))) {
342 success = false;
343 }
344 }
345 if (success) {
346 if (!writeDirHelper(pathForCollection(mCurrentFolder))) {
347 success = false;
348 } else if (!writeDirHelper(pathForCollection(mCurrentFolder) + QLatin1String("/cur"))) {
349 success = false;
350 } else if (!writeDirHelper(pathForCollection(mCurrentFolder) + QLatin1String("/new"))) {
351 success = false;
352 } else if (!writeDirHelper(pathForCollection(mCurrentFolder) + QLatin1String("/tmp"))) {
353 success = false;
354 }
355 }
356 if (!success) {
357 abort(i18n("Unable to create folder structure for folder '%1' within archive file.", mCurrentFolder.name()));
358 return;
359 }
360 auto job = new Akonadi::ItemFetchJob(mCurrentFolder);
361 job->setProperty("folderName", folderName);
362 connect(job, &Akonadi::ItemFetchJob::result, this, &BackupJob::onArchiveNextFolderDone);
363 }
364
onArchiveNextFolderDone(KJob * job)365 void BackupJob::onArchiveNextFolderDone(KJob *job)
366 {
367 if (job->error()) {
368 qCWarning(MAILCOMMON_LOG) << job->errorString();
369 abort(i18n("Unable to get message list for folder %1.", job->property("folderName").toString()));
370 return;
371 }
372
373 auto fetchJob = qobject_cast<Akonadi::ItemFetchJob *>(job);
374 mPendingMessages += fetchJob->items();
375 archiveNextMessage();
376 }
377
start()378 void BackupJob::start()
379 {
380 Q_ASSERT(!mMailArchivePath.isEmpty());
381 Q_ASSERT(mRootFolder.isValid());
382
383 if (!queueFolders(mRootFolder)) {
384 return;
385 }
386
387 switch (mArchiveType) {
388 case Zip: {
389 KZip *zip = new KZip(mMailArchivePath.path());
390 zip->setCompression(KZip::DeflateCompression);
391 mArchive = zip;
392 break;
393 }
394 case Tar:
395 mArchive = new KTar(mMailArchivePath.path(), QStringLiteral("application/x-tar"));
396 break;
397 case TarGz:
398 mArchive = new KTar(mMailArchivePath.path(), QStringLiteral("application/x-gzip"));
399 break;
400 case TarBz2:
401 mArchive = new KTar(mMailArchivePath.path(), QStringLiteral("application/x-bzip2"));
402 break;
403 }
404
405 qCDebug(MAILCOMMON_LOG) << "Starting backup.";
406 if (!mArchive->open(QIODevice::WriteOnly)) {
407 abort(i18n("Unable to open archive for writing."));
408 return;
409 }
410
411 mProgressItem = KPIM::ProgressManager::createProgressItem(QStringLiteral("BackupJob"), i18n("Archiving"), QString(), true);
412 mProgressItem->setUsesBusyIndicator(true);
413 connect(mProgressItem.data(), &KPIM::ProgressItem::progressItemCanceled, this, &BackupJob::cancelJob);
414
415 archiveNextFolder();
416 }
417
setDisplayMessageBox(bool display)418 void BackupJob::setDisplayMessageBox(bool display)
419 {
420 mDisplayMessageBox = display;
421 }
422