diff --git a/Mail/Views/Settings/General/SettingsNotificationsView.swift b/Mail/Views/Settings/General/SettingsNotificationsView.swift
index 0d6e939b6..a406a86f6 100644
--- a/Mail/Views/Settings/General/SettingsNotificationsView.swift
+++ b/Mail/Views/Settings/General/SettingsNotificationsView.swift
@@ -144,6 +144,7 @@ struct SettingsNotificationsView: View {
func updateTopicsForCurrentUserIfNeeded() {
Task {
guard let subscribedTopics else { return }
+
await notificationService.updateTopicsIfNeeded(subscribedTopics, userApiFetcher: mailboxManager.apiFetcher)
}
}
diff --git a/MailCore/API/Extension/Realm+SafeWrite.swift b/MailCore/API/Extension/Realm+SafeWrite.swift
new file mode 100644
index 000000000..586d51098
--- /dev/null
+++ b/MailCore/API/Extension/Realm+SafeWrite.swift
@@ -0,0 +1,42 @@
+/*
+ Infomaniak Mail - iOS App
+ Copyright (C) 2022 Infomaniak Network SA
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ 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, see .
+ */
+
+import Foundation
+import RealmSwift
+
+public extension Realm {
+ func uncheckedSafeWrite(_ block: () throws -> Void) throws {
+ if isInWriteTransaction {
+ try block()
+ } else {
+ try write(block)
+ }
+ }
+
+ func safeWrite(_ block: () throws -> Void) throws {
+ #if DEBUG
+ dispatchPrecondition(condition: .notOnQueue(.main))
+ #endif
+
+ if isInWriteTransaction {
+ try block()
+ } else {
+ try write(block)
+ }
+ }
+}
diff --git a/MailCore/API/MailApiFetcher.swift b/MailCore/API/MailApiFetcher.swift
deleted file mode 100644
index 6bac549f0..000000000
--- a/MailCore/API/MailApiFetcher.swift
+++ /dev/null
@@ -1,499 +0,0 @@
-/*
- Infomaniak Mail - iOS App
- Copyright (C) 2022 Infomaniak Network SA
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- 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, see .
- */
-
-import Alamofire
-import Foundation
-import InfomaniakCore
-import InfomaniakDI
-import InfomaniakLogin
-import Sentry
-import UIKit
-
-public extension ApiFetcher {
- convenience init(token: ApiToken, delegate: RefreshTokenDelegate) {
- self.init()
- createAuthenticatedSession(token,
- authenticator: SyncedAuthenticator(refreshTokenDelegate: delegate),
- additionalAdapters: [RequestContextIdAdaptor()])
- }
-}
-
-public class MailApiFetcher: ApiFetcher {
- public static let clientId = "E90BC22D-67A8-452C-BE93-28DA33588CA4"
-
- /// All status except 401 are handled by our code, 401 status is handled by Alamofire's Authenticator code
- private lazy var handledHttpStatus: Set = {
- var allStatus = Set(200 ... 500)
- allStatus.remove(401)
- return allStatus
- }()
-
- override public func perform(
- request: DataRequest,
- decoder: JSONDecoder = ApiFetcher.decoder
- ) async throws -> (data: T, responseAt: Int?) {
- do {
- return try await super.perform(request: request.validate(statusCode: handledHttpStatus))
- } catch InfomaniakError.apiError(let apiError) {
- throw MailApiError.mailApiErrorWithFallback(apiErrorCode: apiError.code)
- } catch InfomaniakError.serverError(statusCode: let statusCode) {
- throw MailServerError(httpStatus: statusCode)
- } catch {
- if let afError = error.asAFError {
- if case .responseSerializationFailed(let reason) = afError,
- case .decodingFailed(let error) = reason {
- var rawJson = "No data"
- if let data = request.data,
- let stringData = String(data: data, encoding: .utf8) {
- rawJson = stringData
- }
-
- SentrySDK.capture(error: error) { scope in
- scope.setExtras(["Request URL": request.request?.url?.absoluteString ?? "No URL",
- "Request Id": request.request?
- .value(forHTTPHeaderField: RequestContextIdAdaptor.requestContextIdHeader) ??
- "No request Id",
- "Decoded type": String(describing: T.self),
- "Raw JSON": rawJson])
- }
- }
- throw AFErrorWithContext(request: request, afError: afError)
- } else {
- throw error
- }
- }
- }
-
- // MARK: - API methods
-
- public func mailboxes() async throws -> [Mailbox] {
- try await perform(request: authenticatedRequest(.mailboxes)).data
- }
-
- public func addMailbox(mail: String, password: String) async throws -> MailboxLinkedResult {
- try await perform(request: authenticatedRequest(
- .addMailbox,
- method: .post,
- parameters: ["mail": mail, "password": password, "is_primary": false]
- )).data
- }
-
- public func updateMailboxPassword(mailbox: Mailbox, password: String) async throws -> Bool {
- try await perform(request: authenticatedRequest(
- .updateMailboxPassword(mailboxId: mailbox.mailboxId),
- method: .put,
- parameters: ["password": password]
- )).data
- }
-
- public func detachMailbox(mailbox: Mailbox) async throws -> Bool {
- try await perform(request: authenticatedRequest(.detachMailbox(mailboxId: mailbox.mailboxId), method: .delete)).data
- }
-
- func permissions(mailbox: Mailbox) async throws -> MailboxPermissions {
- try await perform(request: authenticatedRequest(.permissions(mailbox: mailbox))).data
- }
-
- func contacts() async throws -> [Contact] {
- try await perform(request: authenticatedRequest(.contacts)).data
- }
-
- func addressBooks() async throws -> AddressBookResult {
- try await perform(request: authenticatedRequest(.addressBooks)).data
- }
-
- func addContact(_ recipient: Recipient, to addressBook: AddressBook) async throws -> Int {
- try await perform(request: authenticatedSession.request(Endpoint.addContact.url,
- method: .post,
- parameters: NewContact(from: recipient, addressBook: addressBook),
- encoder: JSONParameterEncoder.default)).data
- }
-
- public func listBackups(mailbox: Mailbox) async throws -> BackupsList {
- try await perform(request: authenticatedRequest(.backups(hostingId: mailbox.hostingId, mailboxName: mailbox.mailbox)))
- .data
- }
-
- @discardableResult
- public func restoreBackup(mailbox: Mailbox, date: String) async throws -> Bool {
- try await perform(request: authenticatedRequest(.backups(hostingId: mailbox.hostingId, mailboxName: mailbox.mailbox),
- method: .put,
- parameters: ["date": date])).data
- }
-
- func signatures(mailbox: Mailbox) async throws -> SignatureResponse {
- try await perform(request: authenticatedRequest(.signatures(hostingId: mailbox.hostingId, mailboxName: mailbox.mailbox)))
- .data
- }
-
- func updateSignature(mailbox: Mailbox, signature: Signature) async throws -> Bool {
- try await perform(request:
- authenticatedRequest(
- .updateSignature(
- hostingId: mailbox.hostingId,
- mailboxName: mailbox.mailbox,
- signatureId: signature.id
- ),
- method: .patch,
- parameters: signature
- )).data
- }
-
- func folders(mailbox: Mailbox) async throws -> [Folder] {
- try await perform(request: authenticatedRequest(.folders(uuid: mailbox.uuid))).data
- }
-
- func flushFolder(mailbox: Mailbox, folderId: String) async throws -> Bool {
- try await perform(request: authenticatedRequest(.flushFolder(mailboxUuid: mailbox.uuid, folderId: folderId),
- method: .post)).data
- }
-
- public func threads(mailbox: Mailbox, folderId: String, filter: Filter = .all,
- searchFilter: [URLQueryItem] = [], isDraftFolder: Bool = false) async throws -> ThreadResult {
- try await perform(request: authenticatedRequest(.threads(
- uuid: mailbox.uuid,
- folderId: folderId,
- filter: filter == .all ? nil : filter.rawValue,
- searchFilters: searchFilter,
- isDraftFolder: isDraftFolder
- ))).data
- }
-
- public func threads(from resource: String, searchFilter: [URLQueryItem] = []) async throws -> ThreadResult {
- try await perform(request: authenticatedRequest(.resource(resource, queryItems: searchFilter))).data
- }
-
- func messagesUids(
- mailboxUuid: String,
- folderId: String,
- paginationInfo: PaginationInfo? = nil
- ) async throws -> MessageUidsResult {
- try await perform(request: authenticatedRequest(.messagesUids(
- mailboxUuid: mailboxUuid,
- folderId: folderId,
- paginationInfo: paginationInfo
- ))).data
- }
-
- func messagesByUids(mailboxUuid: String, folderId: String, messageUids: [String]) async throws -> MessageByUidsResult {
- try await perform(request: authenticatedRequest(.messagesByUids(
- mailboxUuid: mailboxUuid,
- folderId: folderId,
- messagesUids: messageUids
- ))).data
- }
-
- func messagesDelta(mailboxUUid: String, folderId: String, signature: String) async throws -> MessageDeltaResult {
- try await perform(request: authenticatedRequest(.messagesDelta(
- mailboxUuid: mailboxUUid,
- folderId: folderId,
- signature: signature
- ))).data
- }
-
- func message(message: Message) async throws -> Message {
- try await perform(request: authenticatedRequest(.resource(
- message.resource,
- queryItems: [
- URLQueryItem(name: "prefered_format", value: "html")
- ]
- ))).data
- }
-
- public func download(message: Message) async throws -> URL {
- let destination = DownloadRequest.suggestedDownloadDestination(options: [
- .createIntermediateDirectories,
- .removePreviousFile
- ])
- let download = authenticatedSession.download(Endpoint.resource(message.downloadResource).url, to: destination)
- return try await download.serializingDownloadedFileURL().value
- }
-
- func markAsSeen(mailbox: Mailbox, messages: [Message]) async throws -> MessageActionResult {
- try await perform(request: authenticatedRequest(.messageSeen(uuid: mailbox.uuid),
- method: .post,
- parameters: ["uids": messages.map(\.uid)])).data
- }
-
- func markAsUnseen(mailbox: Mailbox, messages: [Message]) async throws -> MessageActionResult {
- try await perform(request: authenticatedRequest(.messageUnseen(uuid: mailbox.uuid),
- method: .post,
- parameters: ["uids": messages.map(\.uid)])).data
- }
-
- func move(mailbox: Mailbox, messages: [Message], destinationId: String) async throws -> UndoResponse {
- try await perform(request: authenticatedRequest(.moveMessages(uuid: mailbox.uuid),
- method: .post,
- parameters: ["uids": messages.map(\.uid), "to": destinationId])).data
- }
-
- func delete(mailbox: Mailbox, messages: [Message]) async throws -> Empty {
- try await perform(request: authenticatedRequest(.deleteMessages(uuid: mailbox.uuid),
- method: .post,
- parameters: ["uids": messages.map(\.uid)])).data
- }
-
- func attachment(attachment: Attachment) async throws -> Data {
- guard let resource = attachment.resource else {
- throw MailError.resourceError
- }
- let request = authenticatedRequest(.resource(resource))
- return try await request.serializingData().value
- }
-
- public func quotas(mailbox: Mailbox) async throws -> Quotas {
- try await perform(request: authenticatedRequest(.quotas(mailbox: mailbox.mailbox, productId: mailbox.hostingId))).data
- }
-
- func draft(mailbox: Mailbox, draftUuid: String) async throws -> Draft {
- try await perform(request: authenticatedRequest(.draft(uuid: mailbox.uuid, draftUuid: draftUuid))).data
- }
-
- func draft(from message: Message) async throws -> Draft {
- guard let resource = message.draftResource else {
- throw MailError.resourceError
- }
- return try await perform(request: authenticatedRequest(.resource(resource))).data
- }
-
- func send(mailbox: Mailbox, draft: Draft) async throws -> SendResponse {
- try await perform(request: authenticatedRequest(
- draft.remoteUUID.isEmpty ? .draft(uuid: mailbox.uuid) : .draft(uuid: mailbox.uuid, draftUuid: draft.remoteUUID),
- method: draft.remoteUUID.isEmpty ? .post : .put,
- parameters: draft
- )).data
- }
-
- func save(mailbox: Mailbox, draft: Draft) async throws -> DraftResponse {
- try await perform(request: authenticatedRequest(
- draft.remoteUUID.isEmpty ? .draft(uuid: mailbox.uuid) : .draft(uuid: mailbox.uuid, draftUuid: draft.remoteUUID),
- method: draft.remoteUUID.isEmpty ? .post : .put,
- parameters: draft
- )).data
- }
-
- @discardableResult
- func deleteDraft(mailbox: Mailbox, draftId: String) async throws -> Empty? {
- // TODO: Remove try? when bug will be fixed from API
- return try? await perform(request: authenticatedRequest(.draft(uuid: mailbox.uuid, draftUuid: draftId), method: .delete))
- .data
- }
-
- @discardableResult
- func deleteDraft(draftResource: String) async throws -> Empty? {
- // TODO: Remove try? when bug will be fixed from API
- return try? await perform(request: authenticatedRequest(.resource(draftResource), method: .delete)).data
- }
-
- @discardableResult
- public func undoAction(resource: String) async throws -> Bool {
- try await perform(request: authenticatedRequest(.resource(resource), method: .post)).data
- }
-
- public func star(mailbox: Mailbox, messages: [Message]) async throws -> MessageActionResult {
- try await perform(request: authenticatedRequest(.star(uuid: mailbox.uuid),
- method: .post,
- parameters: ["uids": messages.map(\.uid)])).data
- }
-
- public func unstar(mailbox: Mailbox, messages: [Message]) async throws -> MessageActionResult {
- try await perform(request: authenticatedRequest(.unstar(uuid: mailbox.uuid),
- method: .post,
- parameters: ["uids": messages.map(\.uid)])).data
- }
-
- public func downloadAttachments(message: Message) async throws -> URL {
- let destination = DownloadRequest.suggestedDownloadDestination(options: [
- .createIntermediateDirectories,
- .removePreviousFile
- ])
- let download = authenticatedSession.download(
- Endpoint.downloadAttachments(messageResource: message.resource).url,
- to: destination
- )
- return try await download.serializingDownloadedFileURL().value
- }
-
- public func blockSender(message: Message) async throws -> Bool {
- try await perform(request: authenticatedRequest(.blockSender(messageResource: message.resource), method: .post)).data
- }
-
- public func reportPhishing(message: Message) async throws -> Bool {
- try await perform(request: authenticatedRequest(.report(messageResource: message.resource),
- method: .post,
- parameters: ["type": "phishing"])).data
- }
-
- public func create(mailbox: Mailbox, folder: NewFolder) async throws -> Folder {
- try await perform(request: authenticatedRequest(.folders(uuid: mailbox.uuid), method: .post, parameters: folder)).data
- }
-
- public func createAttachment(
- mailbox: Mailbox,
- attachmentData: Data,
- attachment: Attachment,
- progressObserver: @escaping (Double) -> Void
- ) async throws -> Attachment {
- let headers = HTTPHeaders([
- "x-ws-attachment-filename": attachment.name,
- "x-ws-attachment-mime-type": attachment.mimeType,
- "x-ws-attachment-disposition": attachment.disposition.rawValue
- ])
- var request = try URLRequest(url: Endpoint.createAttachment(uuid: mailbox.uuid).url, method: .post, headers: headers)
- request.httpBody = attachmentData
-
- let uploadRequest = authenticatedSession.request(request)
- Task {
- for await progress in uploadRequest.uploadProgress() {
- progressObserver(progress.fractionCompleted)
- }
- }
-
- return try await perform(request: uploadRequest).data
- }
-
- public func attachmentsToForward(mailbox: Mailbox, message: Message) async throws -> AttachmentsToForwardResult {
- let attachmentsToForward = AttachmentsToForward(toForwardUids: [message.uid], mode: AttachmentDisposition.inline.rawValue)
- return try await perform(request: authenticatedRequest(.attachmentToForward(uuid: mailbox.uuid), method: .post,
- parameters: attachmentsToForward)).data
- }
-}
-
-final class SyncedAuthenticator: OAuthAuthenticator {
- func handleFailedRefreshingToken(oldToken: ApiToken, error: Error?) -> Result {
- guard let error = error as NSError?,
- error.domain == "invalid_grant" else {
- // Couldn't refresh the token, keep the old token and fetch it later. Maybe because of bad network ?
- SentrySDK
- .addBreadcrumb(oldToken.generateBreadcrumb(level: .error,
- message: "Refreshing token failed - Other \(error.debugDescription)"))
- return .success(oldToken)
- }
-
- // Couldn't refresh the token, API says it's invalid
- SentrySDK.addBreadcrumb(oldToken.generateBreadcrumb(level: .error, message: "Refreshing token failed - Invalid grant"))
- refreshTokenDelegate?.didFailRefreshToken(oldToken)
- return .failure(error)
- }
-
- override func refresh(
- _ credential: OAuthAuthenticator.Credential,
- for session: Session,
- completion: @escaping (Result) -> Void
- ) {
- @InjectService var keychainHelper: KeychainHelper
- @InjectService var tokenStore: TokenStore
- @InjectService var networkLoginService: InfomaniakNetworkLoginable
-
- SentrySDK.addBreadcrumb((credential as ApiToken).generateBreadcrumb(level: .info, message: "Refreshing token - Starting"))
-
- guard keychainHelper.isKeychainAccessible else {
- SentrySDK
- .addBreadcrumb((credential as ApiToken)
- .generateBreadcrumb(level: .error, message: "Refreshing token failed - Keychain unaccessible"))
- completion(.failure(MailError.noToken))
- return
- }
-
- // Maybe someone else refreshed our token
- if let token = tokenStore.tokenFor(userId: credential.userId, fetchLocation: .keychain),
- token.expirationDate > credential.expirationDate {
- SentrySDK.addBreadcrumb(token.generateBreadcrumb(level: .info, message: "Refreshing token - Success with local"))
- completion(.success(token))
- return
- }
-
- // It is absolutely necessary that the app stays awake while we refresh the token
- BackgroundExecutor.executeWithBackgroundTask { endBackgroundTask in
- networkLoginService.refreshToken(token: credential) { token, error in
- // New token has been fetched correctly
- if let token {
- SentrySDK
- .addBreadcrumb(token
- .generateBreadcrumb(level: .info, message: "Refreshing token - Success with remote"))
- self.refreshTokenDelegate?.didUpdateToken(newToken: token, oldToken: credential)
- completion(.success(token))
- } else {
- completion(self.handleFailedRefreshingToken(oldToken: credential, error: error))
- }
- endBackgroundTask()
- }
- } onExpired: {
- SentrySDK
- .addBreadcrumb((credential as ApiToken)
- .generateBreadcrumb(level: .error, message: "Refreshing token failed - Background task expired"))
- // If we didn't fetch the new token in the given time there is not much we can do apart from hoping that it wasn't
- // revoked
- completion(.failure(MailError.noToken))
- }
- }
-}
-
-class NetworkRequestRetrier: RequestInterceptor {
- let maxRetry: Int
- private var retriedRequests: [String: Int] = [:]
- let timeout = -1001
- let connectionLost = -1005
-
- init(maxRetry: Int = 3) {
- self.maxRetry = maxRetry
- }
-
- func retry(
- _ request: Alamofire.Request,
- for session: Session,
- dueTo error: Error,
- completion: @escaping (RetryResult) -> Void
- ) {
- guard request.task?.response == nil,
- let url = request.request?.url?.absoluteString else {
- removeCachedUrlRequest(url: request.request?.url?.absoluteString)
- completion(.doNotRetry)
- return
- }
-
- let errorGenerated = error as NSError
- switch errorGenerated.code {
- case timeout, connectionLost:
- guard let retryCount = retriedRequests[url] else {
- retriedRequests[url] = 1
- completion(.retryWithDelay(0.5))
- return
- }
-
- if retryCount < maxRetry {
- retriedRequests[url] = retryCount + 1
- completion(.retryWithDelay(0.5))
- } else {
- removeCachedUrlRequest(url: url)
- completion(.doNotRetry)
- }
-
- default:
- removeCachedUrlRequest(url: url)
- completion(.doNotRetry)
- }
- }
-
- private func removeCachedUrlRequest(url: String?) {
- guard let url else {
- return
- }
- retriedRequests.removeValue(forKey: url)
- }
-}
diff --git a/MailCore/API/MailApiFetcher/MailApiFetchable.swift b/MailCore/API/MailApiFetcher/MailApiFetchable.swift
new file mode 100644
index 000000000..c408c25f3
--- /dev/null
+++ b/MailCore/API/MailApiFetcher/MailApiFetchable.swift
@@ -0,0 +1,123 @@
+/*
+ Infomaniak Mail - iOS App
+ Copyright (C) 2022 Infomaniak Network SA
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ 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, see .
+ */
+
+import Foundation
+import Alamofire
+
+/// Public interface of `MailApiFetcher`
+public typealias MailApiFetchable = MailApiCommonFetchable & MailApiExtendedFetchable
+
+/// Main interface of the `MailApiFetcher`
+public protocol MailApiCommonFetchable {
+ func mailboxes() async throws -> [Mailbox]
+
+ func addMailbox(mail: String, password: String) async throws -> MailboxLinkedResult
+
+ func updateMailboxPassword(mailbox: Mailbox, password: String) async throws -> Bool
+
+ func detachMailbox(mailbox: Mailbox) async throws -> Bool
+
+ func listBackups(mailbox: Mailbox) async throws -> BackupsList
+
+ func restoreBackup(mailbox: Mailbox, date: String) async throws -> Bool
+
+ func threads(mailbox: Mailbox,
+ folderId: String,
+ filter: Filter,
+ searchFilter: [URLQueryItem],
+ isDraftFolder: Bool) async throws -> ThreadResult
+
+ func threads(from resource: String, searchFilter: [URLQueryItem]) async throws -> ThreadResult
+
+ func download(message: Message) async throws -> URL
+
+ func quotas(mailbox: Mailbox) async throws -> Quotas
+
+ func undoAction(resource: String) async throws -> Bool
+
+ func star(mailbox: Mailbox, messages: [Message]) async throws -> MessageActionResult
+
+ func unstar(mailbox: Mailbox, messages: [Message]) async throws -> MessageActionResult
+
+ func downloadAttachments(message: Message) async throws -> URL
+
+ func blockSender(message: Message) async throws -> Bool
+
+ func reportPhishing(message: Message) async throws -> Bool
+
+ func create(mailbox: Mailbox, folder: NewFolder) async throws -> Folder
+
+ func createAttachment(mailbox: Mailbox,
+ attachmentData: Data,
+ attachment: Attachment,
+ progressObserver: @escaping (Double) -> Void) async throws -> Attachment
+
+ func attachmentsToForward(mailbox: Mailbox, message: Message) async throws -> AttachmentsToForwardResult
+}
+
+/// Extended capabilities of the `MailApiFetcher`
+public protocol MailApiExtendedFetchable {
+
+ func permissions(mailbox: Mailbox) async throws -> MailboxPermissions
+
+ func contacts() async throws -> [Contact]
+
+ func addressBooks() async throws -> AddressBookResult
+
+ func addContact(_ recipient: Recipient, to addressBook: AddressBook) async throws -> Int
+
+ func signatures(mailbox: Mailbox) async throws -> SignatureResponse
+
+ func updateSignature(mailbox: Mailbox, signature: Signature) async throws -> Bool
+
+ func folders(mailbox: Mailbox) async throws -> [Folder]
+
+ func flushFolder(mailbox: Mailbox, folderId: String) async throws -> Bool
+
+ func messagesUids(mailboxUuid: String,
+ folderId: String,
+ paginationInfo: PaginationInfo?) async throws -> MessageUidsResult
+
+ func messagesByUids(mailboxUuid: String, folderId: String, messageUids: [String]) async throws -> MessageByUidsResult
+
+ func messagesDelta(mailboxUUid: String, folderId: String, signature: String) async throws -> MessageDeltaResult
+
+ func message(message: Message) async throws -> Message
+
+ func markAsSeen(mailbox: Mailbox, messages: [Message]) async throws -> MessageActionResult
+
+ func markAsUnseen(mailbox: Mailbox, messages: [Message]) async throws -> MessageActionResult
+
+ func move(mailbox: Mailbox, messages: [Message], destinationId: String) async throws -> UndoResponse
+
+ func delete(mailbox: Mailbox, messages: [Message]) async throws -> Empty
+
+ func attachment(attachment: Attachment) async throws -> Data
+
+ func draft(mailbox: Mailbox, draftUuid: String) async throws -> Draft
+
+ func draft(from message: Message) async throws -> Draft
+
+ func send(mailbox: Mailbox, draft: Draft) async throws -> SendResponse
+
+ func save(mailbox: Mailbox, draft: Draft) async throws -> DraftResponse
+
+ func deleteDraft(mailbox: Mailbox, draftId: String) async throws -> Empty?
+
+ func deleteDraft(draftResource: String) async throws -> Empty?
+}
diff --git a/MailCore/API/MailApiFetcher/MailApiFetcher+Common.swift b/MailCore/API/MailApiFetcher/MailApiFetcher+Common.swift
new file mode 100644
index 000000000..49870a6ef
--- /dev/null
+++ b/MailCore/API/MailApiFetcher/MailApiFetcher+Common.swift
@@ -0,0 +1,164 @@
+/*
+ Infomaniak Mail - iOS App
+ Copyright (C) 2022 Infomaniak Network SA
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ 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, see .
+ */
+
+import Alamofire
+import Foundation
+import InfomaniakCore
+import InfomaniakLogin
+
+/// implementing `MailApiCommonFetchable`
+public extension MailApiFetcher {
+ // MARK: - API methods
+
+ func mailboxes() async throws -> [Mailbox] {
+ try await perform(request: authenticatedRequest(.mailboxes)).data
+ }
+
+ func addMailbox(mail: String, password: String) async throws -> MailboxLinkedResult {
+ try await perform(request: authenticatedRequest(
+ .addMailbox,
+ method: .post,
+ parameters: ["mail": mail, "password": password, "is_primary": false]
+ )).data
+ }
+
+ func updateMailboxPassword(mailbox: Mailbox, password: String) async throws -> Bool {
+ try await perform(request: authenticatedRequest(
+ .updateMailboxPassword(mailboxId: mailbox.mailboxId),
+ method: .put,
+ parameters: ["password": password]
+ )).data
+ }
+
+ func detachMailbox(mailbox: Mailbox) async throws -> Bool {
+ try await perform(request: authenticatedRequest(.detachMailbox(mailboxId: mailbox.mailboxId), method: .delete)).data
+ }
+
+ func listBackups(mailbox: Mailbox) async throws -> BackupsList {
+ try await perform(request: authenticatedRequest(.backups(hostingId: mailbox.hostingId, mailboxName: mailbox.mailbox)))
+ .data
+ }
+
+ @discardableResult
+ func restoreBackup(mailbox: Mailbox, date: String) async throws -> Bool {
+ try await perform(request: authenticatedRequest(.backups(hostingId: mailbox.hostingId, mailboxName: mailbox.mailbox),
+ method: .put,
+ parameters: ["date": date])).data
+ }
+
+ func threads(mailbox: Mailbox, folderId: String, filter: Filter = .all,
+ searchFilter: [URLQueryItem] = [], isDraftFolder: Bool = false) async throws -> ThreadResult {
+ try await perform(request: authenticatedRequest(.threads(
+ uuid: mailbox.uuid,
+ folderId: folderId,
+ filter: filter == .all ? nil : filter.rawValue,
+ searchFilters: searchFilter,
+ isDraftFolder: isDraftFolder
+ ))).data
+ }
+
+ func threads(from resource: String, searchFilter: [URLQueryItem] = []) async throws -> ThreadResult {
+ try await perform(request: authenticatedRequest(.resource(resource, queryItems: searchFilter))).data
+ }
+
+ func download(message: Message) async throws -> URL {
+ let destination = DownloadRequest.suggestedDownloadDestination(options: [
+ .createIntermediateDirectories,
+ .removePreviousFile
+ ])
+ let download = authenticatedSession.download(Endpoint.resource(message.downloadResource).url, to: destination)
+ return try await download.serializingDownloadedFileURL().value
+ }
+
+ func quotas(mailbox: Mailbox) async throws -> Quotas {
+ try await perform(request: authenticatedRequest(.quotas(mailbox: mailbox.mailbox, productId: mailbox.hostingId))).data
+ }
+
+ @discardableResult
+ func undoAction(resource: String) async throws -> Bool {
+ try await perform(request: authenticatedRequest(.resource(resource), method: .post)).data
+ }
+
+ func star(mailbox: Mailbox, messages: [Message]) async throws -> MessageActionResult {
+ try await perform(request: authenticatedRequest(.star(uuid: mailbox.uuid),
+ method: .post,
+ parameters: ["uids": messages.map(\.uid)])).data
+ }
+
+ func unstar(mailbox: Mailbox, messages: [Message]) async throws -> MessageActionResult {
+ try await perform(request: authenticatedRequest(.unstar(uuid: mailbox.uuid),
+ method: .post,
+ parameters: ["uids": messages.map(\.uid)])).data
+ }
+
+ func downloadAttachments(message: Message) async throws -> URL {
+ let destination = DownloadRequest.suggestedDownloadDestination(options: [
+ .createIntermediateDirectories,
+ .removePreviousFile
+ ])
+ let download = authenticatedSession.download(
+ Endpoint.downloadAttachments(messageResource: message.resource).url,
+ to: destination
+ )
+ return try await download.serializingDownloadedFileURL().value
+ }
+
+ func blockSender(message: Message) async throws -> Bool {
+ try await perform(request: authenticatedRequest(.blockSender(messageResource: message.resource), method: .post)).data
+ }
+
+ func reportPhishing(message: Message) async throws -> Bool {
+ try await perform(request: authenticatedRequest(.report(messageResource: message.resource),
+ method: .post,
+ parameters: ["type": "phishing"])).data
+ }
+
+ func create(mailbox: Mailbox, folder: NewFolder) async throws -> Folder {
+ try await perform(request: authenticatedRequest(.folders(uuid: mailbox.uuid), method: .post, parameters: folder)).data
+ }
+
+ func createAttachment(
+ mailbox: Mailbox,
+ attachmentData: Data,
+ attachment: Attachment,
+ progressObserver: @escaping (Double) -> Void
+ ) async throws -> Attachment {
+ let headers = HTTPHeaders([
+ "x-ws-attachment-filename": attachment.name,
+ "x-ws-attachment-mime-type": attachment.mimeType,
+ "x-ws-attachment-disposition": attachment.disposition.rawValue
+ ])
+ var request = try URLRequest(url: Endpoint.createAttachment(uuid: mailbox.uuid).url, method: .post, headers: headers)
+ request.httpBody = attachmentData
+
+ let uploadRequest = authenticatedSession.request(request)
+ Task {
+ for await progress in uploadRequest.uploadProgress() {
+ progressObserver(progress.fractionCompleted)
+ }
+ }
+
+ return try await perform(request: uploadRequest).data
+ }
+
+ func attachmentsToForward(mailbox: Mailbox, message: Message) async throws -> AttachmentsToForwardResult {
+ let attachmentsToForward = AttachmentsToForward(toForwardUids: [message.uid], mode: AttachmentDisposition.inline.rawValue)
+ return try await perform(request: authenticatedRequest(.attachmentToForward(uuid: mailbox.uuid), method: .post,
+ parameters: attachmentsToForward)).data
+ }
+}
diff --git a/MailCore/API/MailApiFetcher/MailApiFetcher+Extended.swift b/MailCore/API/MailApiFetcher/MailApiFetcher+Extended.swift
new file mode 100644
index 000000000..3010bf45b
--- /dev/null
+++ b/MailCore/API/MailApiFetcher/MailApiFetcher+Extended.swift
@@ -0,0 +1,180 @@
+/*
+ Infomaniak Mail - iOS App
+ Copyright (C) 2022 Infomaniak Network SA
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ 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, see .
+ */
+
+import Alamofire
+import Foundation
+import InfomaniakCore
+import InfomaniakLogin
+
+/// implementing `MailApiExtendedFetchable`
+public extension MailApiFetcher {
+ func permissions(mailbox: Mailbox) async throws -> MailboxPermissions {
+ try await perform(request: authenticatedRequest(.permissions(mailbox: mailbox))).data
+ }
+
+ func contacts() async throws -> [Contact] {
+ try await perform(request: authenticatedRequest(.contacts)).data
+ }
+
+ func addressBooks() async throws -> AddressBookResult {
+ try await perform(request: authenticatedRequest(.addressBooks)).data
+ }
+
+ func addContact(_ recipient: Recipient, to addressBook: AddressBook) async throws -> Int {
+ try await perform(request: authenticatedSession.request(Endpoint.addContact.url,
+ method: .post,
+ parameters: NewContact(from: recipient, addressBook: addressBook),
+ encoder: JSONParameterEncoder.default)).data
+ }
+
+ func signatures(mailbox: Mailbox) async throws -> SignatureResponse {
+ try await perform(request: authenticatedRequest(.signatures(hostingId: mailbox.hostingId, mailboxName: mailbox.mailbox)))
+ .data
+ }
+
+ func updateSignature(mailbox: Mailbox, signature: Signature) async throws -> Bool {
+ try await perform(request:
+ authenticatedRequest(
+ .updateSignature(
+ hostingId: mailbox.hostingId,
+ mailboxName: mailbox.mailbox,
+ signatureId: signature.id
+ ),
+ method: .patch,
+ parameters: signature
+ )).data
+ }
+
+ func folders(mailbox: Mailbox) async throws -> [Folder] {
+ try await perform(request: authenticatedRequest(.folders(uuid: mailbox.uuid))).data
+ }
+
+ func flushFolder(mailbox: Mailbox, folderId: String) async throws -> Bool {
+ try await perform(request: authenticatedRequest(.flushFolder(mailboxUuid: mailbox.uuid, folderId: folderId),
+ method: .post)).data
+ }
+
+ func markAsSeen(mailbox: Mailbox, messages: [Message]) async throws -> MessageActionResult {
+ try await perform(request: authenticatedRequest(.messageSeen(uuid: mailbox.uuid),
+ method: .post,
+ parameters: ["uids": messages.map(\.uid)])).data
+ }
+
+ func markAsUnseen(mailbox: Mailbox, messages: [Message]) async throws -> MessageActionResult {
+ try await perform(request: authenticatedRequest(.messageUnseen(uuid: mailbox.uuid),
+ method: .post,
+ parameters: ["uids": messages.map(\.uid)])).data
+ }
+
+ func move(mailbox: Mailbox, messages: [Message], destinationId: String) async throws -> UndoResponse {
+ try await perform(request: authenticatedRequest(.moveMessages(uuid: mailbox.uuid),
+ method: .post,
+ parameters: ["uids": messages.map(\.uid), "to": destinationId])).data
+ }
+
+ func delete(mailbox: Mailbox, messages: [Message]) async throws -> Empty {
+ try await perform(request: authenticatedRequest(.deleteMessages(uuid: mailbox.uuid),
+ method: .post,
+ parameters: ["uids": messages.map(\.uid)])).data
+ }
+
+ func attachment(attachment: Attachment) async throws -> Data {
+ guard let resource = attachment.resource else {
+ throw MailError.resourceError
+ }
+ let request = authenticatedRequest(.resource(resource))
+ return try await request.serializingData().value
+ }
+
+ func messagesUids(
+ mailboxUuid: String,
+ folderId: String,
+ paginationInfo: PaginationInfo? = nil
+ ) async throws -> MessageUidsResult {
+ try await perform(request: authenticatedRequest(.messagesUids(
+ mailboxUuid: mailboxUuid,
+ folderId: folderId,
+ paginationInfo: paginationInfo
+ ))).data
+ }
+
+ func messagesByUids(mailboxUuid: String, folderId: String, messageUids: [String]) async throws -> MessageByUidsResult {
+ try await perform(request: authenticatedRequest(.messagesByUids(
+ mailboxUuid: mailboxUuid,
+ folderId: folderId,
+ messagesUids: messageUids
+ ))).data
+ }
+
+ func messagesDelta(mailboxUUid: String, folderId: String, signature: String) async throws -> MessageDeltaResult {
+ try await perform(request: authenticatedRequest(.messagesDelta(
+ mailboxUuid: mailboxUUid,
+ folderId: folderId,
+ signature: signature
+ ))).data
+ }
+
+ func message(message: Message) async throws -> Message {
+ try await perform(request: authenticatedRequest(.resource(
+ message.resource,
+ queryItems: [
+ URLQueryItem(name: "prefered_format", value: "html")
+ ]
+ ))).data
+ }
+
+ func draft(mailbox: Mailbox, draftUuid: String) async throws -> Draft {
+ try await perform(request: authenticatedRequest(.draft(uuid: mailbox.uuid, draftUuid: draftUuid))).data
+ }
+
+ func draft(from message: Message) async throws -> Draft {
+ guard let resource = message.draftResource else {
+ throw MailError.resourceError
+ }
+ return try await perform(request: authenticatedRequest(.resource(resource))).data
+ }
+
+ func send(mailbox: Mailbox, draft: Draft) async throws -> SendResponse {
+ try await perform(request: authenticatedRequest(
+ draft.remoteUUID.isEmpty ? .draft(uuid: mailbox.uuid) : .draft(uuid: mailbox.uuid, draftUuid: draft.remoteUUID),
+ method: draft.remoteUUID.isEmpty ? .post : .put,
+ parameters: draft
+ )).data
+ }
+
+ func save(mailbox: Mailbox, draft: Draft) async throws -> DraftResponse {
+ try await perform(request: authenticatedRequest(
+ draft.remoteUUID.isEmpty ? .draft(uuid: mailbox.uuid) : .draft(uuid: mailbox.uuid, draftUuid: draft.remoteUUID),
+ method: draft.remoteUUID.isEmpty ? .post : .put,
+ parameters: draft
+ )).data
+ }
+
+ @discardableResult
+ func deleteDraft(mailbox: Mailbox, draftId: String) async throws -> Empty? {
+ // TODO: Remove try? when bug will be fixed from API
+ return try? await perform(request: authenticatedRequest(.draft(uuid: mailbox.uuid, draftUuid: draftId), method: .delete))
+ .data
+ }
+
+ @discardableResult
+ func deleteDraft(draftResource: String) async throws -> Empty? {
+ // TODO: Remove try? when bug will be fixed from API
+ return try? await perform(request: authenticatedRequest(.resource(draftResource), method: .delete)).data
+ }
+}
diff --git a/MailCore/API/MailApiFetcher+Nuke.swift b/MailCore/API/MailApiFetcher/MailApiFetcher+Nuke.swift
similarity index 100%
rename from MailCore/API/MailApiFetcher+Nuke.swift
rename to MailCore/API/MailApiFetcher/MailApiFetcher+Nuke.swift
diff --git a/MailCore/API/MailApiFetcher/MailApiFetcher.swift b/MailCore/API/MailApiFetcher/MailApiFetcher.swift
new file mode 100644
index 000000000..c31ddc493
--- /dev/null
+++ b/MailCore/API/MailApiFetcher/MailApiFetcher.swift
@@ -0,0 +1,81 @@
+/*
+ Infomaniak Mail - iOS App
+ Copyright (C) 2022 Infomaniak Network SA
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ 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, see .
+ */
+
+import Alamofire
+import Foundation
+import InfomaniakCore
+import InfomaniakDI
+import InfomaniakLogin
+import Sentry
+import UIKit
+
+public extension ApiFetcher {
+ convenience init(token: ApiToken, delegate: RefreshTokenDelegate) {
+ self.init()
+ createAuthenticatedSession(token,
+ authenticator: SyncedAuthenticator(refreshTokenDelegate: delegate),
+ additionalAdapters: [RequestContextIdAdaptor()])
+ }
+}
+
+public final class MailApiFetcher: ApiFetcher, MailApiFetchable {
+ public static let clientId = "E90BC22D-67A8-452C-BE93-28DA33588CA4"
+
+ /// All status except 401 are handled by our code, 401 status is handled by Alamofire's Authenticator code
+ private lazy var handledHttpStatus: Set = {
+ var allStatus = Set(200 ... 500)
+ allStatus.remove(401)
+ return allStatus
+ }()
+
+ override public func perform(
+ request: DataRequest,
+ decoder: JSONDecoder = ApiFetcher.decoder
+ ) async throws -> (data: T, responseAt: Int?) {
+ do {
+ return try await super.perform(request: request.validate(statusCode: handledHttpStatus))
+ } catch InfomaniakError.apiError(let apiError) {
+ throw MailApiError.mailApiErrorWithFallback(apiErrorCode: apiError.code)
+ } catch InfomaniakError.serverError(statusCode: let statusCode) {
+ throw MailServerError(httpStatus: statusCode)
+ } catch {
+ if let afError = error.asAFError {
+ if case .responseSerializationFailed(let reason) = afError,
+ case .decodingFailed(let error) = reason {
+ var rawJson = "No data"
+ if let data = request.data,
+ let stringData = String(data: data, encoding: .utf8) {
+ rawJson = stringData
+ }
+
+ SentrySDK.capture(error: error) { scope in
+ scope.setExtras(["Request URL": request.request?.url?.absoluteString ?? "No URL",
+ "Request Id": request.request?
+ .value(forHTTPHeaderField: RequestContextIdAdaptor.requestContextIdHeader) ??
+ "No request Id",
+ "Decoded type": String(describing: T.self),
+ "Raw JSON": rawJson])
+ }
+ }
+ throw AFErrorWithContext(request: request, afError: afError)
+ } else {
+ throw error
+ }
+ }
+ }
+}
diff --git a/MailCore/API/NetworkRequestRetrier.swift b/MailCore/API/NetworkRequestRetrier.swift
new file mode 100644
index 000000000..634e54d67
--- /dev/null
+++ b/MailCore/API/NetworkRequestRetrier.swift
@@ -0,0 +1,74 @@
+/*
+ Infomaniak Mail - iOS App
+ Copyright (C) 2022 Infomaniak Network SA
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ 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, see .
+ */
+
+import Foundation
+import Alamofire
+
+final class NetworkRequestRetrier: RequestInterceptor {
+ let maxRetry: Int
+ private var retriedRequests: [String: Int] = [:]
+ let timeout = -1001
+ let connectionLost = -1005
+
+ init(maxRetry: Int = 3) {
+ self.maxRetry = maxRetry
+ }
+
+ func retry(
+ _ request: Alamofire.Request,
+ for session: Session,
+ dueTo error: Error,
+ completion: @escaping (RetryResult) -> Void
+ ) {
+ guard request.task?.response == nil,
+ let url = request.request?.url?.absoluteString else {
+ removeCachedUrlRequest(url: request.request?.url?.absoluteString)
+ completion(.doNotRetry)
+ return
+ }
+
+ let errorGenerated = error as NSError
+ switch errorGenerated.code {
+ case timeout, connectionLost:
+ guard let retryCount = retriedRequests[url] else {
+ retriedRequests[url] = 1
+ completion(.retryWithDelay(0.5))
+ return
+ }
+
+ if retryCount < maxRetry {
+ retriedRequests[url] = retryCount + 1
+ completion(.retryWithDelay(0.5))
+ } else {
+ removeCachedUrlRequest(url: url)
+ completion(.doNotRetry)
+ }
+
+ default:
+ removeCachedUrlRequest(url: url)
+ completion(.doNotRetry)
+ }
+ }
+
+ private func removeCachedUrlRequest(url: String?) {
+ guard let url else {
+ return
+ }
+ retriedRequests.removeValue(forKey: url)
+ }
+}
diff --git a/MailCore/API/SyncedAuthenticator.swift b/MailCore/API/SyncedAuthenticator.swift
new file mode 100644
index 000000000..e4d0f9f77
--- /dev/null
+++ b/MailCore/API/SyncedAuthenticator.swift
@@ -0,0 +1,94 @@
+/*
+ Infomaniak Mail - iOS App
+ Copyright (C) 2022 Infomaniak Network SA
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ 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, see .
+ */
+
+import Alamofire
+import Foundation
+import InfomaniakCore
+import InfomaniakDI
+import InfomaniakLogin
+import Sentry
+
+final class SyncedAuthenticator: OAuthAuthenticator {
+ func handleFailedRefreshingToken(oldToken: ApiToken, error: Error?) -> Result {
+ guard let error = error as NSError?,
+ error.domain == "invalid_grant" else {
+ // Couldn't refresh the token, keep the old token and fetch it later. Maybe because of bad network ?
+ SentrySDK
+ .addBreadcrumb(oldToken.generateBreadcrumb(level: .error,
+ message: "Refreshing token failed - Other \(error.debugDescription)"))
+ return .success(oldToken)
+ }
+
+ // Couldn't refresh the token, API says it's invalid
+ SentrySDK.addBreadcrumb(oldToken.generateBreadcrumb(level: .error, message: "Refreshing token failed - Invalid grant"))
+ refreshTokenDelegate?.didFailRefreshToken(oldToken)
+ return .failure(error)
+ }
+
+ override func refresh(
+ _ credential: OAuthAuthenticator.Credential,
+ for session: Session,
+ completion: @escaping (Result) -> Void
+ ) {
+ @InjectService var keychainHelper: KeychainHelper
+ @InjectService var tokenStore: TokenStore
+ @InjectService var networkLoginService: InfomaniakNetworkLoginable
+
+ SentrySDK.addBreadcrumb((credential as ApiToken).generateBreadcrumb(level: .info, message: "Refreshing token - Starting"))
+
+ guard keychainHelper.isKeychainAccessible else {
+ SentrySDK
+ .addBreadcrumb((credential as ApiToken)
+ .generateBreadcrumb(level: .error, message: "Refreshing token failed - Keychain unaccessible"))
+ completion(.failure(MailError.noToken))
+ return
+ }
+
+ // Maybe someone else refreshed our token
+ if let token = tokenStore.tokenFor(userId: credential.userId, fetchLocation: .keychain),
+ token.expirationDate > credential.expirationDate {
+ SentrySDK.addBreadcrumb(token.generateBreadcrumb(level: .info, message: "Refreshing token - Success with local"))
+ completion(.success(token))
+ return
+ }
+
+ // It is absolutely necessary that the app stays awake while we refresh the token
+ BackgroundExecutor.executeWithBackgroundTask { endBackgroundTask in
+ networkLoginService.refreshToken(token: credential) { token, error in
+ // New token has been fetched correctly
+ if let token {
+ SentrySDK
+ .addBreadcrumb(token
+ .generateBreadcrumb(level: .info, message: "Refreshing token - Success with remote"))
+ self.refreshTokenDelegate?.didUpdateToken(newToken: token, oldToken: credential)
+ completion(.success(token))
+ } else {
+ completion(self.handleFailedRefreshingToken(oldToken: credential, error: error))
+ }
+ endBackgroundTask()
+ }
+ } onExpired: {
+ SentrySDK
+ .addBreadcrumb((credential as ApiToken)
+ .generateBreadcrumb(level: .error, message: "Refreshing token failed - Background task expired"))
+ // If we didn't fetch the new token in the given time there is not much we can do apart from hoping that it wasn't
+ // revoked
+ completion(.failure(MailError.noToken))
+ }
+ }
+}
diff --git a/MailCore/Cache/MailboxManager.swift b/MailCore/Cache/MailboxManager.swift
deleted file mode 100644
index 8130fbb17..000000000
--- a/MailCore/Cache/MailboxManager.swift
+++ /dev/null
@@ -1,1304 +0,0 @@
-/*
- Infomaniak Mail - iOS App
- Copyright (C) 2022 Infomaniak Network SA
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- 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, see .
- */
-
-import CocoaLumberjackSwift
-import Foundation
-import InfomaniakCore
-import InfomaniakCoreUI
-import InfomaniakDI
-import MailResources
-import RealmSwift
-import Sentry
-import SwiftRegex
-
-public final class MailboxManager: ObservableObject {
- @LazyInjectService private var snackbarPresenter: SnackBarPresentable
-
- public final class MailboxManagerConstants {
- private let fileManager = FileManager.default
- public let rootDocumentsURL: URL
- public let groupDirectoryURL: URL
- public let cacheDirectoryURL: URL
-
- init() {
- @InjectService var appGroupPathProvider: AppGroupPathProvidable
- groupDirectoryURL = appGroupPathProvider.groupDirectoryURL
- rootDocumentsURL = appGroupPathProvider.realmRootURL
- cacheDirectoryURL = appGroupPathProvider.cacheDirectoryURL
-
- DDLogInfo("groupDirectoryURL: \(groupDirectoryURL)")
- DDLogInfo(
- "App working path is: \(fileManager.urls(for: .documentDirectory, in: .userDomainMask).first?.absoluteString ?? "")"
- )
- DDLogInfo("Group container path is: \(groupDirectoryURL.absoluteString)")
- }
- }
-
- public static let constants = MailboxManagerConstants()
-
- public let realmConfiguration: Realm.Configuration
- public let mailbox: Mailbox
- public let account: Account
-
- public let apiFetcher: MailApiFetcher
- public let contactManager: ContactManager
- private let backgroundRealm: BackgroundRealm
-
- private lazy var refreshActor = RefreshActor(mailboxManager: self)
-
- public init(account: Account, mailbox: Mailbox, apiFetcher: MailApiFetcher, contactManager: ContactManager) {
- self.account = account
- self.mailbox = mailbox
- self.apiFetcher = apiFetcher
- self.contactManager = contactManager
- let realmName = "\(mailbox.userId)-\(mailbox.mailboxId).realm"
- realmConfiguration = Realm.Configuration(
- fileURL: MailboxManager.constants.rootDocumentsURL.appendingPathComponent(realmName),
- schemaVersion: 17,
- migrationBlock: { migration, oldSchemaVersion in
- // No migration needed from 0 to 16
- if oldSchemaVersion < 17 {
- // Remove signatures without `senderName` and `senderEmailIdn`
- migration.deleteData(forType: Signature.className())
- }
- },
- objectTypes: [
- Folder.self,
- Thread.self,
- Message.self,
- Body.self,
- Attachment.self,
- Recipient.self,
- Draft.self,
- Signature.self,
- SearchHistory.self
- ]
- )
- backgroundRealm = BackgroundRealm(configuration: realmConfiguration)
- }
-
- public func getRealm() -> Realm {
- do {
- let realm = try Realm(configuration: realmConfiguration)
- realm.refresh()
- return realm
- } catch {
- // We can't recover from this error but at least we report it correctly on Sentry
- Logging.reportRealmOpeningError(error, realmConfiguration: realmConfiguration)
- }
- }
-
- /// Delete all mailbox data cache for user
- /// - Parameters:
- /// - userId: User ID
- /// - mailboxId: Mailbox ID (`nil` if all user mailboxes)
- public static func deleteUserMailbox(userId: Int, mailboxId: Int? = nil) {
- let files = (try? FileManager.default
- .contentsOfDirectory(at: MailboxManager.constants.rootDocumentsURL, includingPropertiesForKeys: nil))
- files?.forEach { file in
- if let matches = Regex(pattern: "(\\d+)-(\\d+).realm.*")?.firstMatch(in: file.lastPathComponent), matches.count > 2 {
- let fileUserId = matches[1]
- let fileMailboxId = matches[2]
- if Int(fileUserId) == userId && (mailboxId == nil || Int(fileMailboxId) == mailboxId) {
- DDLogInfo("Deleting file: \(file.lastPathComponent)")
- try? FileManager.default.removeItem(at: file)
- }
- }
- }
- }
-
- // MARK: - Signatures
-
- public func refreshAllSignatures() async throws {
- // Get from API
- let signaturesResult = try await apiFetcher.signatures(mailbox: mailbox)
- let updatedSignatures = Array(signaturesResult.signatures)
-
- await backgroundRealm.execute { realm in
- let signaturesToDelete: [Signature] // no longer present server side
- let signaturesToUpdate: [Signature] // updated signatures
- let signaturesToAdd: [Signature] // new signatures
-
- // fetch all local signatures
- let existingSignatures = Array(realm.objects(Signature.self))
-
- signaturesToAdd = updatedSignatures.filter { updatedElement in
- !existingSignatures.contains(updatedElement)
- }
-
- signaturesToUpdate = updatedSignatures.filter { updatedElement in
- existingSignatures.contains(updatedElement)
- }
-
- signaturesToDelete = existingSignatures.filter { existingElement in
- !updatedSignatures.contains(existingElement)
- }
-
- // NOTE: local drafts in `signaturesToDelete` should be migrated to use the new default signature.
-
- // Update signatures in Realm
- try? realm.safeWrite {
- realm.add(signaturesToUpdate, update: .modified)
- realm.delete(signaturesToDelete)
- realm.add(signaturesToAdd, update: .modified)
- }
- }
- }
-
- public func updateSignature(signature: Signature) async throws {
- _ = try await apiFetcher.updateSignature(mailbox: mailbox, signature: signature)
- try await refreshAllSignatures()
- }
-
- public func getStoredSignatures(using realm: Realm? = nil) -> [Signature] {
- let realm = realm ?? getRealm()
- return Array(realm.objects(Signature.self))
- }
-
- // MARK: - Folders
-
- public func folders() async throws {
- // Get from Realm
- guard ReachabilityListener.instance.currentStatus != .offline else {
- return
- }
- // Get from API
- let folderResult = try await apiFetcher.folders(mailbox: mailbox)
- let newFolders = getSubFolders(from: folderResult)
-
- await backgroundRealm.execute { realm in
- for folder in newFolders {
- self.keepCacheAttributes(for: folder, using: realm)
- }
-
- let cachedFolders = realm.objects(Folder.self)
-
- // Update folders in Realm
- try? realm.safeWrite {
- // Remove old folders
- realm.add(folderResult, update: .modified)
- let toDeleteFolders = Set(cachedFolders).subtracting(Set(newFolders)).filter { $0.id != Constants.searchFolderId }
- var toDeleteThreads = [Thread]()
-
- // Threads contains in folders to delete
- let mayBeDeletedThreads = Set(toDeleteFolders.flatMap(\.threads))
- // Messages contains in folders to delete
- let toDeleteMessages = Set(toDeleteFolders.flatMap(\.messages))
-
- // Delete thread if all his messages are deleted
- for thread in mayBeDeletedThreads where Set(thread.messages).isSubset(of: toDeleteMessages) {
- toDeleteThreads.append(thread)
- }
-
- realm.delete(toDeleteMessages)
- realm.delete(toDeleteThreads)
- realm.delete(toDeleteFolders)
- }
- }
- }
-
- /// Get the folder with the corresponding role in Realm.
- /// - Parameters:
- /// - role: Role of the folder.
- /// - realm: The Realm instance to use. If this parameter is `nil`, a new one will be created.
- /// - Returns: The folder with the corresponding role, or `nil` if no such folder has been found.
- public func getFolder(with role: FolderRole, using realm: Realm? = nil) -> Folder? {
- let realm = realm ?? getRealm()
- return realm.objects(Folder.self).where { $0.role == role }.first
- }
-
- /// Get all the real folders in Realm
- /// - Parameters:
- /// - realm: The Realm instance to use. If this parameter is `nil`, a new one will be created.
- /// - Returns: The list of real folders.
- public func getFolders(using realm: Realm? = nil) -> [Folder] {
- let realm = realm ?? getRealm()
- return Array(realm.objects(Folder.self).where { $0.toolType == nil })
- }
-
- public func createFolder(name: String, parent: Folder? = nil) async throws -> Folder {
- var folder = try await apiFetcher.create(mailbox: mailbox, folder: NewFolder(name: name, path: parent?.path))
- await backgroundRealm.execute { realm in
- try? realm.safeWrite {
- realm.add(folder)
- if let parent {
- parent.fresh(using: realm)?.children.insert(folder)
- }
- }
- folder = folder.freeze()
- }
- return folder
- }
-
- // MARK: - RefreshActor
-
- public func flushFolder(folder: Folder) async throws -> Bool {
- try await refreshActor.flushFolder(folder: folder, mailbox: mailbox, apiFetcher: apiFetcher)
- }
-
- public func refreshFolder(from messages: [Message], additionalFolder: Folder? = nil) async throws {
- try await refreshActor.refreshFolder(from: messages, additionalFolder: additionalFolder)
- }
-
- public func refresh(folder: Folder) async {
- await refreshActor.refresh(folder: folder)
- }
-
- public func cancelRefresh() async {
- await refreshActor.cancelRefresh()
- }
-
- // MARK: - Thread
-
- public func threads(folder: Folder, fetchCurrentFolderCompleted: (() -> Void) = {}) async throws {
- try await messages(folder: folder.freezeIfNeeded())
- fetchCurrentFolderCompleted()
-
- var roles: [FolderRole] {
- switch folder.role {
- case .inbox:
- return [.sent, .draft]
- case .sent:
- return [.inbox, .draft]
- case .draft:
- return [.inbox, .sent]
- default:
- return []
- }
- }
-
- for folderRole in roles {
- if let realFolder = getFolder(with: folderRole) {
- try await messages(folder: realFolder.freezeIfNeeded())
- }
- }
- }
-
- private func deleteMessages(uids: [String]) async {
- guard !uids.isEmpty && !Task.isCancelled else { return }
-
- await backgroundRealm.execute { realm in
- let batchSize = 100
- for index in stride(from: 0, to: uids.count, by: batchSize) {
- autoreleasepool {
- let uidsBatch = Array(uids[index ..< min(index + batchSize, uids.count)])
-
- let messagesToDelete = realm.objects(Message.self).where { $0.uid.in(uidsBatch) }
- var threadsToUpdate = Set()
- var threadsToDelete = Set()
- var draftsToDelete = Set()
-
- for message in messagesToDelete {
- if let draft = self.draft(messageUid: message.uid, using: realm) {
- draftsToDelete.insert(draft)
- }
- for parent in message.threads {
- threadsToUpdate.insert(parent)
- }
- }
-
- let foldersToUpdate = Set(threadsToUpdate.compactMap(\.folder))
-
- try? realm.safeWrite {
- realm.delete(draftsToDelete)
- realm.delete(messagesToDelete)
- for thread in threadsToUpdate {
- if thread.messageInFolderCount == 0 {
- threadsToDelete.insert(thread)
- } else {
- do {
- try thread.recomputeOrFail()
- } catch {
- threadsToDelete.insert(thread)
- SentryDebug.threadHasNilLastMessageFromFolderDate(thread: thread)
- }
- }
- }
- realm.delete(threadsToDelete)
- for updateFolder in foldersToUpdate {
- updateFolder.computeUnreadCount()
- }
- }
- }
- }
- }
- }
-
- private func saveThreads(result: ThreadResult, parent: Folder) async {
- await backgroundRealm.execute { realm in
- guard let parentFolder = parent.fresh(using: realm) else { return }
-
- let fetchedThreads = MutableSet()
- fetchedThreads.insert(objectsIn: result.threads ?? [])
-
- for thread in fetchedThreads {
- for message in thread.messages {
- self.keepCacheAttributes(for: message, keepProperties: .standard, using: realm)
- }
- }
-
- // Update thread in Realm
- try? realm.safeWrite {
- // Clean old threads after fetching first page
- if result.currentOffset == 0 {
- parentFolder.lastUpdate = Date()
- realm.delete(parentFolder.threads.flatMap(\.messages).filter { $0.fromSearch == true })
- realm.delete(parentFolder.threads.filter { $0.fromSearch == true })
- }
- realm.add(fetchedThreads, update: .modified)
- parentFolder.threads.insert(objectsIn: fetchedThreads)
- parentFolder.unreadCount = result.folderUnseenMessages
- }
- }
- }
-
- public func toggleRead(threads: [Thread]) async throws {
- if threads.contains(where: \.hasUnseenMessages) {
- var messages = threads.flatMap(\.messages)
- messages.append(contentsOf: messages.flatMap(\.duplicates))
- try await markAsSeen(messages: messages, seen: true)
- } else {
- let messages = threads.flatMap { thread in
- thread.lastMessageAndItsDuplicateToExecuteAction(currentMailboxEmail: mailbox.email)
- }
- try await markAsSeen(messages: messages, seen: false)
- }
- }
-
- public func move(threads: [Thread], to folderRole: FolderRole) async throws -> UndoRedoAction {
- guard let folder = getFolder(with: folderRole)?.freeze() else { throw MailError.folderNotFound }
- return try await move(threads: threads, to: folder)
- }
-
- public func move(threads: [Thread], to folder: Folder) async throws -> UndoRedoAction {
- var messages = threads.flatMap(\.messages).filter { $0.folder == threads.first?.folder }
- messages.append(contentsOf: messages.flatMap(\.duplicates))
-
- return try await move(messages: messages, to: folder)
- }
-
- public func moveOrDelete(threads: [Thread]) async throws {
- let messagesToMoveOrDelete = threads.flatMap(\.messages)
- try await moveOrDelete(messages: messagesToMoveOrDelete)
- }
-
- public func toggleStar(threads: [Thread]) async throws {
- let messagesToToggleStar = threads.flatMap { thread in
- thread.lastMessageAndItsDuplicateToExecuteAction(currentMailboxEmail: mailbox.email)
- }
- try await toggleStar(messages: messagesToToggleStar)
- }
-
- // MARK: - Search
-
- public func initSearchFolder() -> Folder {
- let searchFolder = Folder(
- id: Constants.searchFolderId,
- path: "",
- name: "",
- isFavorite: false,
- separator: "/",
- children: [],
- toolType: .search
- )
-
- let realm = getRealm()
- try? realm.uncheckedSafeWrite {
- realm.add(searchFolder, update: .modified)
- }
- return searchFolder
- }
-
- public func searchThreads(searchFolder: Folder?, filterFolderId: String, filter: Filter = .all,
- searchFilter: [URLQueryItem] = []) async throws -> ThreadResult {
- let threadResult = try await apiFetcher.threads(
- mailbox: mailbox,
- folderId: filterFolderId,
- filter: filter,
- searchFilter: searchFilter
- )
-
- await backgroundRealm.execute { realm in
- for thread in threadResult.threads ?? [] {
- thread.fromSearch = true
-
- for message in thread.messages where realm.object(ofType: Message.self, forPrimaryKey: message.uid) == nil {
- message.fromSearch = true
- }
- }
- }
-
- if let searchFolder {
- await saveThreads(result: threadResult, parent: searchFolder)
- }
-
- return threadResult
- }
-
- public func searchThreads(searchFolder: Folder?, from resource: String,
- searchFilter: [URLQueryItem] = []) async throws -> ThreadResult {
- let threadResult = try await apiFetcher.threads(from: resource, searchFilter: searchFilter)
-
- let realm = getRealm()
- for thread in threadResult.threads ?? [] {
- thread.fromSearch = true
-
- for message in thread.messages where realm.object(ofType: Message.self, forPrimaryKey: message.uid) == nil {
- message.fromSearch = true
- }
- }
-
- if let searchFolder {
- await saveThreads(result: threadResult, parent: searchFolder)
- }
-
- return threadResult
- }
-
- public func searchThreadsOffline(searchFolder: Folder?, filterFolderId: String,
- searchFilters: [SearchCondition]) async {
- await backgroundRealm.execute { realm in
- guard let searchFolder = searchFolder?.fresh(using: realm) else { return }
-
- try? realm.safeWrite {
- realm.delete(realm.objects(Message.self).where { $0.fromSearch == true })
- realm.delete(searchFolder.threads.where { $0.fromSearch == true })
- searchFolder.threads.removeAll()
- }
-
- var predicates: [NSPredicate] = []
- for searchFilter in searchFilters {
- switch searchFilter {
- case .filter(let filter):
- switch filter {
- case .seen:
- predicates.append(NSPredicate(format: "seen = true"))
- case .unseen:
- predicates.append(NSPredicate(format: "seen = false"))
- case .starred:
- predicates.append(NSPredicate(format: "flagged = true"))
- case .unstarred:
- predicates.append(NSPredicate(format: "flagged = false"))
- default:
- break
- }
- case .from(let from):
- predicates.append(NSPredicate(format: "ANY from.email = %@", from))
- case .contains(let content):
- predicates
- .append(
- NSPredicate(format: "subject CONTAINS[c] %@ OR preview CONTAINS[c] %@",
- content, content, content)
- )
- case .everywhere(let searchEverywhere):
- if !searchEverywhere {
- predicates.append(NSPredicate(format: "folderId = %@", filterFolderId))
- }
- case .attachments(let searchAttachments):
- if searchAttachments {
- predicates.append(NSPredicate(format: "hasAttachments = true"))
- }
- }
- }
-
- let compoundPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
-
- let filteredMessages = realm.objects(Message.self).filter(compoundPredicate)
-
- // Update thread in Realm
- try? realm.safeWrite {
- for message in filteredMessages {
- let newMessage = message.detached()
- newMessage.uid = "offline\(newMessage.uid)"
- newMessage.fromSearch = true
-
- let newThread = Thread(
- uid: "offlineThread\(message.uid)",
- messages: [newMessage],
- unseenMessages: 0,
- from: Array(message.from.detached()),
- to: Array(message.to.detached()),
- date: newMessage.date,
- hasAttachments: newMessage.hasAttachments,
- hasDrafts: newMessage.isDraft,
- flagged: newMessage.flagged,
- answered: newMessage.answered,
- forwarded: newMessage.forwarded
- )
- newThread.fromSearch = true
- newThread.subject = message.subject
- searchFolder.threads.insert(newThread)
- }
- }
- }
- }
-
- public func searchHistory(using realm: Realm? = nil) -> SearchHistory {
- let realm = realm ?? getRealm()
- if let searchHistory = realm.objects(SearchHistory.self).first {
- return searchHistory.freeze()
- }
- let newSearchHistory = SearchHistory()
- try? realm.uncheckedSafeWrite {
- realm.add(newSearchHistory)
- }
- return newSearchHistory
- }
-
- public func update(searchHistory: SearchHistory, with value: String) async -> SearchHistory {
- return await backgroundRealm.execute { realm in
- guard let liveSearchHistory = realm.objects(SearchHistory.self).first else { return searchHistory }
- try? realm.safeWrite {
- if let indexToRemove = liveSearchHistory.history.firstIndex(of: value) {
- liveSearchHistory.history.remove(at: indexToRemove)
- }
- liveSearchHistory.history.insert(value, at: 0)
- }
- return liveSearchHistory.freeze()
- }
- }
-
- public func delete(searchHistory: SearchHistory, with value: String) async -> SearchHistory {
- return await backgroundRealm.execute { realm in
- guard let liveSearchHistory = realm.objects(SearchHistory.self).first else { return searchHistory }
- try? realm.safeWrite {
- if let indexToRemove = liveSearchHistory.history.firstIndex(of: value) {
- liveSearchHistory.history.remove(at: indexToRemove)
- }
- }
- return liveSearchHistory.freeze()
- }
- }
-
- // MARK: - Message
-
- private func getUniqueUids(folder: Folder, remoteUids: [String]) -> [String] {
- let localUids = Set(folder.threads.map { Constants.shortUid(from: $0.uid) })
- let remoteUidsSet = Set(remoteUids)
- var uniqueUids: Set = Set()
- if localUids.isEmpty {
- uniqueUids = remoteUidsSet
- } else {
- uniqueUids = remoteUidsSet.subtracting(localUids)
- }
- return uniqueUids.toArray()
- }
-
- public func messages(folder: Folder) async throws {
- guard !Task.isCancelled else { return }
-
- let realm = getRealm()
- let freshFolder = folder.fresh(using: realm)
-
- let previousCursor = freshFolder?.cursor
- var messagesUids: MessagesUids
-
- if previousCursor == nil {
- let messageUidsResult = try await apiFetcher.messagesUids(
- mailboxUuid: mailbox.uuid,
- folderId: folder.id
- )
- messagesUids = MessagesUids(
- addedShortUids: messageUidsResult.messageShortUids,
- cursor: messageUidsResult.cursor
- )
- } else {
- let messageDeltaResult = try await apiFetcher.messagesDelta(
- mailboxUUid: mailbox.uuid,
- folderId: folder.id,
- signature: previousCursor!
- )
- messagesUids = MessagesUids(
- addedShortUids: [],
- deletedUids: messageDeltaResult.deletedShortUids
- .map { Constants.longUid(from: $0, folderId: folder.id) },
- updated: messageDeltaResult.updated,
- cursor: messageDeltaResult.cursor,
- folderUnreadCount: messageDeltaResult.unreadCount
- )
- }
-
- try await handleMessagesUids(messageUids: messagesUids, folder: folder)
-
- guard !Task.isCancelled else { return }
-
- await backgroundRealm.execute { realm in
- guard let folder = folder.fresh(using: realm) else { return }
- try? realm.safeWrite {
- if previousCursor == nil && messagesUids.addedShortUids.count < Constants.pageSize {
- folder.completeHistoryInfo()
- }
- if let newUnreadCount = messagesUids.folderUnreadCount {
- folder.remoteUnreadCount = newUnreadCount
- }
- folder.computeUnreadCount()
- folder.cursor = messagesUids.cursor
- folder.lastUpdate = Date()
- }
-
- SentryDebug.searchForOrphanMessages(
- folderId: folder.id,
- using: realm,
- previousCursor: previousCursor,
- newCursor: messagesUids.cursor
- )
- SentryDebug.searchForOrphanThreads(
- using: realm,
- previousCursor: previousCursor,
- newCursor: messagesUids.cursor
- )
- }
-
- if previousCursor != nil {
- while try await fetchOnePage(folder: folder, direction: .following) {
- guard !Task.isCancelled else { return }
- }
- }
-
- if folder.role == .inbox,
- let freshFolder = folder.fresh(using: getRealm()) {
- MailboxInfosManager.instance.updateUnseen(unseenMessages: freshFolder.unreadCount, for: mailbox)
- }
-
- let realmPrevious = getRealm()
- if let folderPrevious = folder.fresh(using: realmPrevious) {
- var remainingOldMessagesToFetch = folderPrevious.remainingOldMessagesToFetch
- while remainingOldMessagesToFetch > 0 {
- guard !Task.isCancelled else { return }
-
- if await try !fetchOnePage(folder: folder, direction: .previous) {
- break
- }
-
- remainingOldMessagesToFetch -= Constants.pageSize
- }
- }
- }
-
- public func fetchOnePage(folder: Folder, direction: NewMessagesDirection? = nil) async throws -> Bool {
- let realm = getRealm()
- var paginationInfo: PaginationInfo?
-
- if let offset = realm.objects(Message.self).where({ $0.folderId == folder.id })
- .sorted(by: {
- if direction == .following {
- return $0.shortUid! > $1.shortUid!
- }
- return $0.shortUid! < $1.shortUid!
- }).first?.shortUid?.toString(),
- let direction {
- paginationInfo = PaginationInfo(offsetUid: offset, direction: direction)
- }
-
- let messageUidsResult = try await apiFetcher.messagesUids(
- mailboxUuid: mailbox.uuid,
- folderId: folder.id,
- paginationInfo: paginationInfo
- )
- let messagesUids = MessagesUids(
- addedShortUids: messageUidsResult.messageShortUids,
- cursor: messageUidsResult.cursor
- )
-
- try await handleMessagesUids(messageUids: messagesUids, folder: folder)
-
- switch paginationInfo?.direction {
- case .previous:
- return await backgroundRealm.execute { realm in
- let freshFolder = folder.fresh(using: realm)
- if messagesUids.addedShortUids.count < Constants.pageSize || messagesUids.addedShortUids.contains("1") {
- try? realm.safeWrite {
- freshFolder?.completeHistoryInfo()
- }
- return false
- } else {
- try? realm.safeWrite {
- freshFolder?.remainingOldMessagesToFetch -= Constants.pageSize
- }
- return true
- }
- }
- case .following:
- break
- case .none:
- await backgroundRealm.execute { realm in
- let freshFolder = folder.fresh(using: realm)
- try? realm.safeWrite {
- freshFolder?.resetHistoryInfo()
-
- if messagesUids.addedShortUids.count < Constants.pageSize {
- freshFolder?.completeHistoryInfo()
- }
- }
- }
- }
- return messagesUids.addedShortUids.count == Constants.pageSize
- }
-
- private func handleMessagesUids(messageUids: MessagesUids, folder: Folder) async throws {
- let startDate = Date(timeIntervalSinceNow: -5 * 60)
- let ignoredIds = folder.fresh(using: getRealm())?.threads
- .where { $0.date > startDate }
- .map(\.uid) ?? []
- await deleteMessages(uids: messageUids.deletedUids)
- var shouldIgnoreNextEvents = SentryDebug.captureWrongDate(
- step: "After delete",
- startDate: startDate,
- folder: folder,
- alreadyWrongIds: ignoredIds,
- realm: getRealm()
- )
- await updateMessages(updates: messageUids.updated, folder: folder)
- if !shouldIgnoreNextEvents {
- shouldIgnoreNextEvents = SentryDebug.captureWrongDate(
- step: "After updateMessages",
- startDate: startDate,
- folder: folder,
- alreadyWrongIds: ignoredIds,
- realm: getRealm()
- )
- }
- try await addMessages(shortUids: messageUids.addedShortUids, folder: folder, newCursor: messageUids.cursor)
- if !shouldIgnoreNextEvents {
- _ = SentryDebug.captureWrongDate(
- step: "After addMessages",
- startDate: startDate,
- folder: folder,
- alreadyWrongIds: ignoredIds,
- realm: getRealm()
- )
- }
- }
-
- private func addMessages(shortUids: [String], folder: Folder, newCursor: String?) async throws {
- guard !shortUids.isEmpty && !Task.isCancelled else { return }
-
- let uniqueUids: [String] = getUniqueUids(folder: folder, remoteUids: shortUids)
- let messageByUidsResult = try await apiFetcher.messagesByUids(
- mailboxUuid: mailbox.uuid,
- folderId: folder.id,
- messageUids: uniqueUids
- )
-
- await backgroundRealm.execute { [self] realm in
- if let folder = folder.fresh(using: realm) {
- createMultiMessagesThreads(messageByUids: messageByUidsResult, folder: folder, using: realm)
- }
- SentryDebug.sendMissingMessagesSentry(
- sentUids: uniqueUids,
- receivedMessages: messageByUidsResult.messages,
- folder: folder,
- newCursor: newCursor
- )
- }
- }
-
- private func createMultiMessagesThreads(messageByUids: MessageByUidsResult, folder: Folder, using realm: Realm) {
- var threadsToUpdate = Set()
- try? realm.safeWrite {
- for message in messageByUids.messages {
- guard realm.object(ofType: Message.self, forPrimaryKey: message.uid) == nil else {
- SentrySDK.capture(message: "Found already existing message") { scope in
- scope.setContext(value: ["Message": ["uid": message.uid,
- "messageId": message.messageId],
- "Folder": ["id": message.folder?._id,
- "name": message.folder?.name,
- "cursor": message.folder?.cursor]],
- key: "Message context")
- }
- continue
- }
- message.inTrash = folder.role == .trash
- message.computeReference()
- let existingThreads = Array(realm.objects(Thread.self)
- .where { $0.messageIds.containsAny(in: message.linkedUids) })
-
- if let newThread = createNewThreadIfRequired(
- for: message,
- folder: folder,
- existingThreads: existingThreads
- ) {
- threadsToUpdate.insert(newThread)
- }
-
- var allExistingMessages = Set(existingThreads.flatMap(\.messages))
- allExistingMessages.insert(message)
-
- for thread in existingThreads {
- for existingMessage in allExistingMessages {
- if !thread.messages.map(\.uid).contains(existingMessage.uid) {
- thread.addMessageIfNeeded(newMessage: message.fresh(using: realm) ?? message)
- }
- }
-
- threadsToUpdate.insert(thread)
- }
-
- if let message = realm.objects(Message.self).first(where: { $0.uid == message.uid }) {
- folder.messages.insert(message)
- }
- }
- self.updateThreads(threads: threadsToUpdate, realm: realm)
- }
- }
-
- private func createNewThreadIfRequired(for message: Message, folder: Folder, existingThreads: [Thread]) -> Thread? {
- guard !existingThreads.contains(where: { $0.folder == folder }) else { return nil }
-
- let thread = message.toThread().detached()
- folder.threads.insert(thread)
-
- if let refThread = existingThreads.first(where: { $0.folder?.role != .draft && $0.folder?.role != .trash }) {
- addPreviousMessagesTo(newThread: thread, from: refThread)
- } else {
- for existingThread in existingThreads {
- addPreviousMessagesTo(newThread: thread, from: existingThread)
- }
- }
- return thread
- }
-
- private func addPreviousMessagesTo(newThread: Thread, from existingThread: Thread) {
- newThread.messageIds.insert(objectsIn: existingThread.messageIds)
- for message in existingThread.messages {
- newThread.addMessageIfNeeded(newMessage: message)
- }
- }
-
- private func updateMessages(updates: [MessageFlags], folder: Folder) async {
- guard !Task.isCancelled else { return }
-
- await backgroundRealm.execute { realm in
- var threadsToUpdate = Set()
- try? realm.safeWrite {
- for update in updates {
- let uid = Constants.longUid(from: String(update.shortUid), folderId: folder.id)
- if let message = realm.object(ofType: Message.self, forPrimaryKey: uid) {
- message.answered = update.answered
- message.flagged = update.isFavorite
- message.forwarded = update.forwarded
- message.scheduled = update.scheduled
- message.seen = update.seen
-
- for parent in message.threads {
- threadsToUpdate.insert(parent)
- }
- }
- }
- self.updateThreads(threads: threadsToUpdate, realm: realm)
- }
- }
- }
-
- private func updateThreads(threads: Set, realm: Realm) {
- let folders = Set(threads.compactMap(\.folder))
- for thread in threads {
- do {
- try thread.recomputeOrFail()
- } catch {
- SentryDebug.threadHasNilLastMessageFromFolderDate(thread: thread)
- realm.delete(thread)
- }
- }
- for folder in folders {
- folder.computeUnreadCount()
- }
- }
-
- public func message(message: Message) async throws {
- // Get from API
- let completedMessage = try await apiFetcher.message(message: message)
- completedMessage.fullyDownloaded = true
-
- await backgroundRealm.execute { realm in
- // Update message in Realm
- try? realm.safeWrite {
- realm.add(completedMessage, update: .modified)
- }
- }
- }
-
- public func attachmentData(attachment: Attachment) async throws -> Data {
- let data = try await apiFetcher.attachment(attachment: attachment)
-
- let safeAttachment = ThreadSafeReference(to: attachment)
- await backgroundRealm.execute { realm in
- if let liveAttachment = realm.resolve(safeAttachment) {
- try? realm.safeWrite {
- liveAttachment.saved = true
- }
- }
- }
-
- return data
- }
-
- public func saveAttachmentLocally(attachment: Attachment) async {
- do {
- let data = try await attachmentData(attachment: attachment)
- if let url = attachment.localUrl {
- let parentFolder = url.deletingLastPathComponent()
- if !FileManager.default.fileExists(atPath: parentFolder.path) {
- try FileManager.default.createDirectory(at: parentFolder, withIntermediateDirectories: true)
- }
- try data.write(to: url)
- }
- } catch {
- // Handle error
- print("Failed to save attachment: \(error)")
- }
- }
-
- private func moveOrDeleteMessagesInSameFolder(messages: [Message]) async throws {
- let messagesToMoveOrDelete = messages + messages.flatMap(\.duplicates)
-
- let firstMessageFolderRole = messages.first?.folder?.role
- if firstMessageFolderRole == .trash
- || firstMessageFolderRole == .spam
- || firstMessageFolderRole == .draft {
- try await delete(messages: messagesToMoveOrDelete)
- async let _ = snackbarPresenter.show(message: deletionSnackbarMessage(for: messages, permanentlyDelete: true))
- } else {
- let undoRedoAction = try await move(messages: messagesToMoveOrDelete, to: .trash)
- async let _ = IKSnackBar.showCancelableSnackBar(
- message: deletionSnackbarMessage(for: messages, permanentlyDelete: false),
- cancelSuccessMessage: MailResourcesStrings.Localizable.snackbarMoveCancelled,
- undoRedoAction: undoRedoAction,
- mailboxManager: self
- )
- }
- }
-
- private func deletionSnackbarMessage(for messages: [Message], permanentlyDelete: Bool) -> String {
- if let firstMessageThreadMessagesCount = messages.first?.originalThread?.messages.count,
- messages.count == 1 && firstMessageThreadMessagesCount > 1 {
- return permanentlyDelete ?
- MailResourcesStrings.Localizable.snackbarMessageDeletedPermanently :
- MailResourcesStrings.Localizable.snackbarMessageMoved(FolderRole.trash.localizedName)
- } else {
- let uniqueThreadCount = Set(messages.compactMap(\.originalThread?.uid)).count
- if permanentlyDelete {
- return MailResourcesStrings.Localizable.snackbarThreadDeletedPermanently(uniqueThreadCount)
- } else if uniqueThreadCount == 1 {
- return MailResourcesStrings.Localizable.snackbarThreadMoved(FolderRole.trash.localizedName)
- } else {
- return MailResourcesStrings.Localizable.snackbarThreadsMoved(FolderRole.trash.localizedName)
- }
- }
- }
-
- public func moveOrDelete(messages: [Message]) async throws {
- let messagesGroupedByFolderId = Dictionary(grouping: messages, by: \.folderId)
-
- await withThrowingTaskGroup(of: Void.self) { group in
- for messagesInSameFolder in messagesGroupedByFolderId.values {
- group.addTask {
- try await self.moveOrDeleteMessagesInSameFolder(messages: messagesInSameFolder)
- }
- }
- }
- }
-
- public func markAsSeen(message: Message, seen: Bool = true) async throws {
- if seen {
- var messages = [message]
- messages.append(contentsOf: message.duplicates)
- try await markAsSeen(messages: messages, seen: seen)
- } else {
- try await markAsSeen(messages: [message], seen: seen)
- }
- }
-
- private func markAsSeen(messages: [Message], seen: Bool) async throws {
- if seen {
- _ = try await apiFetcher.markAsSeen(mailbox: mailbox, messages: messages)
- } else {
- _ = try await apiFetcher.markAsUnseen(mailbox: mailbox, messages: messages)
- }
- try await refreshFolder(from: messages)
-
- // TODO: Remove after fix
- Task {
- for message in messages {
- if let liveMessage = message.thaw(),
- liveMessage.seen != seen {
- SentrySDK.capture(message: "Found incoherent message update") { scope in
- scope.setContext(value: ["Message": ["uid": message.uid,
- "messageId": message.messageId,
- "date": message.date,
- "seen": message.seen,
- "duplicates": message.duplicates.compactMap(\.messageId),
- "references": message.references],
- "Seen": ["Expected": seen, "Actual": liveMessage.seen],
- "Folder": ["id": message.folder?._id,
- "name": message.folder?.name,
- "last update": message.folder?.lastUpdate,
- "cursor": message.folder?.cursor]],
- key: "Message context")
- }
- }
- }
- }
- }
-
- public func move(messages: [Message], to folderRole: FolderRole) async throws -> UndoRedoAction {
- guard let folder = getFolder(with: folderRole)?.freeze() else { throw MailError.folderNotFound }
- return try await move(messages: messages, to: folder)
- }
-
- public func move(messages: [Message], to folder: Folder) async throws -> UndoRedoAction {
- let response = try await apiFetcher.move(mailbox: mailbox, messages: messages, destinationId: folder._id)
- try await refreshFolder(from: messages, additionalFolder: folder)
- return undoRedoAction(for: response, and: messages)
- }
-
- public func delete(messages: [Message]) async throws {
- _ = try await apiFetcher.delete(mailbox: mailbox, messages: messages)
- try await refreshFolder(from: messages)
- }
-
- public func toggleStar(messages: [Message]) async throws {
- if messages.contains(where: { !$0.flagged }) {
- let messagesToStar = messages + messages.flatMap(\.duplicates)
- _ = try await star(messages: messagesToStar)
- } else {
- let messagesToUnstar = messages
- .compactMap { $0.originalThread?.messages.where { $0.isDraft == false } }
- .flatMap { $0 + $0.flatMap(\.duplicates) }
- _ = try await unstar(messages: messagesToUnstar)
- }
- }
-
- private func star(messages: [Message]) async throws -> MessageActionResult {
- let response = try await apiFetcher.star(mailbox: mailbox, messages: messages)
- try await refreshFolder(from: messages)
- return response
- }
-
- private func unstar(messages: [Message]) async throws -> MessageActionResult {
- let response = try await apiFetcher.unstar(mailbox: mailbox, messages: messages)
- try await refreshFolder(from: messages)
- return response
- }
-
- private func undoRedoAction(for cancellableResponse: UndoResponse, and messages: [Message]) -> UndoRedoAction {
- let redoAction = {
- try await self.refreshFolder(from: messages)
- }
- return UndoRedoAction(undo: cancellableResponse, redo: redoAction)
- }
-
- // MARK: - Draft
-
- public func draftWithPendingAction() -> Results {
- let realm = getRealm()
- return realm.objects(Draft.self).where { $0.action != nil }
- }
-
- public func draft(messageUid: String, using realm: Realm? = nil) -> Draft? {
- let realm = realm ?? getRealm()
- return realm.objects(Draft.self).where { $0.messageUid == messageUid }.first
- }
-
- public func draft(localUuid: String, using realm: Realm? = nil) -> Draft? {
- let realm = realm ?? getRealm()
- return realm.objects(Draft.self).where { $0.localUUID == localUuid }.first
- }
-
- public func draft(remoteUuid: String, using realm: Realm? = nil) -> Draft? {
- let realm = realm ?? getRealm()
- return realm.objects(Draft.self).where { $0.remoteUUID == remoteUuid }.first
- }
-
- public func send(draft: Draft) async throws -> SendResponse {
- do {
- let cancelableResponse = try await apiFetcher.send(mailbox: mailbox, draft: draft)
- // Once the draft has been sent, we can delete it from Realm
- try await deleteLocally(draft: draft)
- return cancelableResponse
- } catch let error as AFErrorWithContext where (200 ... 299).contains(error.request.response?.statusCode ?? 0) {
- // Status code is valid but something went wrong eg. we couldn't parse the response
- try await deleteLocally(draft: draft)
- throw error
- } catch let error as MailApiError {
- // The api returned an error
- try await deleteLocally(draft: draft)
- throw error
- }
- }
-
- public func save(draft: Draft) async throws {
- do {
- let saveResponse = try await apiFetcher.save(mailbox: mailbox, draft: draft)
- await backgroundRealm.execute { realm in
- // Update draft in Realm
- guard let liveDraft = realm.object(ofType: Draft.self, forPrimaryKey: draft.localUUID) else { return }
- try? realm.safeWrite {
- liveDraft.remoteUUID = saveResponse.uuid
- liveDraft.messageUid = saveResponse.uid
- liveDraft.action = nil
- }
- }
- } catch let error as MailApiError {
- // The api returned an error for now we can do nothing about it so we delete the draft
- try await deleteLocally(draft: draft)
- throw error
- }
- }
-
- public func delete(draft: Draft) async throws {
- try await deleteLocally(draft: draft)
- try await apiFetcher.deleteDraft(mailbox: mailbox, draftId: draft.remoteUUID)
- }
-
- public func delete(draftMessage: Message) async throws {
- guard let draftResource = draftMessage.draftResource else {
- throw MailError.resourceError
- }
-
- if let draft = getRealm().objects(Draft.self).where({ $0.remoteUUID == draftResource }).first?.freeze() {
- try await deleteLocally(draft: draft)
- }
-
- try await apiFetcher.deleteDraft(draftResource: draftResource)
- try await refreshFolder(from: [draftMessage])
- }
-
- public func deleteLocally(draft: Draft) async throws {
- await backgroundRealm.execute { realm in
- guard let liveDraft = realm.object(ofType: Draft.self, forPrimaryKey: draft.localUUID) else { return }
- try? realm.safeWrite {
- realm.delete(liveDraft)
- }
- }
- }
-
- public func deleteOrphanDrafts() async {
- guard let draftFolder = getFolder(with: .draft) else { return }
-
- let existingMessageUids = Set(draftFolder.threads.flatMap(\.messages).map(\.uid))
-
- await backgroundRealm.execute { realm in
- try? realm.safeWrite {
- let noActionDrafts = realm.objects(Draft.self).where { $0.action == nil }
- for draft in noActionDrafts {
- if let messageUid = draft.messageUid,
- !existingMessageUids.contains(messageUid) {
- realm.delete(draft)
- }
- }
- }
- }
- }
-
- // MARK: - Utilities
-
- struct MessagePropertiesOptions: OptionSet {
- let rawValue: Int
-
- static let fullyDownloaded = MessagePropertiesOptions(rawValue: 1 << 0)
- static let body = MessagePropertiesOptions(rawValue: 1 << 1)
- static let attachments = MessagePropertiesOptions(rawValue: 1 << 2)
- static let localSafeDisplay = MessagePropertiesOptions(rawValue: 1 << 3)
-
- static let standard: MessagePropertiesOptions = [.fullyDownloaded, .body, .attachments, .localSafeDisplay]
- }
-
- private func keepCacheAttributes(
- for message: Message,
- keepProperties: MessagePropertiesOptions,
- using realm: Realm? = nil
- ) {
- let realm = realm ?? getRealm()
- guard let savedMessage = realm.object(ofType: Message.self, forPrimaryKey: message.uid) else { return }
- message.inTrash = savedMessage.inTrash
- if keepProperties.contains(.fullyDownloaded) {
- message.fullyDownloaded = savedMessage.fullyDownloaded
- }
- if keepProperties.contains(.body), let body = savedMessage.body {
- message.body = Body(value: body)
- }
- if keepProperties.contains(.localSafeDisplay) {
- message.localSafeDisplay = savedMessage.localSafeDisplay
- }
- if keepProperties.contains(.attachments) {
- for attachment in savedMessage.attachments {
- message.attachments.append(Attachment(value: attachment.freeze()))
- }
- }
- }
-
- private func keepCacheAttributes(
- for folder: Folder,
- using realm: Realm
- ) {
- guard let savedFolder = realm.object(ofType: Folder.self, forPrimaryKey: folder._id) else { return }
- folder.unreadCount = savedFolder.unreadCount
- folder.lastUpdate = savedFolder.lastUpdate
- folder.cursor = savedFolder.cursor
- folder.remainingOldMessagesToFetch = savedFolder.remainingOldMessagesToFetch
- folder.isHistoryComplete = savedFolder.isHistoryComplete
- folder.isExpanded = savedFolder.isExpanded
- }
-
- func getSubFolders(from folders: [Folder], oldResult: [Folder] = []) -> [Folder] {
- var result = oldResult
- for folder in folders {
- result.append(folder)
- if !folder.children.isEmpty {
- result.append(contentsOf: getSubFolders(from: Array(folder.children)))
- }
- }
- return result
- }
-
- public func hasUnreadMessages() -> Bool {
- let realm = getRealm()
- return realm.objects(Folder.self).contains { $0.unreadCount > 0 }
- }
-}
-
-// MARK: - Equatable conformance
-
-extension MailboxManager: Equatable {
- public static func == (lhs: MailboxManager, rhs: MailboxManager) -> Bool {
- return lhs.mailbox.id == rhs.mailbox.id
- }
-}
-
-public extension Realm {
- func uncheckedSafeWrite(_ block: () throws -> Void) throws {
- if isInWriteTransaction {
- try block()
- } else {
- try write(block)
- }
- }
-
- func safeWrite(_ block: () throws -> Void) throws {
- #if DEBUG
- dispatchPrecondition(condition: .notOnQueue(.main))
- #endif
-
- if isInWriteTransaction {
- try block()
- } else {
- try write(block)
- }
- }
-}
diff --git a/MailCore/Cache/MailboxManager/MailboxManageable.swift b/MailCore/Cache/MailboxManager/MailboxManageable.swift
new file mode 100644
index 000000000..7a8b268d5
--- /dev/null
+++ b/MailCore/Cache/MailboxManager/MailboxManageable.swift
@@ -0,0 +1,54 @@
+/*
+ Infomaniak Mail - iOS App
+ Copyright (C) 2022 Infomaniak Network SA
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ 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, see .
+ */
+
+import Foundation
+import RealmSwift
+
+/// An abstract interface on the `MailboxManager`
+public typealias MailboxManageable = MailBoxManagerMessageable & MailBoxManagerDraftable
+
+/// An abstract interface on the `MailboxManager` related to messages
+public protocol MailBoxManagerMessageable {
+ func messages(folder: Folder) async throws
+ func fetchOnePage(folder: Folder, direction: NewMessagesDirection?) async throws -> Bool
+ func message(message: Message) async throws
+ func attachmentData(attachment: Attachment) async throws -> Data
+ func saveAttachmentLocally(attachment: Attachment) async
+ func moveOrDelete(messages: [Message]) async throws
+ func markAsSeen(message: Message, seen: Bool) async throws
+ func move(messages: [Message], to folderRole: FolderRole) async throws -> UndoRedoAction
+ func move(messages: [Message], to folder: Folder) async throws -> UndoRedoAction
+ func delete(messages: [Message]) async throws
+ func toggleStar(messages: [Message]) async throws
+}
+
+/// An abstract interface on the `MailboxManager` related to drafts
+public protocol MailBoxManagerDraftable {
+ func draftWithPendingAction() -> Results
+ func draft(messageUid: String, using realm: Realm?) -> Draft?
+ func draft(localUuid: String, using realm: Realm?) -> Draft?
+ func draft(remoteUuid: String, using realm: Realm?) -> Draft?
+ func send(draft: Draft) async throws -> SendResponse
+ func save(draft: Draft) async throws
+ func delete(draft: Draft) async throws
+ func delete(draftMessage: Message) async throws
+ func deleteLocally(draft: Draft) async throws
+ func deleteOrphanDrafts() async
+}
+
+// TODO write a dedicated protocol for each MailboxManager+<>
diff --git a/MailCore/Cache/MailboxManager/MailboxManager+Draft.swift b/MailCore/Cache/MailboxManager/MailboxManager+Draft.swift
new file mode 100644
index 000000000..11c15393e
--- /dev/null
+++ b/MailCore/Cache/MailboxManager/MailboxManager+Draft.swift
@@ -0,0 +1,125 @@
+/*
+ Infomaniak Mail - iOS App
+ Copyright (C) 2022 Infomaniak Network SA
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ 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, see .
+ */
+
+import Foundation
+import RealmSwift
+
+// MARK: - Draft
+
+public extension MailboxManager {
+ func draftWithPendingAction() -> Results {
+ let realm = getRealm()
+ return realm.objects(Draft.self).where { $0.action != nil }
+ }
+
+ func draft(messageUid: String, using realm: Realm? = nil) -> Draft? {
+ let realm = realm ?? getRealm()
+ return realm.objects(Draft.self).where { $0.messageUid == messageUid }.first
+ }
+
+ func draft(localUuid: String, using realm: Realm? = nil) -> Draft? {
+ let realm = realm ?? getRealm()
+ return realm.objects(Draft.self).where { $0.localUUID == localUuid }.first
+ }
+
+ func draft(remoteUuid: String, using realm: Realm? = nil) -> Draft? {
+ let realm = realm ?? getRealm()
+ return realm.objects(Draft.self).where { $0.remoteUUID == remoteUuid }.first
+ }
+
+ func send(draft: Draft) async throws -> SendResponse {
+ do {
+ let cancelableResponse = try await apiFetcher.send(mailbox: mailbox, draft: draft)
+ // Once the draft has been sent, we can delete it from Realm
+ try await deleteLocally(draft: draft)
+ return cancelableResponse
+ } catch let error as AFErrorWithContext where (200 ... 299).contains(error.request.response?.statusCode ?? 0) {
+ // Status code is valid but something went wrong eg. we couldn't parse the response
+ try await deleteLocally(draft: draft)
+ throw error
+ } catch let error as MailApiError {
+ // The api returned an error
+ try await deleteLocally(draft: draft)
+ throw error
+ }
+ }
+
+ func save(draft: Draft) async throws {
+ do {
+ let saveResponse = try await apiFetcher.save(mailbox: mailbox, draft: draft)
+ await backgroundRealm.execute { realm in
+ // Update draft in Realm
+ guard let liveDraft = realm.object(ofType: Draft.self, forPrimaryKey: draft.localUUID) else { return }
+ try? realm.safeWrite {
+ liveDraft.remoteUUID = saveResponse.uuid
+ liveDraft.messageUid = saveResponse.uid
+ liveDraft.action = nil
+ }
+ }
+ } catch let error as MailApiError {
+ // The api returned an error for now we can do nothing about it so we delete the draft
+ try await deleteLocally(draft: draft)
+ throw error
+ }
+ }
+
+ func delete(draft: Draft) async throws {
+ try await deleteLocally(draft: draft)
+ try await apiFetcher.deleteDraft(mailbox: mailbox, draftId: draft.remoteUUID)
+ }
+
+ func delete(draftMessage: Message) async throws {
+ guard let draftResource = draftMessage.draftResource else {
+ throw MailError.resourceError
+ }
+
+ if let draft = getRealm().objects(Draft.self).where({ $0.remoteUUID == draftResource }).first?.freeze() {
+ try await deleteLocally(draft: draft)
+ }
+
+ try await apiFetcher.deleteDraft(draftResource: draftResource)
+ try await refreshFolder(from: [draftMessage])
+ }
+
+ func deleteLocally(draft: Draft) async throws {
+ await backgroundRealm.execute { realm in
+ guard let liveDraft = realm.object(ofType: Draft.self, forPrimaryKey: draft.localUUID) else { return }
+ try? realm.safeWrite {
+ realm.delete(liveDraft)
+ }
+ }
+ }
+
+ func deleteOrphanDrafts() async {
+ guard let draftFolder = getFolder(with: .draft) else { return }
+
+ let existingMessageUids = Set(draftFolder.threads.flatMap(\.messages).map(\.uid))
+
+ await backgroundRealm.execute { realm in
+ try? realm.safeWrite {
+ let noActionDrafts = realm.objects(Draft.self).where { $0.action == nil }
+ for draft in noActionDrafts {
+ if let messageUid = draft.messageUid,
+ !existingMessageUids.contains(messageUid) {
+ realm.delete(draft)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/MailCore/Cache/MailboxManager/MailboxManager+Folders.swift b/MailCore/Cache/MailboxManager/MailboxManager+Folders.swift
new file mode 100644
index 000000000..915711c35
--- /dev/null
+++ b/MailCore/Cache/MailboxManager/MailboxManager+Folders.swift
@@ -0,0 +1,116 @@
+/*
+ Infomaniak Mail - iOS App
+ Copyright (C) 2022 Infomaniak Network SA
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ 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, see .
+ */
+
+import Foundation
+import InfomaniakCore
+import RealmSwift
+
+// MARK: - Folders
+
+public extension MailboxManager {
+ func folders() async throws {
+ // Get from Realm
+ guard ReachabilityListener.instance.currentStatus != .offline else {
+ return
+ }
+ // Get from API
+ let folderResult = try await apiFetcher.folders(mailbox: mailbox)
+ let newFolders = getSubFolders(from: folderResult)
+
+ await backgroundRealm.execute { realm in
+ for folder in newFolders {
+ self.keepCacheAttributes(for: folder, using: realm)
+ }
+
+ let cachedFolders = realm.objects(Folder.self)
+
+ // Update folders in Realm
+ try? realm.safeWrite {
+ // Remove old folders
+ realm.add(folderResult, update: .modified)
+ let toDeleteFolders = Set(cachedFolders).subtracting(Set(newFolders)).filter { $0.id != Constants.searchFolderId }
+ var toDeleteThreads = [Thread]()
+
+ // Threads contains in folders to delete
+ let mayBeDeletedThreads = Set(toDeleteFolders.flatMap(\.threads))
+ // Messages contains in folders to delete
+ let toDeleteMessages = Set(toDeleteFolders.flatMap(\.messages))
+
+ // Delete thread if all his messages are deleted
+ for thread in mayBeDeletedThreads where Set(thread.messages).isSubset(of: toDeleteMessages) {
+ toDeleteThreads.append(thread)
+ }
+
+ realm.delete(toDeleteMessages)
+ realm.delete(toDeleteThreads)
+ realm.delete(toDeleteFolders)
+ }
+ }
+ }
+
+ /// Get the folder with the corresponding role in Realm.
+ /// - Parameters:
+ /// - role: Role of the folder.
+ /// - realm: The Realm instance to use. If this parameter is `nil`, a new one will be created.
+ /// - Returns: The folder with the corresponding role, or `nil` if no such folder has been found.
+ func getFolder(with role: FolderRole, using realm: Realm? = nil) -> Folder? {
+ let realm = realm ?? getRealm()
+ return realm.objects(Folder.self).where { $0.role == role }.first
+ }
+
+ /// Get all the real folders in Realm
+ /// - Parameters:
+ /// - realm: The Realm instance to use. If this parameter is `nil`, a new one will be created.
+ /// - Returns: The list of real folders.
+ func getFolders(using realm: Realm? = nil) -> [Folder] {
+ let realm = realm ?? getRealm()
+ return Array(realm.objects(Folder.self).where { $0.toolType == nil })
+ }
+
+ func createFolder(name: String, parent: Folder? = nil) async throws -> Folder {
+ var folder = try await apiFetcher.create(mailbox: mailbox, folder: NewFolder(name: name, path: parent?.path))
+ await backgroundRealm.execute { realm in
+ try? realm.safeWrite {
+ realm.add(folder)
+ if let parent {
+ parent.fresh(using: realm)?.children.insert(folder)
+ }
+ }
+ folder = folder.freeze()
+ }
+ return folder
+ }
+
+ // MARK: RefreshActor
+
+ func flushFolder(folder: Folder) async throws -> Bool {
+ return try await refreshActor.flushFolder(folder: folder, mailbox: mailbox, apiFetcher: apiFetcher)
+ }
+
+ func refreshFolder(from messages: [Message], additionalFolder: Folder? = nil) async throws {
+ try await refreshActor.refreshFolder(from: messages, additionalFolder: additionalFolder)
+ }
+
+ func refresh(folder: Folder) async {
+ await refreshActor.refresh(folder: folder)
+ }
+
+ func cancelRefresh() async {
+ await refreshActor.cancelRefresh()
+ }
+}
diff --git a/MailCore/Cache/MailboxManager/MailboxManager+Message.swift b/MailCore/Cache/MailboxManager/MailboxManager+Message.swift
new file mode 100644
index 000000000..5d032c5e7
--- /dev/null
+++ b/MailCore/Cache/MailboxManager/MailboxManager+Message.swift
@@ -0,0 +1,546 @@
+/*
+ Infomaniak Mail - iOS App
+ Copyright (C) 2022 Infomaniak Network SA
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ 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, see .
+ */
+
+import Foundation
+import InfomaniakCoreUI
+import MailResources
+import RealmSwift
+import Sentry
+
+// MARK: - Message
+
+public extension MailboxManager {
+ func messages(folder: Folder) async throws {
+ guard !Task.isCancelled else { return }
+
+ let realm = getRealm()
+ let freshFolder = folder.fresh(using: realm)
+
+ let previousCursor = freshFolder?.cursor
+ var messagesUids: MessagesUids
+
+ if previousCursor == nil {
+ let messageUidsResult = try await apiFetcher.messagesUids(
+ mailboxUuid: mailbox.uuid,
+ folderId: folder.id,
+ paginationInfo: nil
+ )
+ messagesUids = MessagesUids(
+ addedShortUids: messageUidsResult.messageShortUids,
+ cursor: messageUidsResult.cursor
+ )
+ } else {
+ let messageDeltaResult = try await apiFetcher.messagesDelta(
+ mailboxUUid: mailbox.uuid,
+ folderId: folder.id,
+ signature: previousCursor!
+ )
+ messagesUids = MessagesUids(
+ addedShortUids: [],
+ deletedUids: messageDeltaResult.deletedShortUids
+ .map { Constants.longUid(from: $0, folderId: folder.id) },
+ updated: messageDeltaResult.updated,
+ cursor: messageDeltaResult.cursor,
+ folderUnreadCount: messageDeltaResult.unreadCount
+ )
+ }
+
+ try await handleMessagesUids(messageUids: messagesUids, folder: folder)
+
+ guard !Task.isCancelled else { return }
+
+ await backgroundRealm.execute { realm in
+ guard let folder = folder.fresh(using: realm) else { return }
+ try? realm.safeWrite {
+ if previousCursor == nil && messagesUids.addedShortUids.count < Constants.pageSize {
+ folder.completeHistoryInfo()
+ }
+ if let newUnreadCount = messagesUids.folderUnreadCount {
+ folder.remoteUnreadCount = newUnreadCount
+ }
+ folder.computeUnreadCount()
+ folder.cursor = messagesUids.cursor
+ folder.lastUpdate = Date()
+ }
+
+ SentryDebug.searchForOrphanMessages(
+ folderId: folder.id,
+ using: realm,
+ previousCursor: previousCursor,
+ newCursor: messagesUids.cursor
+ )
+ SentryDebug.searchForOrphanThreads(
+ using: realm,
+ previousCursor: previousCursor,
+ newCursor: messagesUids.cursor
+ )
+ }
+
+ if previousCursor != nil {
+ while try await fetchOnePage(folder: folder, direction: .following) {
+ guard !Task.isCancelled else { return }
+ }
+ }
+
+ if folder.role == .inbox,
+ let freshFolder = folder.fresh(using: getRealm()) {
+ MailboxInfosManager.instance.updateUnseen(unseenMessages: freshFolder.unreadCount, for: mailbox)
+ }
+
+ let realmPrevious = getRealm()
+ if let folderPrevious = folder.fresh(using: realmPrevious) {
+ var remainingOldMessagesToFetch = folderPrevious.remainingOldMessagesToFetch
+ while remainingOldMessagesToFetch > 0 {
+ guard !Task.isCancelled else { return }
+
+ if await try !fetchOnePage(folder: folder, direction: .previous) {
+ break
+ }
+
+ remainingOldMessagesToFetch -= Constants.pageSize
+ }
+ }
+ }
+
+ func fetchOnePage(folder: Folder, direction: NewMessagesDirection? = nil) async throws -> Bool {
+ let realm = getRealm()
+ var paginationInfo: PaginationInfo?
+
+ if let offset = realm.objects(Message.self).where({ $0.folderId == folder.id })
+ .sorted(by: {
+ if direction == .following {
+ return $0.shortUid! > $1.shortUid!
+ }
+ return $0.shortUid! < $1.shortUid!
+ }).first?.shortUid?.toString(),
+ let direction {
+ paginationInfo = PaginationInfo(offsetUid: offset, direction: direction)
+ }
+
+ let messageUidsResult = try await apiFetcher.messagesUids(
+ mailboxUuid: mailbox.uuid,
+ folderId: folder.id,
+ paginationInfo: paginationInfo
+ )
+ let messagesUids = MessagesUids(
+ addedShortUids: messageUidsResult.messageShortUids,
+ cursor: messageUidsResult.cursor
+ )
+
+ try await handleMessagesUids(messageUids: messagesUids, folder: folder)
+
+ switch paginationInfo?.direction {
+ case .previous:
+ return await backgroundRealm.execute { realm in
+ let freshFolder = folder.fresh(using: realm)
+ if messagesUids.addedShortUids.count < Constants.pageSize || messagesUids.addedShortUids.contains("1") {
+ try? realm.safeWrite {
+ freshFolder?.completeHistoryInfo()
+ }
+ return false
+ } else {
+ try? realm.safeWrite {
+ freshFolder?.remainingOldMessagesToFetch -= Constants.pageSize
+ }
+ return true
+ }
+ }
+ case .following:
+ break
+ case .none:
+ await backgroundRealm.execute { realm in
+ let freshFolder = folder.fresh(using: realm)
+ try? realm.safeWrite {
+ freshFolder?.resetHistoryInfo()
+
+ if messagesUids.addedShortUids.count < Constants.pageSize {
+ freshFolder?.completeHistoryInfo()
+ }
+ }
+ }
+ }
+ return messagesUids.addedShortUids.count == Constants.pageSize
+ }
+
+ func message(message: Message) async throws {
+ // Get from API
+ let completedMessage = try await apiFetcher.message(message: message)
+ completedMessage.fullyDownloaded = true
+
+ await backgroundRealm.execute { realm in
+ // Update message in Realm
+ try? realm.safeWrite {
+ realm.add(completedMessage, update: .modified)
+ }
+ }
+ }
+
+ func attachmentData(attachment: Attachment) async throws -> Data {
+ let data = try await apiFetcher.attachment(attachment: attachment)
+
+ let safeAttachment = ThreadSafeReference(to: attachment)
+ await backgroundRealm.execute { realm in
+ if let liveAttachment = realm.resolve(safeAttachment) {
+ try? realm.safeWrite {
+ liveAttachment.saved = true
+ }
+ }
+ }
+
+ return data
+ }
+
+ func saveAttachmentLocally(attachment: Attachment) async {
+ do {
+ let data = try await attachmentData(attachment: attachment)
+ if let url = attachment.localUrl {
+ let parentFolder = url.deletingLastPathComponent()
+ if !FileManager.default.fileExists(atPath: parentFolder.path) {
+ try FileManager.default.createDirectory(at: parentFolder, withIntermediateDirectories: true)
+ }
+ try data.write(to: url)
+ }
+ } catch {
+ // Handle error
+ print("Failed to save attachment: \(error)")
+ }
+ }
+
+ func moveOrDelete(messages: [Message]) async throws {
+ let messagesGroupedByFolderId = Dictionary(grouping: messages, by: \.folderId)
+
+ await withThrowingTaskGroup(of: Void.self) { group in
+ for messagesInSameFolder in messagesGroupedByFolderId.values {
+ group.addTask {
+ try await self.moveOrDeleteMessagesInSameFolder(messages: messagesInSameFolder)
+ }
+ }
+ }
+ }
+
+ func markAsSeen(message: Message, seen: Bool = true) async throws {
+ if seen {
+ var messages = [message]
+ messages.append(contentsOf: message.duplicates)
+ try await markAsSeen(messages: messages, seen: seen)
+ } else {
+ try await markAsSeen(messages: [message], seen: seen)
+ }
+ }
+
+ func move(messages: [Message], to folderRole: FolderRole) async throws -> UndoRedoAction {
+ guard let folder = getFolder(with: folderRole)?.freeze() else { throw MailError.folderNotFound }
+ return try await move(messages: messages, to: folder)
+ }
+
+ func move(messages: [Message], to folder: Folder) async throws -> UndoRedoAction {
+ let response = try await apiFetcher.move(mailbox: mailbox, messages: messages, destinationId: folder._id)
+ try await refreshFolder(from: messages, additionalFolder: folder)
+ return undoRedoAction(for: response, and: messages)
+ }
+
+ func delete(messages: [Message]) async throws {
+ _ = try await apiFetcher.delete(mailbox: mailbox, messages: messages)
+ try await refreshFolder(from: messages)
+ }
+
+ func toggleStar(messages: [Message]) async throws {
+ if messages.contains(where: { !$0.flagged }) {
+ let messagesToStar = messages + messages.flatMap(\.duplicates)
+ _ = try await star(messages: messagesToStar)
+ } else {
+ let messagesToUnstar = messages
+ .compactMap { $0.originalThread?.messages.where { $0.isDraft == false } }
+ .flatMap { $0 + $0.flatMap(\.duplicates) }
+ _ = try await unstar(messages: messagesToUnstar)
+ }
+ }
+
+ // MARK: Private
+
+ private func getUniqueUids(folder: Folder, remoteUids: [String]) -> [String] {
+ let localUids = Set(folder.threads.map { Constants.shortUid(from: $0.uid) })
+ let remoteUidsSet = Set(remoteUids)
+ var uniqueUids: Set = Set()
+ if localUids.isEmpty {
+ uniqueUids = remoteUidsSet
+ } else {
+ uniqueUids = remoteUidsSet.subtracting(localUids)
+ }
+ return uniqueUids.toArray()
+ }
+
+ private func handleMessagesUids(messageUids: MessagesUids, folder: Folder) async throws {
+ let startDate = Date(timeIntervalSinceNow: -5 * 60)
+ let ignoredIds = folder.fresh(using: getRealm())?.threads
+ .where { $0.date > startDate }
+ .map(\.uid) ?? []
+ await deleteMessages(uids: messageUids.deletedUids)
+ var shouldIgnoreNextEvents = SentryDebug.captureWrongDate(
+ step: "After delete",
+ startDate: startDate,
+ folder: folder,
+ alreadyWrongIds: ignoredIds,
+ realm: getRealm()
+ )
+ await updateMessages(updates: messageUids.updated, folder: folder)
+ if !shouldIgnoreNextEvents {
+ shouldIgnoreNextEvents = SentryDebug.captureWrongDate(
+ step: "After updateMessages",
+ startDate: startDate,
+ folder: folder,
+ alreadyWrongIds: ignoredIds,
+ realm: getRealm()
+ )
+ }
+ try await addMessages(shortUids: messageUids.addedShortUids, folder: folder, newCursor: messageUids.cursor)
+ if !shouldIgnoreNextEvents {
+ _ = SentryDebug.captureWrongDate(
+ step: "After addMessages",
+ startDate: startDate,
+ folder: folder,
+ alreadyWrongIds: ignoredIds,
+ realm: getRealm()
+ )
+ }
+ }
+
+ private func addMessages(shortUids: [String], folder: Folder, newCursor: String?) async throws {
+ guard !shortUids.isEmpty && !Task.isCancelled else { return }
+
+ let uniqueUids: [String] = getUniqueUids(folder: folder, remoteUids: shortUids)
+ let messageByUidsResult = try await apiFetcher.messagesByUids(
+ mailboxUuid: mailbox.uuid,
+ folderId: folder.id,
+ messageUids: uniqueUids
+ )
+
+ await backgroundRealm.execute { [self] realm in
+ if let folder = folder.fresh(using: realm) {
+ createMultiMessagesThreads(messageByUids: messageByUidsResult, folder: folder, using: realm)
+ }
+ SentryDebug.sendMissingMessagesSentry(
+ sentUids: uniqueUids,
+ receivedMessages: messageByUidsResult.messages,
+ folder: folder,
+ newCursor: newCursor
+ )
+ }
+ }
+
+ private func createMultiMessagesThreads(messageByUids: MessageByUidsResult, folder: Folder, using realm: Realm) {
+ var threadsToUpdate = Set()
+ try? realm.safeWrite {
+ for message in messageByUids.messages {
+ guard realm.object(ofType: Message.self, forPrimaryKey: message.uid) == nil else {
+ SentrySDK.capture(message: "Found already existing message") { scope in
+ scope.setContext(value: ["Message": ["uid": message.uid,
+ "messageId": message.messageId],
+ "Folder": ["id": message.folder?._id,
+ "name": message.folder?.name,
+ "cursor": message.folder?.cursor]],
+ key: "Message context")
+ }
+ continue
+ }
+ message.inTrash = folder.role == .trash
+ message.computeReference()
+ let existingThreads = Array(realm.objects(Thread.self)
+ .where { $0.messageIds.containsAny(in: message.linkedUids) })
+
+ if let newThread = createNewThreadIfRequired(
+ for: message,
+ folder: folder,
+ existingThreads: existingThreads
+ ) {
+ threadsToUpdate.insert(newThread)
+ }
+
+ var allExistingMessages = Set(existingThreads.flatMap(\.messages))
+ allExistingMessages.insert(message)
+
+ for thread in existingThreads {
+ for existingMessage in allExistingMessages {
+ if !thread.messages.map(\.uid).contains(existingMessage.uid) {
+ thread.addMessageIfNeeded(newMessage: message.fresh(using: realm) ?? message)
+ }
+ }
+
+ threadsToUpdate.insert(thread)
+ }
+
+ if let message = realm.objects(Message.self).first(where: { $0.uid == message.uid }) {
+ folder.messages.insert(message)
+ }
+ }
+ self.updateThreads(threads: threadsToUpdate, realm: realm)
+ }
+ }
+
+ private func createNewThreadIfRequired(for message: Message, folder: Folder, existingThreads: [Thread]) -> Thread? {
+ guard !existingThreads.contains(where: { $0.folder == folder }) else { return nil }
+
+ let thread = message.toThread().detached()
+ folder.threads.insert(thread)
+
+ if let refThread = existingThreads.first(where: { $0.folder?.role != .draft && $0.folder?.role != .trash }) {
+ addPreviousMessagesTo(newThread: thread, from: refThread)
+ } else {
+ for existingThread in existingThreads {
+ addPreviousMessagesTo(newThread: thread, from: existingThread)
+ }
+ }
+ return thread
+ }
+
+ private func addPreviousMessagesTo(newThread: Thread, from existingThread: Thread) {
+ newThread.messageIds.insert(objectsIn: existingThread.messageIds)
+ for message in existingThread.messages {
+ newThread.addMessageIfNeeded(newMessage: message)
+ }
+ }
+
+ private func updateMessages(updates: [MessageFlags], folder: Folder) async {
+ guard !Task.isCancelled else { return }
+
+ await backgroundRealm.execute { realm in
+ var threadsToUpdate = Set()
+ try? realm.safeWrite {
+ for update in updates {
+ let uid = Constants.longUid(from: String(update.shortUid), folderId: folder.id)
+ if let message = realm.object(ofType: Message.self, forPrimaryKey: uid) {
+ message.answered = update.answered
+ message.flagged = update.isFavorite
+ message.forwarded = update.forwarded
+ message.scheduled = update.scheduled
+ message.seen = update.seen
+
+ for parent in message.threads {
+ threadsToUpdate.insert(parent)
+ }
+ }
+ }
+ self.updateThreads(threads: threadsToUpdate, realm: realm)
+ }
+ }
+ }
+
+ private func updateThreads(threads: Set, realm: Realm) {
+ let folders = Set(threads.compactMap(\.folder))
+ for thread in threads {
+ do {
+ try thread.recomputeOrFail()
+ } catch {
+ SentryDebug.threadHasNilLastMessageFromFolderDate(thread: thread)
+ realm.delete(thread)
+ }
+ }
+ for folder in folders {
+ folder.computeUnreadCount()
+ }
+ }
+
+ private func moveOrDeleteMessagesInSameFolder(messages: [Message]) async throws {
+ let messagesToMoveOrDelete = messages + messages.flatMap(\.duplicates)
+
+ let firstMessageFolderRole = messages.first?.folder?.role
+ if firstMessageFolderRole == .trash
+ || firstMessageFolderRole == .spam
+ || firstMessageFolderRole == .draft {
+ try await delete(messages: messagesToMoveOrDelete)
+ async let _ = snackbarPresenter.show(message: deletionSnackbarMessage(for: messages, permanentlyDelete: true))
+ } else {
+ let undoRedoAction = try await move(messages: messagesToMoveOrDelete, to: .trash)
+ async let _ = IKSnackBar.showCancelableSnackBar(
+ message: deletionSnackbarMessage(for: messages, permanentlyDelete: false),
+ cancelSuccessMessage: MailResourcesStrings.Localizable.snackbarMoveCancelled,
+ undoRedoAction: undoRedoAction,
+ mailboxManager: self
+ )
+ }
+ }
+
+ private func deletionSnackbarMessage(for messages: [Message], permanentlyDelete: Bool) -> String {
+ if let firstMessageThreadMessagesCount = messages.first?.originalThread?.messages.count,
+ messages.count == 1 && firstMessageThreadMessagesCount > 1 {
+ return permanentlyDelete ?
+ MailResourcesStrings.Localizable.snackbarMessageDeletedPermanently :
+ MailResourcesStrings.Localizable.snackbarMessageMoved(FolderRole.trash.localizedName)
+ } else {
+ let uniqueThreadCount = Set(messages.compactMap(\.originalThread?.uid)).count
+ if permanentlyDelete {
+ return MailResourcesStrings.Localizable.snackbarThreadDeletedPermanently(uniqueThreadCount)
+ } else if uniqueThreadCount == 1 {
+ return MailResourcesStrings.Localizable.snackbarThreadMoved(FolderRole.trash.localizedName)
+ } else {
+ return MailResourcesStrings.Localizable.snackbarThreadsMoved(FolderRole.trash.localizedName)
+ }
+ }
+ }
+
+ internal func markAsSeen(messages: [Message], seen: Bool) async throws {
+ if seen {
+ _ = try await apiFetcher.markAsSeen(mailbox: mailbox, messages: messages)
+ } else {
+ _ = try await apiFetcher.markAsUnseen(mailbox: mailbox, messages: messages)
+ }
+ try await refreshFolder(from: messages)
+
+ // TODO: Remove after fix
+ Task {
+ for message in messages {
+ if let liveMessage = message.thaw(),
+ liveMessage.seen != seen {
+ SentrySDK.capture(message: "Found incoherent message update") { scope in
+ scope.setContext(value: ["Message": ["uid": message.uid,
+ "messageId": message.messageId,
+ "date": message.date,
+ "seen": message.seen,
+ "duplicates": message.duplicates.compactMap(\.messageId),
+ "references": message.references],
+ "Seen": ["Expected": seen, "Actual": liveMessage.seen],
+ "Folder": ["id": message.folder?._id,
+ "name": message.folder?.name,
+ "last update": message.folder?.lastUpdate,
+ "cursor": message.folder?.cursor]],
+ key: "Message context")
+ }
+ }
+ }
+ }
+ }
+
+ private func star(messages: [Message]) async throws -> MessageActionResult {
+ let response = try await apiFetcher.star(mailbox: mailbox, messages: messages)
+ try await refreshFolder(from: messages)
+ return response
+ }
+
+ private func unstar(messages: [Message]) async throws -> MessageActionResult {
+ let response = try await apiFetcher.unstar(mailbox: mailbox, messages: messages)
+ try await refreshFolder(from: messages)
+ return response
+ }
+
+ private func undoRedoAction(for cancellableResponse: UndoResponse, and messages: [Message]) -> UndoRedoAction {
+ let redoAction = {
+ try await self.refreshFolder(from: messages)
+ }
+ return UndoRedoAction(undo: cancellableResponse, redo: redoAction)
+ }
+}
diff --git a/MailCore/Cache/MailboxManager/MailboxManager+Search.swift b/MailCore/Cache/MailboxManager/MailboxManager+Search.swift
new file mode 100644
index 000000000..7369aa07f
--- /dev/null
+++ b/MailCore/Cache/MailboxManager/MailboxManager+Search.swift
@@ -0,0 +1,205 @@
+/*
+ Infomaniak Mail - iOS App
+ Copyright (C) 2022 Infomaniak Network SA
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ 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, see .
+ */
+
+import Foundation
+import InfomaniakCore
+import RealmSwift
+
+// MARK: - Search
+
+public extension MailboxManager {
+ func initSearchFolder() -> Folder {
+ let searchFolder = Folder(
+ id: Constants.searchFolderId,
+ path: "",
+ name: "",
+ isFavorite: false,
+ separator: "/",
+ children: [],
+ toolType: .search
+ )
+
+ let realm = getRealm()
+ try? realm.uncheckedSafeWrite {
+ realm.add(searchFolder, update: .modified)
+ }
+ return searchFolder
+ }
+
+ func searchThreads(searchFolder: Folder?, filterFolderId: String, filter: Filter = .all,
+ searchFilter: [URLQueryItem] = []) async throws -> ThreadResult {
+ let threadResult = try await apiFetcher.threads(
+ mailbox: mailbox,
+ folderId: filterFolderId,
+ filter: filter,
+ searchFilter: searchFilter,
+ isDraftFolder: false
+ )
+
+ await backgroundRealm.execute { realm in
+ for thread in threadResult.threads ?? [] {
+ thread.fromSearch = true
+
+ for message in thread.messages where realm.object(ofType: Message.self, forPrimaryKey: message.uid) == nil {
+ message.fromSearch = true
+ }
+ }
+ }
+
+ if let searchFolder {
+ await saveThreads(result: threadResult, parent: searchFolder)
+ }
+
+ return threadResult
+ }
+
+ func searchThreads(searchFolder: Folder?, from resource: String,
+ searchFilter: [URLQueryItem] = []) async throws -> ThreadResult {
+ let threadResult = try await apiFetcher.threads(from: resource, searchFilter: searchFilter)
+
+ let realm = getRealm()
+ for thread in threadResult.threads ?? [] {
+ thread.fromSearch = true
+
+ for message in thread.messages where realm.object(ofType: Message.self, forPrimaryKey: message.uid) == nil {
+ message.fromSearch = true
+ }
+ }
+
+ if let searchFolder {
+ await saveThreads(result: threadResult, parent: searchFolder)
+ }
+
+ return threadResult
+ }
+
+ func searchThreadsOffline(searchFolder: Folder?, filterFolderId: String,
+ searchFilters: [SearchCondition]) async {
+ await backgroundRealm.execute { realm in
+ guard let searchFolder = searchFolder?.fresh(using: realm) else { return }
+
+ try? realm.safeWrite {
+ realm.delete(realm.objects(Message.self).where { $0.fromSearch == true })
+ realm.delete(searchFolder.threads.where { $0.fromSearch == true })
+ searchFolder.threads.removeAll()
+ }
+
+ var predicates: [NSPredicate] = []
+ for searchFilter in searchFilters {
+ switch searchFilter {
+ case .filter(let filter):
+ switch filter {
+ case .seen:
+ predicates.append(NSPredicate(format: "seen = true"))
+ case .unseen:
+ predicates.append(NSPredicate(format: "seen = false"))
+ case .starred:
+ predicates.append(NSPredicate(format: "flagged = true"))
+ case .unstarred:
+ predicates.append(NSPredicate(format: "flagged = false"))
+ default:
+ break
+ }
+ case .from(let from):
+ predicates.append(NSPredicate(format: "ANY from.email = %@", from))
+ case .contains(let content):
+ predicates
+ .append(
+ NSPredicate(format: "subject CONTAINS[c] %@ OR preview CONTAINS[c] %@",
+ content, content, content)
+ )
+ case .everywhere(let searchEverywhere):
+ if !searchEverywhere {
+ predicates.append(NSPredicate(format: "folderId = %@", filterFolderId))
+ }
+ case .attachments(let searchAttachments):
+ if searchAttachments {
+ predicates.append(NSPredicate(format: "hasAttachments = true"))
+ }
+ }
+ }
+
+ let compoundPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
+
+ let filteredMessages = realm.objects(Message.self).filter(compoundPredicate)
+
+ // Update thread in Realm
+ try? realm.safeWrite {
+ for message in filteredMessages {
+ let newMessage = message.detached()
+ newMessage.uid = "offline\(newMessage.uid)"
+ newMessage.fromSearch = true
+
+ let newThread = Thread(
+ uid: "offlineThread\(message.uid)",
+ messages: [newMessage],
+ unseenMessages: 0,
+ from: Array(message.from.detached()),
+ to: Array(message.to.detached()),
+ date: newMessage.date,
+ hasAttachments: newMessage.hasAttachments,
+ hasDrafts: newMessage.isDraft,
+ flagged: newMessage.flagged,
+ answered: newMessage.answered,
+ forwarded: newMessage.forwarded
+ )
+ newThread.fromSearch = true
+ newThread.subject = message.subject
+ searchFolder.threads.insert(newThread)
+ }
+ }
+ }
+ }
+
+ func searchHistory(using realm: Realm? = nil) -> SearchHistory {
+ let realm = realm ?? getRealm()
+ if let searchHistory = realm.objects(SearchHistory.self).first {
+ return searchHistory.freeze()
+ }
+ let newSearchHistory = SearchHistory()
+ try? realm.uncheckedSafeWrite {
+ realm.add(newSearchHistory)
+ }
+ return newSearchHistory
+ }
+
+ func update(searchHistory: SearchHistory, with value: String) async -> SearchHistory {
+ return await backgroundRealm.execute { realm in
+ guard let liveSearchHistory = realm.objects(SearchHistory.self).first else { return searchHistory }
+ try? realm.safeWrite {
+ if let indexToRemove = liveSearchHistory.history.firstIndex(of: value) {
+ liveSearchHistory.history.remove(at: indexToRemove)
+ }
+ liveSearchHistory.history.insert(value, at: 0)
+ }
+ return liveSearchHistory.freeze()
+ }
+ }
+
+ func delete(searchHistory: SearchHistory, with value: String) async -> SearchHistory {
+ return await backgroundRealm.execute { realm in
+ guard let liveSearchHistory = realm.objects(SearchHistory.self).first else { return searchHistory }
+ try? realm.safeWrite {
+ if let indexToRemove = liveSearchHistory.history.firstIndex(of: value) {
+ liveSearchHistory.history.remove(at: indexToRemove)
+ }
+ }
+ return liveSearchHistory.freeze()
+ }
+ }
+}
diff --git a/MailCore/Cache/MailboxManager/MailboxManager+Signatures.swift b/MailCore/Cache/MailboxManager/MailboxManager+Signatures.swift
new file mode 100644
index 000000000..3cf9469b0
--- /dev/null
+++ b/MailCore/Cache/MailboxManager/MailboxManager+Signatures.swift
@@ -0,0 +1,71 @@
+/*
+ Infomaniak Mail - iOS App
+ Copyright (C) 2022 Infomaniak Network SA
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ 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, see .
+ */
+
+import Foundation
+import MailResources
+import RealmSwift
+
+// MARK: - Signatures
+
+public extension MailboxManager {
+ func refreshAllSignatures() async throws {
+ // Get from API
+ let signaturesResult = try await apiFetcher.signatures(mailbox: mailbox)
+ let updatedSignatures = Array(signaturesResult.signatures)
+
+ await backgroundRealm.execute { realm in
+ let signaturesToDelete: [Signature] // no longer present server side
+ let signaturesToUpdate: [Signature] // updated signatures
+ let signaturesToAdd: [Signature] // new signatures
+
+ // fetch all local signatures
+ let existingSignatures = Array(realm.objects(Signature.self))
+
+ signaturesToAdd = updatedSignatures.filter { updatedElement in
+ !existingSignatures.contains(updatedElement)
+ }
+
+ signaturesToUpdate = updatedSignatures.filter { updatedElement in
+ existingSignatures.contains(updatedElement)
+ }
+
+ signaturesToDelete = existingSignatures.filter { existingElement in
+ !updatedSignatures.contains(existingElement)
+ }
+
+ // NOTE: local drafts in `signaturesToDelete` should be migrated to use the new default signature.
+
+ // Update signatures in Realm
+ try? realm.safeWrite {
+ realm.add(signaturesToUpdate, update: .modified)
+ realm.delete(signaturesToDelete)
+ realm.add(signaturesToAdd, update: .modified)
+ }
+ }
+ }
+
+ func updateSignature(signature: Signature) async throws {
+ _ = try await apiFetcher.updateSignature(mailbox: mailbox, signature: signature)
+ try await refreshAllSignatures()
+ }
+
+ func getStoredSignatures(using realm: Realm? = nil) -> [Signature] {
+ let realm = realm ?? getRealm()
+ return Array(realm.objects(Signature.self))
+ }
+}
diff --git a/MailCore/Cache/MailboxManager/MailboxManager+Thread.swift b/MailCore/Cache/MailboxManager/MailboxManager+Thread.swift
new file mode 100644
index 000000000..9ce866a73
--- /dev/null
+++ b/MailCore/Cache/MailboxManager/MailboxManager+Thread.swift
@@ -0,0 +1,165 @@
+/*
+ Infomaniak Mail - iOS App
+ Copyright (C) 2022 Infomaniak Network SA
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ 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, see .
+ */
+
+import Foundation
+import InfomaniakCore
+import RealmSwift
+
+// MARK: - Thread
+
+public extension MailboxManager {
+ func threads(folder: Folder, fetchCurrentFolderCompleted: (() -> Void) = {}) async throws {
+ try await messages(folder: folder.freezeIfNeeded())
+ fetchCurrentFolderCompleted()
+
+ var roles: [FolderRole] {
+ switch folder.role {
+ case .inbox:
+ return [.sent, .draft]
+ case .sent:
+ return [.inbox, .draft]
+ case .draft:
+ return [.inbox, .sent]
+ default:
+ return []
+ }
+ }
+
+ for folderRole in roles {
+ guard !Task.isCancelled else { break }
+ if let realFolder = getFolder(with: folderRole) {
+ try await messages(folder: realFolder.freezeIfNeeded())
+ }
+ }
+ }
+
+ internal func deleteMessages(uids: [String]) async {
+ guard !uids.isEmpty && !Task.isCancelled else { return }
+
+ await backgroundRealm.execute { realm in
+ let batchSize = 100
+ for index in stride(from: 0, to: uids.count, by: batchSize) {
+ autoreleasepool {
+ let uidsBatch = Array(uids[index ..< min(index + batchSize, uids.count)])
+
+ let messagesToDelete = realm.objects(Message.self).where { $0.uid.in(uidsBatch) }
+ var threadsToUpdate = Set()
+ var threadsToDelete = Set()
+ var draftsToDelete = Set()
+
+ for message in messagesToDelete {
+ if let draft = self.draft(messageUid: message.uid, using: realm) {
+ draftsToDelete.insert(draft)
+ }
+ for parent in message.threads {
+ threadsToUpdate.insert(parent)
+ }
+ }
+
+ let foldersToUpdate = Set(threadsToUpdate.compactMap(\.folder))
+
+ try? realm.safeWrite {
+ realm.delete(draftsToDelete)
+ realm.delete(messagesToDelete)
+ for thread in threadsToUpdate {
+ if thread.messageInFolderCount == 0 {
+ threadsToDelete.insert(thread)
+ } else {
+ do {
+ try thread.recomputeOrFail()
+ } catch {
+ threadsToDelete.insert(thread)
+ SentryDebug.threadHasNilLastMessageFromFolderDate(thread: thread)
+ }
+ }
+ }
+ realm.delete(threadsToDelete)
+ for updateFolder in foldersToUpdate {
+ updateFolder.computeUnreadCount()
+ }
+ }
+ }
+ }
+ }
+ }
+
+ internal func saveThreads(result: ThreadResult, parent: Folder) async {
+ await backgroundRealm.execute { realm in
+ guard let parentFolder = parent.fresh(using: realm) else { return }
+
+ let fetchedThreads = MutableSet()
+ fetchedThreads.insert(objectsIn: result.threads ?? [])
+
+ for thread in fetchedThreads {
+ for message in thread.messages {
+ self.keepCacheAttributes(for: message, keepProperties: .standard, using: realm)
+ }
+ }
+
+ // Update thread in Realm
+ try? realm.safeWrite {
+ // Clean old threads after fetching first page
+ if result.currentOffset == 0 {
+ parentFolder.lastUpdate = Date()
+ realm.delete(parentFolder.threads.flatMap(\.messages).filter { $0.fromSearch == true })
+ realm.delete(parentFolder.threads.filter { $0.fromSearch == true })
+ }
+ realm.add(fetchedThreads, update: .modified)
+ parentFolder.threads.insert(objectsIn: fetchedThreads)
+ parentFolder.unreadCount = result.folderUnseenMessages
+ }
+ }
+ }
+
+ func toggleRead(threads: [Thread]) async throws {
+ if threads.contains(where: \.hasUnseenMessages) {
+ var messages = threads.flatMap(\.messages)
+ messages.append(contentsOf: messages.flatMap(\.duplicates))
+ try await markAsSeen(messages: messages, seen: true)
+ } else {
+ let messages = threads.flatMap { thread in
+ thread.lastMessageAndItsDuplicateToExecuteAction(currentMailboxEmail: mailbox.email)
+ }
+ try await markAsSeen(messages: messages, seen: false)
+ }
+ }
+
+ func move(threads: [Thread], to folderRole: FolderRole) async throws -> UndoRedoAction {
+ guard let folder = getFolder(with: folderRole)?.freeze() else { throw MailError.folderNotFound }
+ return try await move(threads: threads, to: folder)
+ }
+
+ func move(threads: [Thread], to folder: Folder) async throws -> UndoRedoAction {
+ var messages = threads.flatMap(\.messages).filter { $0.folder == threads.first?.folder }
+ messages.append(contentsOf: messages.flatMap(\.duplicates))
+
+ return try await move(messages: messages, to: folder)
+ }
+
+ func moveOrDelete(threads: [Thread]) async throws {
+ let messagesToMoveOrDelete = threads.flatMap(\.messages)
+ try await moveOrDelete(messages: messagesToMoveOrDelete)
+ }
+
+ func toggleStar(threads: [Thread]) async throws {
+ let messagesToToggleStar = threads.flatMap { thread in
+ thread.lastMessageAndItsDuplicateToExecuteAction(currentMailboxEmail: mailbox.email)
+ }
+ try await toggleStar(messages: messagesToToggleStar)
+ }
+}
diff --git a/MailCore/Cache/MailboxManager/MailboxManager.swift b/MailCore/Cache/MailboxManager/MailboxManager.swift
new file mode 100644
index 000000000..484f40aba
--- /dev/null
+++ b/MailCore/Cache/MailboxManager/MailboxManager.swift
@@ -0,0 +1,195 @@
+/*
+ Infomaniak Mail - iOS App
+ Copyright (C) 2022 Infomaniak Network SA
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ 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, see .
+ */
+
+import CocoaLumberjackSwift
+import Foundation
+import InfomaniakCore
+import InfomaniakDI
+import RealmSwift
+import SwiftRegex
+
+public final class MailboxManager: ObservableObject, MailboxManageable {
+ @LazyInjectService internal var snackbarPresenter: SnackBarPresentable
+
+ internal lazy var refreshActor = RefreshActor(mailboxManager: self)
+ internal let backgroundRealm: BackgroundRealm
+
+ public static let constants = MailboxManagerConstants()
+
+ public let realmConfiguration: Realm.Configuration
+ public let mailbox: Mailbox
+ public let account: Account
+
+ public let apiFetcher: MailApiFetcher
+ public let contactManager: ContactManager
+
+ public final class MailboxManagerConstants {
+ private let fileManager = FileManager.default
+ public let rootDocumentsURL: URL
+ public let groupDirectoryURL: URL
+ public let cacheDirectoryURL: URL
+
+ init() {
+ @InjectService var appGroupPathProvider: AppGroupPathProvidable
+ groupDirectoryURL = appGroupPathProvider.groupDirectoryURL
+ rootDocumentsURL = appGroupPathProvider.realmRootURL
+ cacheDirectoryURL = appGroupPathProvider.cacheDirectoryURL
+
+ DDLogInfo("groupDirectoryURL: \(groupDirectoryURL)")
+ DDLogInfo(
+ "App working path is: \(fileManager.urls(for: .documentDirectory, in: .userDomainMask).first?.absoluteString ?? "")"
+ )
+ DDLogInfo("Group container path is: \(groupDirectoryURL.absoluteString)")
+ }
+ }
+
+ public init(account: Account, mailbox: Mailbox, apiFetcher: MailApiFetcher, contactManager: ContactManager) {
+ self.account = account
+ self.mailbox = mailbox
+ self.apiFetcher = apiFetcher
+ self.contactManager = contactManager
+ let realmName = "\(mailbox.userId)-\(mailbox.mailboxId).realm"
+ realmConfiguration = Realm.Configuration(
+ fileURL: MailboxManager.constants.rootDocumentsURL.appendingPathComponent(realmName),
+ schemaVersion: 17,
+ migrationBlock: { migration, oldSchemaVersion in
+ // No migration needed from 0 to 16
+ if oldSchemaVersion < 17 {
+ // Remove signatures without `senderName` and `senderEmailIdn`
+ migration.deleteData(forType: Signature.className())
+ }
+ },
+ objectTypes: [
+ Folder.self,
+ Thread.self,
+ Message.self,
+ Body.self,
+ Attachment.self,
+ Recipient.self,
+ Draft.self,
+ Signature.self,
+ SearchHistory.self
+ ]
+ )
+ backgroundRealm = BackgroundRealm(configuration: realmConfiguration)
+ }
+
+ public func getRealm() -> Realm {
+ do {
+ let realm = try Realm(configuration: realmConfiguration)
+ realm.refresh()
+ return realm
+ } catch {
+ // We can't recover from this error but at least we report it correctly on Sentry
+ Logging.reportRealmOpeningError(error, realmConfiguration: realmConfiguration)
+ }
+ }
+
+ /// Delete all mailbox data cache for user
+ /// - Parameters:
+ /// - userId: User ID
+ /// - mailboxId: Mailbox ID (`nil` if all user mailboxes)
+ public static func deleteUserMailbox(userId: Int, mailboxId: Int? = nil) {
+ let files = (try? FileManager.default
+ .contentsOfDirectory(at: MailboxManager.constants.rootDocumentsURL, includingPropertiesForKeys: nil))
+ files?.forEach { file in
+ if let matches = Regex(pattern: "(\\d+)-(\\d+).realm.*")?.firstMatch(in: file.lastPathComponent), matches.count > 2 {
+ let fileUserId = matches[1]
+ let fileMailboxId = matches[2]
+ if Int(fileUserId) == userId && (mailboxId == nil || Int(fileMailboxId) == mailboxId) {
+ DDLogInfo("Deleting file: \(file.lastPathComponent)")
+ try? FileManager.default.removeItem(at: file)
+ }
+ }
+ }
+ }
+
+ // MARK: - Utilities
+
+ struct MessagePropertiesOptions: OptionSet {
+ let rawValue: Int
+
+ static let fullyDownloaded = MessagePropertiesOptions(rawValue: 1 << 0)
+ static let body = MessagePropertiesOptions(rawValue: 1 << 1)
+ static let attachments = MessagePropertiesOptions(rawValue: 1 << 2)
+ static let localSafeDisplay = MessagePropertiesOptions(rawValue: 1 << 3)
+
+ static let standard: MessagePropertiesOptions = [.fullyDownloaded, .body, .attachments, .localSafeDisplay]
+ }
+
+ internal func keepCacheAttributes(
+ for message: Message,
+ keepProperties: MessagePropertiesOptions,
+ using realm: Realm? = nil
+ ) {
+ let realm = realm ?? getRealm()
+ guard let savedMessage = realm.object(ofType: Message.self, forPrimaryKey: message.uid) else { return }
+ message.inTrash = savedMessage.inTrash
+ if keepProperties.contains(.fullyDownloaded) {
+ message.fullyDownloaded = savedMessage.fullyDownloaded
+ }
+ if keepProperties.contains(.body), let body = savedMessage.body {
+ message.body = Body(value: body)
+ }
+ if keepProperties.contains(.localSafeDisplay) {
+ message.localSafeDisplay = savedMessage.localSafeDisplay
+ }
+ if keepProperties.contains(.attachments) {
+ for attachment in savedMessage.attachments {
+ message.attachments.append(Attachment(value: attachment.freeze()))
+ }
+ }
+ }
+
+ internal func keepCacheAttributes(
+ for folder: Folder,
+ using realm: Realm
+ ) {
+ guard let savedFolder = realm.object(ofType: Folder.self, forPrimaryKey: folder._id) else { return }
+ folder.unreadCount = savedFolder.unreadCount
+ folder.lastUpdate = savedFolder.lastUpdate
+ folder.cursor = savedFolder.cursor
+ folder.remainingOldMessagesToFetch = savedFolder.remainingOldMessagesToFetch
+ folder.isHistoryComplete = savedFolder.isHistoryComplete
+ folder.isExpanded = savedFolder.isExpanded
+ }
+
+ internal func getSubFolders(from folders: [Folder], oldResult: [Folder] = []) -> [Folder] {
+ var result = oldResult
+ for folder in folders {
+ result.append(folder)
+ if !folder.children.isEmpty {
+ result.append(contentsOf: getSubFolders(from: Array(folder.children)))
+ }
+ }
+ return result
+ }
+
+ public func hasUnreadMessages() -> Bool {
+ let realm = getRealm()
+ return realm.objects(Folder.self).contains { $0.unreadCount > 0 }
+ }
+}
+
+// MARK: - Equatable conformance
+
+extension MailboxManager: Equatable {
+ public static func == (lhs: MailboxManager, rhs: MailboxManager) -> Bool {
+ return lhs.mailbox.id == rhs.mailbox.id
+ }
+}