├── .swiftlint.yml ├── LICENSE ├── README.md ├── TempBox.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ ├── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ │ └── Package.resolved │ └── xcuserdata │ │ └── waseemakram.xcuserdatad │ │ └── IDEFindNavigatorScopes.plist ├── xcshareddata │ └── xcschemes │ │ └── TempBox (macOS).xcscheme └── xcuserdata │ └── waseemakram.xcuserdatad │ ├── xcdebugger │ └── Breakpoints_v2.xcbkptlist │ └── xcschemes │ └── xcschememanagement.plist ├── TempBoxTests ├── Cases │ ├── AppControllerTests.swift │ ├── Features │ │ └── AddAccountTests │ │ │ └── AddAccountControllerTests.swift │ ├── Models │ │ └── AccountsTests.swift │ ├── Repositories │ │ └── AccountRepositoryTests.swift │ └── Services │ │ ├── AccountServiceTests.swift │ │ └── MessagesListenerServiceTests.swift ├── Fakes │ ├── FakeAccountRepository.swift │ ├── FakeAccountService.swift │ ├── FakeMTAccountService.swift │ ├── FakeMTDomainService.swift │ └── FakeMTLiveMessagesService.swift ├── Mocks │ ├── MockAccountRepository.swift │ ├── MockAccountService.swift │ ├── MockDataTask.swift │ ├── MockMTAccountService.swift │ └── MockMTDomainService.swift └── Persistence │ └── TestPersistenceManager.swift └── macOS ├── App+Injection.swift ├── AppConfig.swift ├── AppController.swift ├── AppDelegate.swift ├── Extensions ├── Logger+Extensions.swift ├── MTAccount+Extensions.swift ├── MTLiveMessagesService+Extensions.swift ├── MTMessage+Extensions.swift ├── NSManagedObjectContext+Extensions.swift ├── Notifications+Extensions.swift └── String+Extensions.swift ├── Features ├── AddAccount │ ├── AddAccountView.swift │ ├── AddAccountViewController.swift │ └── AddAccountWindow.swift ├── Inbox │ ├── InboxCell.swift │ └── InboxView.swift ├── MessageDetail │ ├── AttachmentsView.swift │ ├── AttachmentsViewController.swift │ ├── MessageDetailHeader.swift │ ├── MessageDetailView.swift │ └── MessageDetailViewController.swift └── Sidebar │ ├── AccountInfoView │ ├── AccountInfoView.swift │ └── AccountInfoViewController.swift │ ├── QuotaView.swift │ └── SidebarView.swift ├── Models ├── LocalNotificationKeys.swift ├── Message.swift ├── MessageStore.swift └── SimpleAlertData.swift ├── Persistence ├── Models │ ├── Account+CoreDataClass.swift │ └── Account+CoreDataProperties.swift ├── Persistence+Injection.swift ├── Persistence.swift └── TempBox.xcdatamodeld │ └── TempBox.xcdatamodel │ └── contents ├── Repositories ├── AccountRepository.swift └── Respositories+Injection.swift ├── RootNavigationView.swift ├── Services ├── AccountService.swift ├── AttachmentDownloadManager.swift ├── FileDownloadManager.swift ├── MessageDownloadManager.swift ├── MessagesListenerService.swift └── Services+Injection.swift ├── Shared Views ├── BadgeView.swift ├── ToolbarDivider.swift └── WebView.swift ├── Supporting files ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── icon_128@1x.png │ │ ├── icon_128@2x.png │ │ ├── icon_16@1x.png │ │ ├── icon_16@2x.png │ │ ├── icon_256@1x.png │ │ ├── icon_256@2x.png │ │ ├── icon_32@1x.png │ │ ├── icon_32@2x.png │ │ ├── icon_512@1x.png │ │ └── icon_512@2x.png │ └── Contents.json ├── Info.plist └── macOS.entitlements ├── TempBoxApp.swift └── Updater ├── CheckForUpdatesView.swift └── UpdaterViewController.swift /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | # By default, SwiftLint uses a set of sensible default rules you can adjust: 2 | disabled_rules: # rule identifiers turned on by default to exclude from running 3 | - colon 4 | - comma 5 | - control_statement 6 | - trailing_whitespace 7 | - switch_case_alignment 8 | - private_over_fileprivate 9 | opt_in_rules: # some rules are turned off by default, so you need to opt-in 10 | - empty_count # Find all the available rules by running: `swiftlint rules` 11 | 12 | # Alternatively, specify all rules explicitly by uncommenting this option: 13 | # only_rules: # delete `disabled_rules` & `opt_in_rules` if using this 14 | # - empty_parameters 15 | # - vertical_whitespace 16 | 17 | #included: # paths to include during linting. `--path` is ignored if present. 18 | excluded: # paths to ignore during linting. Takes precedence over `included`. 19 | - Carthage 20 | - Pods 21 | - Source/ExcludedFolder 22 | - Source/ExcludedFile.swift 23 | - Source/*/ExcludedFile.swift # Exclude files with a wildcard 24 | analyzer_rules: # Rules run by `swiftlint analyze` (experimental) 25 | - explicit_self 26 | 27 | # configurable rules can be customized from this configuration file 28 | # binary rules can set their severity level 29 | force_cast: warning # implicitly 30 | force_try: 31 | severity: warning # explicitly 32 | # rules that have both warning and error levels, can set just the warning level 33 | # implicitly 34 | line_length: 35 | warning: 150 36 | ignores_comments: true 37 | # they can set both implicitly with an array 38 | type_body_length: 39 | - 300 # warning 40 | - 400 # error 41 | # or they can set both explicitly 42 | file_length: 43 | warning: 500 44 | error: 1200 45 | # naming rules can set warnings/errors for min_length and max_length 46 | # additionally they can set excluded names 47 | type_name: 48 | min_length: 4 # only warning 49 | max_length: # warning and error 50 | warning: 40 51 | error: 50 52 | excluded: iPhone # excluded via string 53 | allowed_symbols: ["_"] # these are allowed in type names 54 | identifier_name: 55 | min_length: 2 # only min_length 56 | excluded: # excluded via string array 57 | - id 58 | - URL 59 | - GlobalAPIKey 60 | reporter: "xcode" # reporter type (xcode, json, csv, checkstyle, codeclimate, junit, html, emoji, sonarqube, markdown, github-actions-logging) 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Waseem akram 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TempBox 2 | 3 | 4 | 5 | 6 | Instant disposable emails for Mac powered by [Mail.tm](https://mail.tm) 7 | 8 | ![Banner](https://user-images.githubusercontent.com/12982964/136702592-b793d676-fd61-41a9-aa61-1f459787999a.png) 9 | 10 |
11 |
12 |
13 |
14 | 15 | ![1](https://user-images.githubusercontent.com/12982964/138948032-af11501a-ffd5-4167-a65b-82cca30cb96a.png) 16 |
17 | ![2](https://user-images.githubusercontent.com/12982964/138948099-c717dc7f-2881-44a4-8816-984bad702874.png) 18 |
19 | ![3](https://user-images.githubusercontent.com/12982964/138948109-9bd0a910-5a9a-4984-925a-86c4f06e170e.png) 20 |
21 | ![4](https://user-images.githubusercontent.com/12982964/138948114-2affa4f4-2866-4ff5-afbb-b529100fd64a.png) 22 |
23 | ![5](https://user-images.githubusercontent.com/12982964/138948116-b2da220b-4438-4956-9459-e2dfa2b073dc.png) 24 |
25 | 26 | ## Features 27 | - [x] Native Mac app 28 | - [x] Create multiple accounts 29 | - [x] Download message source 30 | - [x] Download Attachments 31 | - [x] Receive notifications for new messages 32 | - [x] Filter unread messages 33 | 34 | ## Dependencies 35 | - [MailTMSwft](https://github.com/devwaseem/MailTMSwift) for core API. 36 | - [Resolver](https://github.com/hmlongco/Resolver) for dependency management. 37 | 38 | ## Contribute 🤝 39 | 40 | If you want to contribute to this library, you're always welcome! 41 | You can contribute by filing issues, bugs and PRs. 42 | 43 | ### Contributing guidelines: 44 | - Open issue regarding proposed change. 45 | - Repo owner will contact you there. 46 | - If your proposed change is approved, Fork this repo and do changes. 47 | - Open PR against latest `development` branch. Add nice description in PR. 48 | - You're done! 49 | 50 | ## ☕️ Donation 51 | 52 | If this project helped you in any way, you can give me a cup of coffee :). 53 | 54 | [![paypal](https://www.paypalobjects.com/en_US/i/btn/btn_donateCC_LG.gif)](https://www.paypal.me/iamwaseem99) 55 | 56 | ## 📱 Contact 57 | 58 | Have an project? DM us at 👇 59 | 60 | Drop a mail to:- waseem07799@gmail.com 61 | 62 | ## License 63 | 64 | TempBox is released under the MIT license. See [LICENSE](https://raw.githubusercontent.com/devwaseem/TempBox/main/LICENSE) for details. 65 | -------------------------------------------------------------------------------- /TempBox.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /TempBox.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /TempBox.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "MailTMSwift", 6 | "repositoryURL": "https://github.com/devwaseem/MailTMSwift.git", 7 | "state": { 8 | "branch": "main", 9 | "revision": "35238e7d01659f055f93426d6689ad2960f8d227", 10 | "version": null 11 | } 12 | }, 13 | { 14 | "package": "Resolver", 15 | "repositoryURL": "https://github.com/hmlongco/Resolver", 16 | "state": { 17 | "branch": null, 18 | "revision": "97de0b0320036607564af4a60025b48f8d041221", 19 | "version": "1.5.0" 20 | } 21 | }, 22 | { 23 | "package": "Sparkle", 24 | "repositoryURL": "https://github.com/sparkle-project/Sparkle", 25 | "state": { 26 | "branch": "2.x", 27 | "revision": "0d43f88a83698e57d93789831318b053767d1560", 28 | "version": null 29 | } 30 | }, 31 | { 32 | "package": "LDSwiftEventSource", 33 | "repositoryURL": "https://github.com/LaunchDarkly/swift-eventsource.git", 34 | "state": { 35 | "branch": null, 36 | "revision": "7c40adad054c9737afadffe42a2ce0bbcfa02f48", 37 | "version": "1.2.1" 38 | } 39 | } 40 | ] 41 | }, 42 | "version": 1 43 | } 44 | -------------------------------------------------------------------------------- /TempBox.xcodeproj/project.xcworkspace/xcuserdata/waseemakram.xcuserdatad/IDEFindNavigatorScopes.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /TempBox.xcodeproj/xcshareddata/xcschemes/TempBox (macOS).xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 32 | 33 | 39 | 40 | 41 | 42 | 44 | 50 | 51 | 52 | 53 | 54 | 64 | 66 | 72 | 73 | 74 | 75 | 78 | 79 | 82 | 83 | 84 | 85 | 89 | 90 | 91 | 92 | 98 | 100 | 106 | 107 | 108 | 109 | 111 | 112 | 115 | 116 | 117 | -------------------------------------------------------------------------------- /TempBox.xcodeproj/xcuserdata/waseemakram.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 9 | 21 | 22 | 23 | 25 | 37 | 38 | 39 | 41 | 53 | 54 | 55 | 57 | 69 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /TempBox.xcodeproj/xcuserdata/waseemakram.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | TempBox (macOS).xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 2 11 | 12 | UI.xcscheme_^#shared#^_ 13 | 14 | orderHint 15 | 4 16 | 17 | User Interface.xcscheme_^#shared#^_ 18 | 19 | orderHint 20 | 3 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /TempBoxTests/Cases/AppControllerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppControllerTests.swift 3 | // TempBoxTests 4 | // 5 | // Created by Waseem Akram on 26/09/21. 6 | // 7 | 8 | import Foundation 9 | import XCTest 10 | import MailTMSwift 11 | @testable import TempBox 12 | 13 | class AppControllerTests: XCTestCase { 14 | 15 | var persistenceManager: TestPersistenceManager! 16 | var mtDomainService: FakeMTDomainService! 17 | var mtAccountService: FakeMTAccountService! 18 | var accountRepo: FakeAccountRepository! 19 | var accountService: FakeAccountService! 20 | var sut: AppController! 21 | 22 | override func setUp() { 23 | super.setUp() 24 | persistenceManager = TestPersistenceManager() 25 | mtDomainService = FakeMTDomainService() 26 | mtAccountService = FakeMTAccountService() 27 | accountRepo = FakeAccountRepository() 28 | accountService = FakeAccountService() 29 | sut = AppController(accountService: accountService) 30 | } 31 | 32 | override func tearDown() { 33 | super.tearDown() 34 | persistenceManager = nil 35 | mtDomainService = nil 36 | mtAccountService = nil 37 | accountRepo = nil 38 | accountService = nil 39 | sut = nil 40 | } 41 | 42 | func getFakeAccount(id: String = "123", address: String = "test@example.com") -> Account { 43 | let mtAccount = MTAccount(id: id, 44 | address: address, 45 | quotaLimit: 100, 46 | quotaUsed: 0, 47 | isDisabled: false, 48 | isDeleted: false, 49 | createdAt: .init(), 50 | updatedAt: .init()) 51 | let givenAccount = Account(context: persistenceManager.mainContext) 52 | givenAccount.set(from: mtAccount, password: "12345", token: "12345") 53 | return givenAccount 54 | } 55 | 56 | func test_init_whenAccountServiceActiveAccountChanges_changesReflectInLocalProperty() { 57 | 58 | let givenAccount = getFakeAccount() 59 | 60 | // Initially 61 | XCTAssertFalse(givenAccount.isArchived) 62 | XCTAssertEqual(sut.activeAccounts.count, 0) 63 | XCTAssertEqual(sut.archivedAccounts.count, 0) 64 | 65 | // When 66 | accountService.activeAccounts = [givenAccount] 67 | 68 | // Then 69 | XCTAssertEqual(sut.activeAccounts.count, 1) 70 | XCTAssertEqual(sut.archivedAccounts.count, 0) 71 | } 72 | 73 | func test_init_whenAccountServiceArchivedAccountChanges_changesReflectInLocalProperty() { 74 | let givenAccount = getFakeAccount() 75 | givenAccount.isArchived = true 76 | 77 | // Initially 78 | XCTAssertTrue(givenAccount.isArchived) 79 | XCTAssertEqual(sut.activeAccounts.count, 0) 80 | XCTAssertEqual(sut.archivedAccounts.count, 0) 81 | 82 | // When 83 | accountService.archivedAccounts = [givenAccount] 84 | 85 | // Then 86 | XCTAssertEqual(sut.archivedAccounts.count, 1) 87 | XCTAssertEqual(sut.activeAccounts.count, 0) 88 | } 89 | 90 | func test_archiveAccount_archivesAccountAndUpdatesArchivedAccountProperty() { 91 | let givenAccount = getFakeAccount() 92 | givenAccount.isArchived = false 93 | 94 | // Initially 95 | accountService.activeAccounts = [givenAccount] 96 | XCTAssertFalse(givenAccount.isArchived) 97 | XCTAssertEqual(sut.archivedAccounts.count, 0) 98 | XCTAssertEqual(sut.activeAccounts.count, 1) 99 | 100 | // When 101 | sut.archiveAccount(account: givenAccount) 102 | 103 | // Then 104 | XCTAssertEqual(sut.archivedAccounts.count, 1) 105 | XCTAssertEqual(sut.activeAccounts.count, 0) 106 | 107 | } 108 | 109 | func test_activateAccount_activatesAccountAndUpdatesActiveAccountProperty() { 110 | let givenAccount = getFakeAccount() 111 | givenAccount.isArchived = true 112 | 113 | // Initially 114 | accountService.archivedAccounts = [givenAccount] 115 | XCTAssertTrue(givenAccount.isArchived) 116 | XCTAssertEqual(sut.archivedAccounts.count, 1) 117 | XCTAssertEqual(sut.activeAccounts.count, 0) 118 | 119 | // When 120 | sut.activateAccount(account: givenAccount) 121 | 122 | // Then 123 | XCTAssertEqual(sut.archivedAccounts.count, 0) 124 | XCTAssertEqual(sut.activeAccounts.count, 1) 125 | 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /TempBoxTests/Cases/Features/AddAccountTests/AddAccountControllerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AddAccountControllerTests.swift 3 | // TempBoxTests 4 | // 5 | // Created by Waseem Akram on 22/09/21. 6 | // 7 | 8 | import Foundation 9 | import XCTest 10 | import MailTMSwift 11 | @testable import TempBox 12 | 13 | class AddAccountViewControllerTests: XCTestCase { 14 | 15 | var peristenceManager: TestPersistenceManager! 16 | var mtAccountService: FakeMTAccountService! 17 | var mtDomainService: FakeMTDomainService! 18 | var accountRepo: FakeAccountRepository! 19 | var accountService: AccountService! 20 | var sut: AddAccountViewController! 21 | 22 | override func setUp() { 23 | super.setUp() 24 | peristenceManager = TestPersistenceManager() 25 | accountRepo = FakeAccountRepository() 26 | mtDomainService = FakeMTDomainService() 27 | mtAccountService = FakeMTAccountService() 28 | accountService = AccountService(persistenceManager: peristenceManager, 29 | repository: accountRepo, 30 | accountService: mtAccountService, 31 | domainService: mtDomainService) 32 | sut = AddAccountViewController(accountService: accountService) 33 | } 34 | 35 | override func tearDown() { 36 | super.tearDown() 37 | peristenceManager = nil 38 | accountRepo = nil 39 | mtDomainService = nil 40 | mtAccountService = nil 41 | accountService = nil 42 | sut = nil 43 | } 44 | 45 | // MARK: - Helper function 46 | func getDomain(id: String = UUID().uuidString, domain: String, isActive: Bool, isPrivate: Bool) -> MTDomain { 47 | MTDomain(id: id, domain: domain, isActive: isActive, isPrivate: isPrivate, createdAt: .init(), updatedAt: .init()) 48 | } 49 | 50 | func getAccount() -> MTAccount { 51 | MTAccount(id: "1230-123-123", 52 | address: "12345@example.com", 53 | quotaLimit: 0, 54 | quotaUsed: 100, 55 | isDisabled: false, 56 | isDeleted: false, 57 | createdAt: .init(), 58 | updatedAt: .init()) 59 | } 60 | 61 | // MARK: - Init tests cases 62 | 63 | func test_init_whenSuccessfullyRetreivedDomain_setsInitialProperties() { 64 | XCTAssertFalse(sut.isDomainsLoading) 65 | XCTAssertEqual(sut.availableDomains.count, 1) 66 | XCTAssertTrue(sut.selectedDomain != "") 67 | } 68 | 69 | // MARK: - Other tests cases 70 | 71 | func test_addressText_whenAddressEntered_convertsToLowerCase() { 72 | let givenAddress = "123abcDEF" 73 | 74 | sut.addressText = givenAddress 75 | 76 | XCTAssertEqual(sut.addressText, "123abcdef") 77 | 78 | } 79 | 80 | func test_canCreate_whenValidSituation_returnsTrue() { 81 | // when 82 | sut.selectedDomain = "example.com" 83 | sut.addressText = "12345" 84 | sut.shouldGenerateRandomPassword = true 85 | 86 | // then 87 | XCTAssertTrue(sut.canCreate, "isCreateButtonEnabled should be true") 88 | } 89 | 90 | func test_generateRandomAddress_assignsRandomAddress() { 91 | 92 | XCTAssertEqual(sut.addressText, "") 93 | 94 | // when 95 | sut.generateRandomAddress() 96 | 97 | // then 98 | XCTAssertNotEqual(sut.addressText, "") 99 | XCTAssertEqual(sut.addressText.count, 10) 100 | } 101 | 102 | func test_isPasswordValid_shouldReturnTrueForValidPassword() { 103 | // when 104 | sut.shouldGenerateRandomPassword = false 105 | sut.passwordText = "123456" 106 | 107 | // then 108 | XCTAssertTrue(sut.isPasswordValid, "isPassword should be True") 109 | } 110 | 111 | func test_isPasswordValid_shouldReturnFalseForInValidPassword() { 112 | // when 113 | sut.shouldGenerateRandomPassword = false 114 | sut.passwordText = "12" 115 | 116 | // then 117 | XCTAssertFalse(sut.isPasswordValid, "isPassword should be False") 118 | } 119 | 120 | func test_createNewAddress_whenRandomPassword_createsAccountAndClosesWindow() { 121 | // given 122 | sut.selectedDomain = "example.com" 123 | sut.addressText = "12345" 124 | sut.shouldGenerateRandomPassword = true 125 | 126 | // when 127 | XCTAssertTrue(sut.canCreate) 128 | sut.createNewAddress() 129 | 130 | // then 131 | XCTAssertNil(sut.alertMessage) 132 | XCTAssertFalse(sut.isAddAccountWindowOpen) 133 | } 134 | 135 | func test_createNewAddress_whenManualPassword_createsAccountAndClosesWindow() { 136 | // given 137 | sut.selectedDomain = "example.com" 138 | sut.addressText = "12345" 139 | sut.passwordText = "123456" 140 | sut.shouldGenerateRandomPassword = false 141 | 142 | // when 143 | XCTAssertTrue(sut.canCreate) 144 | sut.createNewAddress() 145 | 146 | // then 147 | XCTAssertNil(sut.alertMessage) 148 | XCTAssertFalse(sut.isAddAccountWindowOpen) 149 | } 150 | 151 | func test_createNewAddress_whenCanCreateIsFalse_doNothing() { 152 | sut.createNewAddress() 153 | XCTAssertFalse(sut.canCreate) 154 | } 155 | 156 | func test_createNewAddress_whenAddressAlreadyExists_setsErrorMessage() { 157 | // given 158 | let givenAddressAlreadyExistsMessage = "address: This value is already used." 159 | 160 | sut.isAddAccountWindowOpen = true 161 | sut.selectedDomain = "example.com" 162 | sut.addressText = "12345" 163 | sut.shouldGenerateRandomPassword = true 164 | mtAccountService.error = .mtError(givenAddressAlreadyExistsMessage) 165 | mtAccountService.accounts = [ 166 | getAccount(), 167 | MTAccount(id: "123", 168 | address: "12345@example.com", 169 | quotaLimit: 100, 170 | quotaUsed: 0, 171 | isDisabled: false, 172 | isDeleted: false, 173 | createdAt: .init(), 174 | updatedAt: .init()) 175 | ] 176 | XCTAssertTrue(sut.canCreate) 177 | sut.createNewAddress() 178 | 179 | XCTAssertFalse(sut.isCreatingAccount) 180 | XCTAssertNotNil(sut.alertMessage) 181 | XCTAssertEqual(sut.alertMessage?.title, "This address already exists! Please choose a different address") 182 | XCTAssertTrue(sut.isAddAccountWindowOpen, "The window is closed. The window should stay open if an error is occured") 183 | } 184 | 185 | func test_createNewAddress_whenServerReturnsError_setsErrorMessage() { 186 | // given 187 | let givenErrorMessage = "Test Error: Server Error" 188 | 189 | sut.isAddAccountWindowOpen = true 190 | sut.selectedDomain = "example.com" 191 | sut.addressText = "12345" 192 | sut.shouldGenerateRandomPassword = true 193 | mtAccountService.error = .mtError(givenErrorMessage) 194 | mtAccountService.forceError = true 195 | sut.createNewAddress() 196 | 197 | XCTAssertFalse(sut.isCreatingAccount) 198 | XCTAssertNotNil(sut.alertMessage) 199 | XCTAssertEqual(sut.alertMessage?.title, givenErrorMessage) 200 | XCTAssertTrue(sut.isAddAccountWindowOpen, "The window is closed. The window should stay open if an error is occured") 201 | } 202 | 203 | func test_createNewAddress_whenNetworkOrOtherError_setsCustomErrorMessage() { 204 | let givenErrorMessage = "Test Error: Server Error" 205 | 206 | sut.isAddAccountWindowOpen = true 207 | sut.selectedDomain = "example.com" 208 | sut.addressText = "12345" 209 | sut.shouldGenerateRandomPassword = true 210 | mtAccountService.error = .networkError(givenErrorMessage) 211 | mtAccountService.forceError = true 212 | 213 | sut.createNewAddress() 214 | 215 | XCTAssertFalse(sut.isCreatingAccount) 216 | XCTAssertNotNil(sut.alertMessage) 217 | XCTAssertEqual(sut.alertMessage?.title, "Something went wrong while creating a new address") 218 | XCTAssertTrue(sut.isAddAccountWindowOpen, "The window is closed. The window should stay open if an error is occured") 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /TempBoxTests/Cases/Models/AccountsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccountsTests.swift 3 | // TempBoxTests 4 | // 5 | // Created by Waseem Akram on 01/10/21. 6 | // 7 | 8 | import Foundation 9 | import MailTMSwift 10 | import XCTest 11 | @testable import TempBox 12 | 13 | class AccountsTests: XCTestCase { 14 | 15 | var persistenceManager: TestPersistenceManager! 16 | var createdDate: Date! 17 | var updatedDate: Date! 18 | 19 | override func setUp() { 20 | super.setUp() 21 | persistenceManager = TestPersistenceManager() 22 | createdDate = Date() 23 | updatedDate = Date() 24 | } 25 | 26 | override func tearDown() { 27 | super.tearDown() 28 | persistenceManager = nil 29 | createdDate = nil 30 | updatedDate = nil 31 | } 32 | 33 | func getAccount() -> MTAccount { 34 | MTAccount(id: "testid", 35 | address: "testaddress", 36 | quotaLimit: 100, 37 | quotaUsed: 10, 38 | isDisabled: false, 39 | isDeleted: true, 40 | createdAt: createdDate, 41 | updatedAt: updatedDate 42 | ) 43 | } 44 | 45 | func assertCommonValues(sut: Account) { 46 | XCTAssertEqual(sut.id, "testid") 47 | XCTAssertEqual(sut.address, "testaddress") 48 | XCTAssertEqual(sut.quotaLimit, 100) 49 | XCTAssertEqual(sut.quotaUsed, 10) 50 | XCTAssertFalse(sut.isDisabled) 51 | XCTAssertFalse(sut.isDeleted) 52 | XCTAssertEqual(sut.createdAt, createdDate) 53 | XCTAssertEqual(sut.updatedAt, updatedDate) 54 | XCTAssertEqual(sut.password, "test-password") 55 | XCTAssertEqual(sut.token, "test-token") 56 | } 57 | 58 | func test_set_whenValuesPassedwithoutIsArchived_setsAccountWithActiveStatus() { 59 | 60 | let givenAccount = getAccount() 61 | 62 | let sut = Account(context: persistenceManager.mainContext) 63 | 64 | // when 65 | sut.set(from: givenAccount, password: "test-password", token: "test-token") 66 | 67 | // then 68 | 69 | XCTAssertFalse(sut.isArchived) 70 | 71 | assertCommonValues(sut: sut) 72 | } 73 | 74 | func test_set_whenValuesPassedwithIsArchived_setsAccountWithArchivedStatus() { 75 | 76 | let givenAccount = getAccount() 77 | 78 | let sut = Account(context: persistenceManager.mainContext) 79 | 80 | // when 81 | sut.set(from: givenAccount, password: "test-password", token: "test-token", isArchived: true) 82 | 83 | // then 84 | 85 | XCTAssertTrue(sut.isArchived) 86 | 87 | assertCommonValues(sut: sut) 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /TempBoxTests/Cases/Repositories/AccountRepositoryTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccountRepositoryTests.swift 3 | // TempBoxTests 4 | // 5 | // Created by Waseem Akram on 22/09/21. 6 | // 7 | 8 | import Foundation 9 | import XCTest 10 | import MailTMSwift 11 | @testable import TempBox 12 | 13 | class AccountRepositoryTests: XCTestCase { 14 | 15 | var persistenceManager: PersistenceManager! 16 | var sut: AccountRepository! 17 | 18 | override func setUp() { 19 | super.setUp() 20 | persistenceManager = TestPersistenceManager() 21 | sut = AccountRepository(persistenceManager: persistenceManager) 22 | } 23 | 24 | override func tearDown() { 25 | super.tearDown() 26 | persistenceManager = nil 27 | sut = nil 28 | } 29 | 30 | // MARK: - Helpers 31 | 32 | func getAccount(id: String = UUID().uuidString, address: String = "example.com") -> MTAccount { 33 | MTAccount(id: id, 34 | address: address, 35 | quotaLimit: 100, 36 | quotaUsed: 10, 37 | isDisabled: false, 38 | isDeleted: false, 39 | createdAt: .init(), 40 | updatedAt: .init()) 41 | } 42 | 43 | // MARK: - Given 44 | 45 | func givenAddAccountsToPersistence(mtAccounts: [MTAccount]) { 46 | for mtAccount in mtAccounts { 47 | let account = Account(context: persistenceManager.mainContext) 48 | account.set(from: mtAccount, password: "12345", token: "12345") 49 | } 50 | persistenceManager.saveMainContext() 51 | } 52 | 53 | // MARK: - Test cases 54 | 55 | func test_getAllAccounts_returnsAllAccounts() { 56 | let givenAccount = getAccount() 57 | let givenAccounts = [givenAccount] 58 | givenAddAccountsToPersistence(mtAccounts: givenAccounts) 59 | 60 | let results = sut.getAll() 61 | 62 | XCTAssertEqual(results.count, 1) 63 | } 64 | 65 | func test_getAccount_whenExistingIdIsPassed_returnsNonNilAccount() throws { 66 | let givenAccount = getAccount() 67 | let givenAccounts = [givenAccount] 68 | givenAddAccountsToPersistence(mtAccounts: givenAccounts) 69 | 70 | let optionalResult = sut.getAccount(fromId: givenAccount.id) 71 | XCTAssertNotNil(optionalResult) 72 | let result = try XCTUnwrap(optionalResult) 73 | XCTAssertEqual(result.id, givenAccount.id) 74 | XCTAssertEqual(result.address, givenAccount.address) 75 | XCTAssertEqual(result.createdAt, givenAccount.createdAt) 76 | } 77 | 78 | func test_getAccount_whenNonExistingIdIsPassed_returnsNilAccount() throws { 79 | let givenAccount = getAccount() 80 | let givenAccounts = [givenAccount] 81 | givenAddAccountsToPersistence(mtAccounts: givenAccounts) 82 | 83 | let optionalResult = sut.getAccount(fromId: givenAccount.id + "~") 84 | XCTAssertNil(optionalResult) 85 | } 86 | 87 | func test_create_createsAccountInPersistence() throws { 88 | expectation(forNotification: .NSManagedObjectContextDidSave, object: persistenceManager.mainContext) { _ in 89 | return true 90 | } 91 | 92 | let givenAccount = getAccount() 93 | sut.create(account: givenAccount, password: "12345", token: "123") 94 | 95 | waitForExpectations(timeout: 1) { error in 96 | XCTAssertNil(error, "Save did not occur") 97 | } 98 | 99 | let optionalSearchedResult = sut.getAccount(fromId: givenAccount.id) 100 | XCTAssertNotNil(optionalSearchedResult) 101 | let searchedResult = try XCTUnwrap(optionalSearchedResult) 102 | XCTAssertEqual(searchedResult.address, givenAccount.address) 103 | } 104 | 105 | func test_update_updatesAccountInPersistence() throws { 106 | 107 | let givenAccount = getAccount() 108 | sut.create(account: givenAccount, password: "12345", token:"123") // create the object first 109 | 110 | let searchedResult = sut.getAccount(fromId: givenAccount.id)! 111 | 112 | expectation(forNotification: .NSManagedObjectContextDidSave, object: persistenceManager.mainContext) { _ in 113 | return true 114 | } 115 | 116 | searchedResult.address = "update@example.com" // update 117 | sut.update(account: searchedResult) 118 | 119 | waitForExpectations(timeout: 1) { error in 120 | XCTAssertNil(error, "Save did not occur") 121 | } 122 | 123 | let optionalUpdatedSearchedResult = sut.getAccount(fromId: givenAccount.id) 124 | XCTAssertNotNil(optionalUpdatedSearchedResult) 125 | let updatedSearchedResult = try XCTUnwrap(optionalUpdatedSearchedResult) 126 | XCTAssertEqual(updatedSearchedResult.address, "update@example.com") 127 | 128 | } 129 | 130 | func test_delete_deletesTheAccountFromPersistence() { 131 | let givenDeletingAccount = getAccount(address: "deleting account") 132 | givenAddAccountsToPersistence(mtAccounts: [ 133 | getAccount(address: "someAccount1@test.com"), 134 | getAccount(address: "someAccount2@test.com"), 135 | givenDeletingAccount 136 | ]) 137 | 138 | // get the deletingAccount from persistence 139 | let deletingAccount = sut.getAccount(fromId: givenDeletingAccount.id)! 140 | 141 | expectation(forNotification: .NSManagedObjectContextDidSave, object: persistenceManager.mainContext) { _ in 142 | return true 143 | } 144 | 145 | sut.delete(account: deletingAccount) 146 | 147 | waitForExpectations(timeout: 1) { error in 148 | XCTAssertNil(error, "Save did not occur") 149 | } 150 | 151 | let deletedAccount = sut.getAccount(fromId: givenDeletingAccount.id) 152 | XCTAssertNil(deletedAccount) 153 | } 154 | 155 | func test_deleteAll_deletesAllAccountFromPersistence() { 156 | givenAddAccountsToPersistence(mtAccounts: [ 157 | getAccount(address: "someAccount1@test.com"), 158 | getAccount(address: "someAccount2@test.com"), 159 | getAccount(address: "someAccount3@test.com"), 160 | getAccount(address: "someAccount4@test.com") 161 | ]) 162 | 163 | expectation(forNotification: .NSManagedObjectContextDidSave, object: persistenceManager.mainContext) { _ in 164 | return true 165 | } 166 | 167 | sut.deleteAll() 168 | persistenceManager.mainContext.reset() // batch delete wont work inmemory since it works only on the store. 169 | 170 | waitForExpectations(timeout: 1) { error in 171 | XCTAssertNil(error, "Save did not occur") 172 | } 173 | 174 | let results = sut.getAll() 175 | XCTAssertEqual(results.count, 0) 176 | } 177 | 178 | func test_isAccountExistsForId_whenExists_returnsTrue() { 179 | let givenAccount = getAccount(address: "someAccount1@test.com") 180 | givenAddAccountsToPersistence(mtAccounts: [ 181 | givenAccount 182 | ]) 183 | 184 | let result = sut.isAccountExists(id: givenAccount.id) 185 | XCTAssertTrue(result) 186 | } 187 | 188 | func test_isAccountExistsForId_whenDoesNotExists_returnsFalse() { 189 | let givenAccount = getAccount(address: "someAccount1@test.com") 190 | givenAddAccountsToPersistence(mtAccounts: [ 191 | givenAccount 192 | ]) 193 | 194 | let result = sut.isAccountExists(id: "ID_Does_not_exists") 195 | XCTAssertFalse(result) 196 | } 197 | 198 | func test_isAccountExistsForAddress_whenExists_returnsTrue() { 199 | let givenAccount = getAccount(address: "someAccount1@test.com") 200 | givenAddAccountsToPersistence(mtAccounts: [ 201 | givenAccount 202 | ]) 203 | 204 | let result = sut.isAccountExists(forAddress: "someAccount1@test.com") 205 | XCTAssertTrue(result) 206 | } 207 | 208 | func test_isAccountExistsForAddress_whenDoesNotExists_returnsFalse() { 209 | let givenAccount = getAccount(address: "someAccount1@test.com") 210 | givenAddAccountsToPersistence(mtAccounts: [ 211 | givenAccount 212 | ]) 213 | 214 | let result = sut.isAccountExists(id: "notExisits@test.com") 215 | XCTAssertFalse(result) 216 | } 217 | 218 | } 219 | -------------------------------------------------------------------------------- /TempBoxTests/Cases/Services/MessagesListenerServiceTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessagesListenerServiceTests.swift 3 | // TempBoxTests 4 | // 5 | // Created by Waseem Akram on 01/10/21. 6 | // 7 | 8 | import Foundation 9 | import XCTest 10 | import MailTMSwift 11 | import Combine 12 | @testable import TempBox 13 | 14 | class TestableMessagesListenerService: MessagesListenerService { 15 | 16 | var mtLiveMessageService: FakeMTLiveMessagesService 17 | 18 | init(accountService: AccountServiceProtocol, 19 | mtLiveMessageService: FakeMTLiveMessagesService, 20 | accountRepository: AccountRepository 21 | ) { 22 | self.mtLiveMessageService = mtLiveMessageService 23 | super.init(accountService: accountService, accountRepository: accountRepository) 24 | } 25 | 26 | override func createListener(withToken token: String, accountId: String) -> MTLiveMessageProtocol { 27 | mtLiveMessageService 28 | } 29 | 30 | } 31 | 32 | class MessagesListenerServiceTests: XCTestCase { 33 | 34 | var persistenceManager: TestPersistenceManager! 35 | var accountRepository: AccountRepository! 36 | var accountService: FakeAccountService! 37 | var messageListenerService: FakeMTLiveMessagesService! 38 | var sut: TestableMessagesListenerService! 39 | 40 | var subscriptions = Set() 41 | 42 | override func setUp() { 43 | super.setUp() 44 | persistenceManager = TestPersistenceManager() 45 | accountRepository = AccountRepository(persistenceManager: persistenceManager) 46 | accountService = FakeAccountService() 47 | messageListenerService = FakeMTLiveMessagesService() 48 | sut = TestableMessagesListenerService(accountService: accountService, 49 | mtLiveMessageService: messageListenerService, 50 | accountRepository: accountRepository) 51 | } 52 | 53 | override func tearDown() { 54 | super.tearDown() 55 | persistenceManager = nil 56 | accountRepository = nil 57 | accountService = nil 58 | messageListenerService = nil 59 | sut = nil 60 | } 61 | 62 | func getFakeMTAccount() -> MTAccount { 63 | MTAccount(id: "1234", 64 | address: "test@test.com", 65 | quotaLimit: 100, 66 | quotaUsed: 0, 67 | isDisabled: false, 68 | isDeleted: false, 69 | createdAt: .init(), 70 | updatedAt: .init()) 71 | } 72 | 73 | func getMTMessage() -> MTMessage { 74 | MTMessage(id: "test-id", 75 | msgid: "test-msgId", 76 | from: .init(address: "fromUser@example.com", name: "fromUser"), 77 | to: [], 78 | cc: [], bcc: [], 79 | subject: "test-subject", 80 | seen: false, 81 | flagged: false, 82 | isDeleted: false, 83 | retention: false, 84 | retentionDate: .init(), 85 | intro: "test-intro", 86 | text: "", 87 | html: [], 88 | hasAttachments: false, 89 | attachments: [], 90 | size: 0, 91 | downloadURL: "", 92 | createdAt: .init(), 93 | updatedAt: .init()) 94 | } 95 | 96 | func test_init_whenNewAccountsAdded_createsNewChannelWithAccount() { 97 | 98 | let mtAccount = getFakeMTAccount() 99 | let account = Account(context: persistenceManager.mainContext) 100 | account.set(from: mtAccount, password: "12345", token: "1234") 101 | 102 | // when 103 | accountService.setActiveAccounts(accounts: [account]) 104 | 105 | // then 106 | XCTAssertNotNil(sut.channelsStatus[account]) 107 | XCTAssertEqual(sut.channelsStatus[account], .opened) 108 | XCTAssertTrue(messageListenerService.isStarted) 109 | XCTAssertEqual(messageListenerService.state, .opened) 110 | } 111 | 112 | func test_init_whenExistingAccountsRemoved_stopsListeningAndRemoveChannel() { 113 | 114 | let mtAccount = getFakeMTAccount() 115 | let account = Account(context: persistenceManager.mainContext) 116 | account.set(from: mtAccount, password: "12345", token: "1234") 117 | 118 | accountService.setActiveAccounts(accounts: [account]) 119 | 120 | XCTAssertNotNil(sut.channelsStatus[account]) 121 | XCTAssertEqual(sut.channelsStatus[account], .opened) 122 | XCTAssertTrue(messageListenerService.isStarted) 123 | 124 | // The account is added and live 125 | // Attempt to remove it 126 | 127 | accountService.setActiveAccounts(accounts: []) 128 | XCTAssertNil(sut.channelsStatus[account]) 129 | XCTAssertFalse(messageListenerService.isStarted) 130 | XCTAssertEqual(messageListenerService.state, .closed) 131 | } 132 | 133 | func test_whenMTMessageReceived_messagesReceivedPublisherEmitsResult() throws { 134 | let mtAccount = getFakeMTAccount() 135 | let givenAccount = Account(context: persistenceManager.mainContext) 136 | givenAccount.set(from: mtAccount, password: "12345", token: "1234") 137 | 138 | let givenMessage = getMTMessage() 139 | 140 | let messageExpectation = expectation(description: "Message not received") 141 | var optionalMessageReceived: MessageReceived? 142 | 143 | sut.onMessageReceivedPublisher.sink { _ in 144 | XCTFail("Should not receive completion") 145 | } receiveValue: { messageReceived in 146 | messageExpectation.fulfill() 147 | optionalMessageReceived = messageReceived 148 | } 149 | .store(in: &subscriptions) 150 | 151 | // when 152 | accountService.setActiveAccounts(accounts: [givenAccount]) 153 | messageListenerService.state = .opened 154 | messageListenerService.emulate(message: givenMessage) 155 | 156 | // then 157 | waitForExpectations(timeout: 1) 158 | XCTAssertNotNil(optionalMessageReceived) 159 | 160 | let messageReceived = try XCTUnwrap(optionalMessageReceived) 161 | XCTAssertEqual(messageReceived.account, givenAccount) 162 | XCTAssertEqual(messageReceived.message.id, givenMessage.id) 163 | } 164 | 165 | func test_restartChannel_whenCalled_restartsChannel() { 166 | 167 | let mtAccount = getFakeMTAccount() 168 | let givenAccount = Account(context: persistenceManager.mainContext) 169 | givenAccount.set(from: mtAccount, password: "12345", token: "1234") 170 | 171 | // Initally Add a account, make sure, the connection is opened 172 | accountService.setActiveAccounts(accounts: [givenAccount]) 173 | XCTAssertNotNil(sut.channelsStatus[givenAccount]) 174 | XCTAssertEqual(sut.channelsStatus[givenAccount], .opened) 175 | XCTAssertTrue(messageListenerService.isStarted) 176 | XCTAssertEqual(messageListenerService.state, .opened) 177 | 178 | messageListenerService.stop() // emulating stop like network error. 179 | 180 | // After stopping a connection, make sure, the connection is closed 181 | XCTAssertFalse(messageListenerService.isStarted) 182 | XCTAssertEqual(messageListenerService.state, .closed) 183 | XCTAssertEqual(sut.channelsStatus[givenAccount], .closed) 184 | 185 | // when 186 | sut.restartChannel(account: givenAccount) 187 | 188 | // then 189 | // After restarting a connection, make sure, the connection is opened again 190 | XCTAssertTrue(messageListenerService.isStarted) 191 | XCTAssertEqual(messageListenerService.state, .opened) 192 | XCTAssertEqual(sut.channelsStatus[givenAccount], .opened) 193 | 194 | } 195 | 196 | } 197 | -------------------------------------------------------------------------------- /TempBoxTests/Fakes/FakeAccountRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FakeAccountRepository.swift 3 | // TempBoxTests 4 | // 5 | // Created by Waseem Akram on 26/09/21. 6 | // 7 | 8 | import Foundation 9 | import MailTMSwift 10 | @testable import TempBox 11 | 12 | class FakeAccountRepository: AccountRepositoryProtocol { 13 | 14 | private var accounts: [Account] = [] 15 | 16 | func setAccounts(accounts: [Account]) { 17 | self.accounts = accounts 18 | } 19 | 20 | func isAccountExists(id: String) -> Bool { 21 | accounts.contains { 22 | $0.id == id 23 | } 24 | } 25 | 26 | func isAccountExists(forAddress address: String) -> Bool { 27 | accounts.contains { 28 | $0.address == address 29 | } 30 | } 31 | 32 | func getAll() -> [Account] { 33 | return accounts 34 | } 35 | 36 | func getAllActiveAccounts() -> [Account] { 37 | accounts.filter { 38 | !$0.isArchived 39 | } 40 | } 41 | 42 | func getAllArchivedAccounts() -> [Account] { 43 | accounts.filter { 44 | $0.isArchived 45 | } 46 | } 47 | 48 | func getAccount(fromId accountId: String) -> Account? { 49 | accounts.first { $0.id == accountId } 50 | } 51 | 52 | func create(account mtAccount: MTAccount, password: String, token: String) -> Account { 53 | let account = Account(context: TestPersistenceManager().mainContext) 54 | account.set(from: mtAccount, password: password, token: token) 55 | accounts.append(account) 56 | return account 57 | } 58 | 59 | func update(account: Account) { 60 | accounts = accounts.map { 61 | if $0.id == account.id { 62 | return account 63 | } 64 | 65 | return $0 66 | } 67 | } 68 | 69 | func delete(account: Account) { 70 | accounts = accounts.filter { $0.id != account.id } 71 | } 72 | 73 | func deleteAll() { 74 | accounts.removeAll() 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /TempBoxTests/Fakes/FakeAccountService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FakeAccountService.swift 3 | // TempBoxTests 4 | // 5 | // Created by Waseem Akram on 01/10/21. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | import MailTMSwift 11 | @testable import TempBox 12 | 13 | class FakeAccountService: AccountServiceProtocol { 14 | 15 | var activeAccounts: [Account] = [] { 16 | didSet { 17 | _activeAccountsPublisher.send(activeAccounts) 18 | } 19 | } 20 | 21 | var archivedAccounts: [Account] = [] { 22 | didSet { 23 | _archivedAccountsPublisher.send(archivedAccounts) 24 | } 25 | } 26 | 27 | var availableDomains: [MTDomain] = [] { 28 | didSet { 29 | _availableDomainsPublisher.send(availableDomains) 30 | } 31 | } 32 | 33 | var isDomainsLoading: Bool = false 34 | 35 | var activeAccountsPublisher: AnyPublisher<[Account], Never> { 36 | _activeAccountsPublisher.eraseToAnyPublisher() 37 | } 38 | 39 | var archivedAccountsPublisher: AnyPublisher<[Account], Never> { 40 | _archivedAccountsPublisher.eraseToAnyPublisher() 41 | } 42 | 43 | var availableDomainsPublisher: AnyPublisher<[MTDomain], Never> { 44 | _availableDomainsPublisher.eraseToAnyPublisher() 45 | } 46 | 47 | private var _activeAccountsPublisher = PassthroughSubject<[Account], Never>() 48 | private var _archivedAccountsPublisher = PassthroughSubject<[Account], Never>() 49 | private var _availableDomainsPublisher = PassthroughSubject<[MTDomain], Never>() 50 | 51 | func setIsDomainsLoading(value: Bool) { 52 | isDomainsLoading = value 53 | } 54 | 55 | func setAvailableDomains(domains: [MTDomain]) { 56 | self.availableDomains = domains 57 | } 58 | 59 | func setActiveAccounts(accounts: [Account]) { 60 | self.activeAccounts = accounts 61 | } 62 | 63 | func setArchivedAccounts(accounts: [Account]) { 64 | self.archivedAccounts = accounts 65 | } 66 | 67 | func archiveAccount(account: Account) { 68 | account.isArchived = true 69 | archivedAccounts.append(account) 70 | activeAccounts = activeAccounts.filter { $0.id != account.id } 71 | } 72 | 73 | func activateAccount(account: Account) { 74 | account.isArchived = false 75 | activeAccounts.append(account) 76 | archivedAccounts = archivedAccounts.filter { $0.id != account.id } 77 | } 78 | 79 | func removeAccount(account: Account) { 80 | activeAccounts = activeAccounts.filter { $0.id != account.id } 81 | archivedAccounts = archivedAccounts.filter { $0.id != account.id } 82 | } 83 | 84 | func deleteAndRemoveAccount(account: Account) -> AnyPublisher { 85 | removeAccount(account: account) 86 | return Future { _ in }.eraseToAnyPublisher() 87 | 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /TempBoxTests/Fakes/FakeMTAccountService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FakeMTAccountService.swift 3 | // TempBoxTests 4 | // 5 | // Created by Waseem Akram on 26/09/21. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | import MailTMSwift 11 | 12 | class FakeMTAccountService: MTAccountService { 13 | 14 | var accounts: [MTAccount] = [] 15 | var error: MTError? 16 | var forceError: Bool = false 17 | 18 | override func login(using auth: MTAuth, completion: @escaping (Result) -> Void) -> MTAPIServiceTaskProtocol { 19 | 20 | guard !forceError else { 21 | completion(.failure(error!)) 22 | return MockDataTask() 23 | } 24 | 25 | let account = accounts.first { $0.address == auth.address } 26 | if let account = account { 27 | completion(.success(account.id)) 28 | } else { 29 | guard let error = error else { 30 | fatalError("Error occured but error not passed") 31 | } 32 | completion(.failure(error)) 33 | } 34 | return MockDataTask() 35 | } 36 | 37 | override func createAccount(using auth: MTAuth, completion: @escaping (Result) -> Void) -> MTAPIServiceTaskProtocol { 38 | 39 | guard !forceError else { 40 | completion(.failure(error!)) 41 | return MockDataTask() 42 | } 43 | 44 | let accountExists = accounts.contains { $0.address == auth.address } 45 | if accountExists { 46 | guard let error = error else { 47 | fatalError("Error occured but error not passed") 48 | } 49 | completion(.failure(error)) 50 | } else { 51 | 52 | let account = MTAccount(id: UUID().uuidString, 53 | address: auth.address, 54 | quotaLimit: 100, 55 | quotaUsed: 0, 56 | isDisabled: false, 57 | isDeleted: false, 58 | createdAt: .init(), 59 | updatedAt: .init()) 60 | accounts.append(account) 61 | completion(.success(account)) 62 | 63 | } 64 | return MockDataTask() 65 | } 66 | 67 | override func getMyAccount(token: String, completion: @escaping (Result) -> Void) -> MTAPIServiceTaskProtocol { 68 | 69 | guard !forceError else { 70 | completion(.failure(error!)) 71 | return MockDataTask() 72 | } 73 | 74 | let account = accounts.first { $0.id == token } 75 | if let account = account { 76 | completion(.success(account)) 77 | } else { 78 | guard let error = error else { 79 | fatalError("Error occured but error not passed") 80 | } 81 | 82 | completion(.failure(error)) 83 | } 84 | return MockDataTask() 85 | } 86 | 87 | override func deleteAccount(id: String, 88 | token: String, 89 | completion: @escaping (Result) -> Void) -> MTAPIServiceTaskProtocol { 90 | 91 | guard !forceError else { 92 | completion(.failure(error!)) 93 | return MockDataTask() 94 | } 95 | 96 | if let error = error { 97 | completion(.failure(error)) 98 | return MockDataTask() 99 | } 100 | 101 | accounts = accounts.filter { $0.id != token } 102 | completion(.success(MTEmptyResult())) 103 | 104 | return MockDataTask() 105 | } 106 | 107 | override func login(using auth: MTAuth) -> AnyPublisher { 108 | guard !forceError else { 109 | return Future { promise in 110 | promise(.failure(self.error!)) 111 | } 112 | .eraseToAnyPublisher() 113 | } 114 | 115 | return Future { promise in 116 | let account = self.accounts.first { $0.address == auth.address } 117 | if let account = account { 118 | promise(.success(account.id)) 119 | } else { 120 | guard let error = self.error else { 121 | fatalError("Error occured but error not passed") 122 | } 123 | promise(.failure(error)) 124 | } 125 | } 126 | .eraseToAnyPublisher() 127 | } 128 | 129 | override func createAccount(using auth: MTAuth) -> AnyPublisher { 130 | 131 | guard !forceError else { 132 | return Future { promise in 133 | promise(.failure(self.error!)) 134 | } 135 | .eraseToAnyPublisher() 136 | } 137 | 138 | return Deferred { 139 | Future { promise in 140 | let accountExists = self.accounts.contains { $0.address == auth.address } 141 | if accountExists { 142 | guard let error = self.error else { 143 | fatalError("Error occured but error not passed") 144 | } 145 | promise(.failure(error)) 146 | } else { 147 | 148 | let account = MTAccount(id: UUID().uuidString, 149 | address: auth.address, 150 | quotaLimit: 100, 151 | quotaUsed: 0, 152 | isDisabled: false, 153 | isDeleted: false, 154 | createdAt: .init(), 155 | updatedAt: .init()) 156 | self.accounts.append(account) 157 | promise(.success(account)) 158 | 159 | } 160 | } 161 | } 162 | .eraseToAnyPublisher() 163 | 164 | } 165 | 166 | override func getMyAccount(token: String) -> AnyPublisher { 167 | guard !forceError else { 168 | return Future { promise in 169 | promise(.failure(self.error!)) 170 | } 171 | .eraseToAnyPublisher() 172 | } 173 | 174 | return Future { promise in 175 | let account = self.accounts.first { $0.id == token } 176 | if let account = account { 177 | promise(.success(account)) 178 | } else { 179 | guard let error = self.error else { 180 | fatalError("Error occured but error not passed") 181 | } 182 | 183 | promise(.failure(error)) 184 | } 185 | } 186 | .eraseToAnyPublisher() 187 | } 188 | 189 | override func deleteAccount(id: String, token: String) -> AnyPublisher { 190 | guard !forceError else { 191 | return Future { promise in 192 | promise(.failure(self.error!)) 193 | } 194 | .eraseToAnyPublisher() 195 | } 196 | 197 | return Future { promise in 198 | if let error = self.error { 199 | promise(.failure(error)) 200 | return 201 | } 202 | 203 | self.accounts = self.accounts.filter { $0.id != token } 204 | promise(.success(MTEmptyResult())) 205 | 206 | } 207 | .eraseToAnyPublisher() 208 | 209 | } 210 | 211 | } 212 | -------------------------------------------------------------------------------- /TempBoxTests/Fakes/FakeMTDomainService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StubMTDomainService.swift 3 | // TempBoxTests 4 | // 5 | // Created by Waseem Akram on 26/09/21. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | import MailTMSwift 11 | 12 | class FakeMTDomainService: MTDomainService { 13 | 14 | override func getDomain(id: String, completion: @escaping (Result) -> Void) -> MTAPIServiceTaskProtocol { 15 | completion(.success(getFakeDomain(id: id))) 16 | return MockDataTask() 17 | } 18 | 19 | override func getAllDomains(completion: @escaping (Result<[MTDomain], MTError>) -> Void) -> MTAPIServiceTaskProtocol { 20 | completion(.success([getFakeDomain()])) 21 | return MockDataTask() 22 | } 23 | 24 | override func getDomain(id: String) -> AnyPublisher { 25 | Future { promise in 26 | promise(.success(self.getFakeDomain(id: id))) 27 | } 28 | .eraseToAnyPublisher() 29 | 30 | } 31 | 32 | override func getAllDomains() -> AnyPublisher<[MTDomain], MTError> { 33 | Future { promise in 34 | promise( 35 | .success([self.getFakeDomain()]) 36 | ) 37 | } 38 | .eraseToAnyPublisher() 39 | } 40 | 41 | private func getFakeDomain(id: String = UUID().uuidString) -> MTDomain { 42 | return MTDomain(id: id, 43 | domain: "test@test.com", 44 | isActive: true, 45 | isPrivate: false, 46 | createdAt: .init(), 47 | updatedAt: .init()) 48 | 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /TempBoxTests/Fakes/FakeMTLiveMessagesService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FakeMTLiveMessagesService.swift 3 | // TempBoxTests 4 | // 5 | // Created by Waseem Akram on 30/09/21. 6 | // 7 | 8 | import Foundation 9 | import MailTMSwift 10 | import Combine 11 | @testable import TempBox 12 | 13 | class FakeMTLiveMessagesService: MTLiveMessageProtocol { 14 | 15 | var accountPublisher: AnyPublisher { 16 | _accountPublisher.eraseToAnyPublisher() 17 | } 18 | 19 | var messagePublisher: AnyPublisher { 20 | _messagePublisher.eraseToAnyPublisher() 21 | } 22 | 23 | var statePublisher: AnyPublisher { 24 | $state.eraseToAnyPublisher() 25 | } 26 | 27 | private var _messagePublisher = PassthroughSubject() 28 | private var _accountPublisher = PassthroughSubject() 29 | 30 | @Published 31 | var state: MTLiveMailService.State = .closed 32 | 33 | var isStarted = false 34 | 35 | func start() { 36 | isStarted = true 37 | state = .opened 38 | } 39 | 40 | func stop() { 41 | isStarted = false 42 | state = .closed 43 | } 44 | 45 | func restart() { 46 | start() 47 | } 48 | 49 | func emulate(message: MTMessage) { 50 | self._messagePublisher.send(message) 51 | } 52 | 53 | func emulate(account: MTAccount) { 54 | self._accountPublisher.send(account) 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /TempBoxTests/Mocks/MockAccountRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockAccountRepository.swift 3 | // TempBoxTests 4 | // 5 | // Created by Waseem Akram on 25/09/21. 6 | // 7 | 8 | import Foundation 9 | import XCTest 10 | import MailTMSwift 11 | @testable import TempBox 12 | 13 | class MockAccountRepository: AccountRepositoryProtocol { 14 | 15 | var getAllActiveAccountsCallCount = 0 16 | var givenGetAllActiveAccountsResult: [Account]! 17 | func getAllActiveAccounts() -> [Account] { 18 | getAllActiveAccountsCallCount += 1 19 | return givenGetAllActiveAccountsResult 20 | } 21 | 22 | var getAllArchivedAccountsCallCount = 0 23 | var givenGetAllArchivedAccountsResult: [Account]! 24 | func getAllArchivedAccounts() -> [Account] { 25 | getAllArchivedAccountsCallCount += 1 26 | return givenGetAllArchivedAccountsResult 27 | } 28 | 29 | var getAllCallCount = 0 30 | var givenGetAllResult: [Account]! 31 | func getAll() -> [Account] { 32 | getAllCallCount += 1 33 | return givenGetAllResult 34 | } 35 | 36 | var getAccountCallCount = 0 37 | var getAccountIdPassed: String? 38 | var givenGetAccountResult: Account? 39 | func getAccount(fromId accountId: String) -> Account? { 40 | getAccountCallCount += 1 41 | getAccountIdPassed = accountId 42 | return givenGetAccountResult 43 | } 44 | 45 | var createCallCount = 0 46 | var createMtAccountPassed: MTAccount? 47 | var createPasswordPassed: String? 48 | var createTokenPassed: String? 49 | var givenCreateResult: Account! 50 | func create(account mtAccount: MTAccount, password: String, token: String) -> Account { 51 | createCallCount += 1 52 | createMtAccountPassed = mtAccount 53 | createPasswordPassed = password 54 | createTokenPassed = token 55 | return givenCreateResult 56 | } 57 | 58 | var updateCallCount = 0 59 | var updateAccountPassed: Account? 60 | func update(account: Account) { 61 | updateCallCount += 1 62 | updateAccountPassed = account 63 | } 64 | 65 | var deleteCallCount = 0 66 | var deleteAccountPassed: Account? 67 | func delete(account: Account) { 68 | deleteCallCount += 1 69 | deleteAccountPassed = account 70 | } 71 | 72 | var deleteAllCallCount = 0 73 | func deleteAll() { 74 | deleteAllCallCount += 1 75 | } 76 | 77 | var isAccountForIdExistsCallCount = 0 78 | var isAccountForIdResult: Bool! 79 | func isAccountExists(id: String) -> Bool { 80 | isAccountForIdExistsCallCount += 1 81 | return isAccountForIdResult 82 | } 83 | 84 | var isAccountForAddressExistsCallCount = 0 85 | var isAccountForAddressResult: Bool! 86 | func isAccountExists(forAddress address: String) -> Bool { 87 | isAccountForAddressExistsCallCount += 1 88 | return isAccountForAddressResult 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /TempBoxTests/Mocks/MockAccountService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockAccountService.swift 3 | // TempBoxTests 4 | // 5 | // Created by Waseem Akram on 25/09/21. 6 | // 7 | 8 | import Foundation 9 | import Resolver 10 | import Combine 11 | import MailTMSwift 12 | @testable import TempBox 13 | 14 | class MockAccountService: AccountService { 15 | 16 | // override init(repository: AccountRepositoryProtocol = Resolver.resolve(), accountService: MTAccountService = Resolver.resolve(), domainService: MTDomainService = Resolver.resolve()) { 17 | // 18 | // } 19 | // 20 | // override func createAccount(using auth: MTAuth) -> AnyPublisher { 21 | // fatalError() 22 | // } 23 | } 24 | -------------------------------------------------------------------------------- /TempBoxTests/Mocks/MockDataTask.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockDataTask.swift 3 | // TempBoxTests 4 | // 5 | // Created by Waseem Akram on 22/09/21. 6 | // 7 | 8 | import Foundation 9 | import MailTMSwift 10 | 11 | class MockDataTask: MTAPIServiceTaskProtocol { 12 | 13 | var taskId: UUID 14 | 15 | init (taskId: UUID = .init()) { 16 | self.taskId = taskId 17 | } 18 | 19 | var cancelCallCount = 0 20 | func cancel() { 21 | cancelCallCount += 1 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /TempBoxTests/Mocks/MockMTAccountService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockMTAccountService.swift 3 | // TempBoxTests 4 | // 5 | // Created by Waseem Akram on 22/09/21. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | import MailTMSwift 11 | 12 | class MockMTAccountService: MTAccountService { 13 | 14 | var loginCallCount = 0 15 | var loginAuth: MTAuth? 16 | var givenLoginResult: Result! 17 | override func login(using auth: MTAuth, 18 | completion: @escaping (Result) -> Void) -> MTAPIServiceTaskProtocol { 19 | loginCallCount += 1 20 | self.loginAuth = auth 21 | completion(givenLoginResult) 22 | return MockDataTask() 23 | } 24 | 25 | var createAccountCallCount = 0 26 | var createAccountAuth: MTAuth? 27 | var givenCreateAccountResult: Result! 28 | override func createAccount(using auth: MTAuth, 29 | completion: @escaping (Result) -> Void) -> MTAPIServiceTaskProtocol { 30 | createAccountCallCount += 1 31 | self.createAccountAuth = auth 32 | completion(givenCreateAccountResult) 33 | return MockDataTask() 34 | } 35 | 36 | var getMyAccountCallCount = 0 37 | var getMyAccountToken: String? 38 | var givenGetMyAccountResult: Result! 39 | override func getMyAccount(token: String, 40 | completion: @escaping (Result) -> Void) -> MTAPIServiceTaskProtocol { 41 | getMyAccountCallCount += 1 42 | getMyAccountToken = token 43 | completion(givenGetMyAccountResult) 44 | return MockDataTask() 45 | } 46 | 47 | var deleteAccountCallCount = 0 48 | var deleteAccountToken: String? 49 | var deleteAccountResult: Result! 50 | override func deleteAccount(id: String, 51 | token: String, 52 | completion: @escaping (Result) -> Void) -> MTAPIServiceTaskProtocol { 53 | deleteAccountCallCount += 1 54 | deleteAccountToken = token 55 | completion(deleteAccountResult) 56 | return MockDataTask() 57 | } 58 | 59 | var loginPublisherCallCount = 0 60 | var loginPublisherAuth: MTAuth? 61 | var givenLoginPublisherResult: Result! 62 | override func login(using auth: MTAuth) -> AnyPublisher { 63 | loginPublisherCallCount += 1 64 | loginPublisherAuth = auth 65 | return Future { promise in 66 | promise(self.givenLoginPublisherResult) 67 | } 68 | .eraseToAnyPublisher() 69 | } 70 | 71 | var createAccountPublisherCallCount = 0 72 | var createAccountPublisherAuth: MTAuth? 73 | var givenCreateAccountPublisherResult: Result! 74 | override func createAccount(using auth: MTAuth) -> AnyPublisher { 75 | createAccountPublisherCallCount += 1 76 | createAccountPublisherAuth = auth 77 | return Future { promise in 78 | promise(self.givenCreateAccountPublisherResult) 79 | } 80 | .eraseToAnyPublisher() 81 | } 82 | 83 | override func getMyAccount(token: String) -> AnyPublisher { 84 | fatalError() 85 | } 86 | 87 | override func deleteAccount(id: String, token: String) -> AnyPublisher { 88 | fatalError() 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /TempBoxTests/Mocks/MockMTDomainService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockMTDomainService.swift 3 | // TempBoxTests 4 | // 5 | // Created by Waseem Akram on 22/09/21. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | import MailTMSwift 11 | 12 | class MockMTDomainService: MTDomainService { 13 | 14 | var getAllDomainsCallCount = 0 15 | var givenGetAllDomainsResult: Result<[MTDomain], MTError>! 16 | override func getAllDomains(completion: @escaping (Result<[MTDomain], MTError>) -> Void) -> MTAPIServiceTaskProtocol { 17 | getAllDomainsCallCount += 1 18 | completion(givenGetAllDomainsResult) 19 | return MockDataTask() 20 | } 21 | 22 | var getDomainCallCount = 0 23 | var getDomainId: String? 24 | var givenGetDomainResult: Result! 25 | override func getDomain(id: String, completion: @escaping (Result) -> Void) -> MTAPIServiceTaskProtocol { 26 | getDomainCallCount += 1 27 | getDomainId = id 28 | completion(givenGetDomainResult) 29 | return MockDataTask() 30 | } 31 | 32 | var getAllDomainsPublisherCallCount = 0 33 | var givenGetAllDomainsPubliserResult: Result<[MTDomain], MTError>! 34 | override func getAllDomains() -> AnyPublisher<[MTDomain], MTError> { 35 | getAllDomainsPublisherCallCount += 1 36 | return Future { promise in 37 | promise(self.givenGetAllDomainsPubliserResult) 38 | } 39 | .eraseToAnyPublisher() 40 | 41 | } 42 | 43 | var getDomainPublisherCallCount = 0 44 | var getDomainPublisherId: String? 45 | var givenGetDomainPublisherResult: Result! 46 | override func getDomain(id: String) -> AnyPublisher { 47 | getDomainPublisherCallCount += 1 48 | getDomainPublisherId = id 49 | return Future { promise in 50 | promise(self.givenGetDomainPublisherResult) 51 | } 52 | .eraseToAnyPublisher() 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /TempBoxTests/Persistence/TestPersistenceManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestPersistence.swift 3 | // TempBoxTests 4 | // 5 | // Created by Waseem Akram on 22/09/21. 6 | // 7 | 8 | import Foundation 9 | import CoreData 10 | @testable import TempBox 11 | 12 | class TestPersistenceManager: PersistenceManager { 13 | override init() { 14 | super.init() 15 | let container = NSPersistentContainer(name: Self.modelName, managedObjectModel: Self.model) 16 | // Prevent saving it to the file store, In effect, we can work with objects in memory 17 | container.persistentStoreDescriptions[0].url = URL(fileURLWithPath: "/dev/null") 18 | container.loadPersistentStores { _, error in 19 | if let error = error as NSError? { 20 | fatalError("Unresolved error \(error), \(error.userInfo)") 21 | } 22 | } 23 | self.storeContainer = container 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /macOS/App+Injection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // App+Injection.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 22/09/21. 6 | // 7 | 8 | import Resolver 9 | import MailTMSwift 10 | 11 | extension Resolver: ResolverRegistering { 12 | 13 | public static func registerAllServices() { 14 | defaultScope = .application 15 | registerPersistence() 16 | registerServices() 17 | registerRepositories() 18 | registerMailTMClasses() 19 | } 20 | 21 | static func registerMailTMClasses() { 22 | register { 23 | MTDomainService() 24 | } 25 | .scope(.graph) 26 | 27 | register { 28 | MTAccountService() 29 | } 30 | .scope(.graph) 31 | 32 | register { 33 | MTMessageService() 34 | } 35 | .scope(.graph) 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /macOS/AppConfig.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppConfig.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 21/10/21. 6 | // 7 | 8 | enum AppConfig { 9 | static let maxAccountsAllowed = 30 10 | static let maxActiveAccountsAllowed = 6 11 | } 12 | -------------------------------------------------------------------------------- /macOS/AppController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppController.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 16/09/21. 6 | // 7 | 8 | import Foundation 9 | import MailTMSwift 10 | import Resolver 11 | import Combine 12 | import OSLog 13 | import AppKit 14 | import UserNotifications 15 | 16 | class AppController: ObservableObject { 17 | static private let logger = Logger(subsystem: Logger.subsystem, category: String(describing: AppController.self)) 18 | @Published var filterNotSeen = false 19 | 20 | @Published private(set) var activeAccounts: [Account] = [] 21 | @Published private(set) var archivedAccounts: [Account] = [] 22 | @Published private(set) var accountMessages: [Account: MessageStore] = [:] 23 | @Published private var accountStatus: [Account: MTLiveMailService.State] = [:] 24 | 25 | @Published var selectedAccount: Account? 26 | @Published var selectedMessage: Message? { 27 | didSet { 28 | guard 29 | let selectedAccount = selectedAccount, 30 | let selectedMessage = selectedMessage 31 | else { 32 | return 33 | } 34 | 35 | markMessageAsSeen(message: selectedMessage, for: selectedAccount) 36 | fetchCompleteMessageAndUpdate(message: selectedMessage, for: selectedAccount) 37 | } 38 | } 39 | 40 | var selectedAccountMessages: [Message] { 41 | 42 | if let selectedAccount = selectedAccount, 43 | let exisitingMessageStore = accountMessages[selectedAccount] { 44 | let messages: [Message] 45 | if filterNotSeen { 46 | messages = exisitingMessageStore.messages.filter { !$0.data.seen } 47 | } else { 48 | messages = exisitingMessageStore.messages 49 | } 50 | return messages.sorted { 51 | $0.data.createdAt > $1.data.createdAt 52 | } 53 | } 54 | 55 | return [] 56 | } 57 | 58 | var selectedAccountConnectionIsActive: Bool { 59 | guard let selectedAccount = selectedAccount else { 60 | return false 61 | } 62 | 63 | return accountStatus[selectedAccount, default: .closed] == .opened 64 | } 65 | 66 | var canActivateAccounts: Bool { 67 | activeAccounts.count < AppConfig.maxActiveAccountsAllowed 68 | } 69 | 70 | var mtMessageService: MTMessageService 71 | var accountService: AccountServiceProtocol 72 | var messageListenerService: MessagesListenerService 73 | var subscriptions = Set() 74 | 75 | @Published var alertData: SimpleAlertData? 76 | 77 | init( 78 | accountService: AccountServiceProtocol = Resolver.resolve(), 79 | messageService: MTMessageService = Resolver.resolve(), 80 | messageListenerService: MessagesListenerService = Resolver.resolve() 81 | ) { 82 | self.accountService = accountService 83 | self.mtMessageService = messageService 84 | self.messageListenerService = messageListenerService 85 | 86 | listenForAccountEvents() 87 | listenForMessageEvents() 88 | listenForActivateNotifications() 89 | } 90 | 91 | private func listenForAccountEvents() { 92 | accountService 93 | .activeAccountsPublisher 94 | .sink { [weak self] accounts in 95 | guard let self = self else { return } 96 | let difference = accounts.difference(from: self.activeAccounts) 97 | difference.insertions.forEach { change in 98 | if case let .insert(offset: _, element: insertedAccount, associatedWith: nil) = change { 99 | self.onAccountAddedToActiveAccounts(account: insertedAccount) 100 | } 101 | } 102 | difference.removals.forEach { change in 103 | if case let .remove(offset: _, element: removedAccount, associatedWith: nil) = change { 104 | self.onAccountDeletedFromActiveAccounts(account: removedAccount) 105 | } 106 | } 107 | self.activeAccounts = accounts 108 | } 109 | .store(in: &subscriptions) 110 | 111 | accountService 112 | .archivedAccountsPublisher 113 | .assign(to: \.archivedAccounts, on: self) 114 | .store(in: &subscriptions) 115 | } 116 | 117 | private func listenForMessageEvents() { 118 | messageListenerService 119 | .onMessageReceivedPublisher 120 | .sink { [weak self] messageReceived in 121 | guard let self = self else { return } 122 | self.upsertMessage(message: Message(data: messageReceived.message), 123 | for: messageReceived.account) 124 | } 125 | .store(in: &subscriptions) 126 | 127 | messageListenerService 128 | .onMessageDeletedPublisher 129 | .sink { [weak self] messageReceived in 130 | guard let self = self else { return } 131 | if self.accountMessages[messageReceived.account] != nil { 132 | self.accountMessages[messageReceived.account]?.messages.removeAll(where: { 133 | $0.data.id == messageReceived.message.id 134 | }) 135 | } 136 | } 137 | .store(in: &subscriptions) 138 | 139 | messageListenerService 140 | .$channelsStatus 141 | .assign(to: \.accountStatus, on: self) 142 | .store(in: &subscriptions) 143 | } 144 | 145 | private func upsertMessage(message: Message, for account: Account) { 146 | if let messages = self.accountMessages[account]?.messages { 147 | var updatedMessages = messages 148 | if let index = messages.firstIndex(of: message) { 149 | let oldIntro = updatedMessages[index].data.intro 150 | var updatedMessage = message 151 | updatedMessage.data.intro = oldIntro ?? message.data.intro ?? "" // preserve old intro. 152 | updatedMessages[index] = updatedMessage 153 | } else { 154 | updatedMessages.append(message) 155 | self.triggerNotificationForReceivedMessage(message: message, for: account) 156 | } 157 | self.accountMessages[account]?.messages = updatedMessages 158 | if let selectedMessage = selectedMessage, selectedMessage.id == message.id { 159 | self.selectedMessage = message 160 | } 161 | } 162 | } 163 | 164 | private func fetchInitialMessagesAndSave(forAccount account: Account) { 165 | accountMessages[account]?.isFetching = true 166 | mtMessageService.getAllMessages(token: account.token) 167 | .sink { [weak self] completion in 168 | guard let self = self else { return } 169 | if case let .failure(error) = completion { 170 | self.accountMessages[account] = MessageStore(isFetching: false, error: error, messages: []) 171 | } 172 | } receiveValue: { [weak self] messages in 173 | guard let self = self else { return } 174 | let messages = messages.map { 175 | Message(data: $0) 176 | } 177 | self.accountMessages[account] = MessageStore(isFetching: false, error: nil, messages: messages) 178 | } 179 | .store(in: &subscriptions) 180 | } 181 | 182 | func markMessageAsSeen(message: Message, for account: Account) { 183 | guard 184 | let message = self.accountMessages[account]?.messages.first(where: { $0.data.id == message.data.id }), 185 | !message.data.seen 186 | else { 187 | return 188 | } 189 | mtMessageService.markMessageAs(id: message.data.id, seen: true, token: account.token) 190 | .sink { completion in 191 | if case let .failure(error) = completion { 192 | Self.logger.error("\(#function) \(#line): \(error.localizedDescription)") 193 | } 194 | } receiveValue: { [weak self] updatedMessage in 195 | guard let self = self else { return } 196 | self.upsertMessage(message: Message(data: updatedMessage), for: account) 197 | } 198 | .store(in: &subscriptions) 199 | 200 | } 201 | 202 | private func fetchCompleteMessageAndUpdate(message: Message, for account: Account) { 203 | guard 204 | let message = self.accountMessages[account]?.messages.first(where: { $0.data.id == message.data.id }), 205 | !message.isComplete 206 | else { 207 | return 208 | } 209 | let token = account.token 210 | let messageId = message.data.id 211 | mtMessageService.getMessage(id: messageId, token: token) 212 | .sink { completion in 213 | if case let .failure(error) = completion { 214 | Self.logger.error("\(#function) \(#line): \(error.localizedDescription)") 215 | } 216 | } receiveValue: { [weak self] completeMessage in 217 | guard let self = self else { return } 218 | self.upsertMessage(message: Message(isComplete: true, 219 | data: completeMessage), 220 | for: account) 221 | } 222 | .store(in: &subscriptions) 223 | } 224 | 225 | private func onAccountAddedToActiveAccounts(account: Account) { 226 | accountMessages[account] = MessageStore(isFetching: true, error: nil, messages: []) 227 | fetchInitialMessagesAndSave(forAccount: account) 228 | } 229 | 230 | private func onAccountDeletedFromActiveAccounts(account: Account) { 231 | if accountMessages[account] != nil { 232 | accountMessages.removeValue(forKey: account) 233 | } 234 | } 235 | 236 | func refreshAccount(account: Account) { 237 | accountService.refreshAccount(with: account) 238 | .receive(on: RunLoop.main) 239 | .sink(receiveCompletion: { [weak self] completion in 240 | if case let .failure(error) = completion { 241 | Self.logger.error("\(#function) \(#line): \(error.localizedDescription)") 242 | switch error { 243 | case .mtError(let apiError): 244 | self?.alertData = .init(title: apiError, message: nil) 245 | default: 246 | break 247 | } 248 | } 249 | }, receiveValue: { [weak self] success in 250 | if success { 251 | guard let selectedAccount = self?.selectedAccount else { return } 252 | self?.messageListenerService.stopListeningAndRemoveChannel(account: selectedAccount) 253 | self?.messageListenerService.addChannelAndStartListening(account: selectedAccount) 254 | self?.fetchInitialMessagesAndSave(forAccount: selectedAccount) 255 | } 256 | }) 257 | .store(in: &subscriptions) 258 | } 259 | 260 | func archiveAccount(account: Account) { 261 | accountService.archiveAccount(account: account) 262 | } 263 | 264 | func activateAccount(account: Account) { 265 | if canActivateAccounts { 266 | accountService.activateAccount(account: account) 267 | } else { 268 | alertData = .init(title: "Max Active Account limit reached", 269 | message: "You cannot activate more than \(AppConfig.maxActiveAccountsAllowed) accounts") 270 | } 271 | } 272 | 273 | func removeAccount(account: Account) { 274 | accountService.removeAccount(account: account) 275 | } 276 | 277 | func deleteAccount(account: Account) { 278 | accountService.deleteAndRemoveAccount(account: account) 279 | .sink { [weak self] completion in 280 | guard let self = self else { return } 281 | self.alertData = nil 282 | if case let .failure(error) = completion { 283 | Self.logger.error("\(#function) \(#line): \(error.localizedDescription)") 284 | switch error { 285 | case .mtError(let apiError): 286 | self.alertData = .init(title: apiError, message: nil) 287 | default: 288 | break 289 | } 290 | } 291 | } receiveValue: { _ in 292 | // Deleted successfully 293 | } 294 | .store(in: &subscriptions) 295 | } 296 | 297 | func deleteMessage(message: Message, for account: Account) { 298 | 299 | // remove the message first then delete it 300 | if let messages = self.accountMessages[account]?.messages, let index = messages.firstIndex(of: message) { 301 | self.accountMessages[account]?.messages.remove(at: index) 302 | } else { 303 | return 304 | } 305 | 306 | if self.selectedMessage == message { 307 | self.selectedMessage = nil 308 | } 309 | 310 | mtMessageService.deleteMessage(id: message.id, token: account.token) 311 | .sink { completion in 312 | if case let .failure(error) = completion { 313 | Self.logger.error("\(#function) \(#line): \(error.localizedDescription)") 314 | } 315 | } receiveValue: { _ in 316 | } 317 | .store(in: &subscriptions) 318 | } 319 | 320 | private func listenForActivateNotifications() { 321 | NotificationCenter.default 322 | .publisher(for: .activateAccountAndMessage, object: nil) 323 | .sink { [weak self] notification in 324 | guard 325 | let self = self, 326 | let userInfo = notification.userInfo, 327 | let accountId = userInfo["account"] as? String, 328 | let messageId = userInfo["message"] as? String, 329 | let account = self.activeAccounts.first(where: { $0.id == accountId }), 330 | let message = self.accountMessages[account]?.messages.first(where: { $0.id == messageId }) 331 | else { return } 332 | 333 | self.selectedAccount = account 334 | self.selectedMessage = message 335 | } 336 | .store(in: &subscriptions) 337 | } 338 | 339 | func triggerNotificationForReceivedMessage(message: Message, for account: Account) { 340 | let center = UNUserNotificationCenter.current() 341 | let content = UNMutableNotificationContent() 342 | 343 | let sender: String 344 | if message.data.from.name.trimmingCharacters(in: .whitespaces) != "" { 345 | sender = message.data.from.name 346 | } else { 347 | sender = message.data.from.address 348 | } 349 | 350 | content.title = sender 351 | content.subtitle = message.data.subject 352 | content.body = message.data.textExcerpt 353 | content.sound = .default 354 | content.categoryIdentifier = LocalNotificationKeys.Category.activateMessage 355 | content.userInfo = ["account": account.id, "message": message.id] 356 | 357 | let openAction = UNNotificationAction(identifier: "Open", title: "Open", options: .foreground) 358 | let category = UNNotificationCategory(identifier: LocalNotificationKeys.Identifiers.message, 359 | actions: [openAction], 360 | intentIdentifiers: []) 361 | 362 | let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) 363 | center.setNotificationCategories([category]) 364 | center.add(request) { error in 365 | if let error = error { 366 | Self.logger.error("\(#function) \(#line): Message Notification: \(error.localizedDescription)") 367 | } 368 | } 369 | } 370 | 371 | } 372 | -------------------------------------------------------------------------------- /macOS/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 10/10/21. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import Resolver 11 | import OSLog 12 | import UserNotifications 13 | 14 | final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate { 15 | 16 | var window: NSWindow? 17 | @Injected var persistenceManager: PersistenceManager 18 | 19 | func applicationDidFinishLaunching(_ notification: Notification) { 20 | registerNotifications() 21 | NSWindow.allowsAutomaticWindowTabbing = false 22 | if let mainMenu = NSApp.mainMenu { 23 | DispatchQueue.main.async { 24 | if let edit = mainMenu.items.first(where: { $0.title == "Edit"}) { 25 | mainMenu.removeItem(edit) 26 | } 27 | } 28 | } 29 | 30 | self.window = NSApplication.shared.windows.first 31 | 32 | } 33 | 34 | func registerNotifications() { 35 | UNUserNotificationCenter.current().requestAuthorization( 36 | options: [.alert, .sound, .badge] 37 | ) { accepted, error in 38 | if let error = error { 39 | Logger.notifications.error("\(#fileID) \(#function) \(#line): \(error.localizedDescription)") 40 | return 41 | } 42 | if !accepted { 43 | Logger.notifications.info("Notification access denied.") 44 | } 45 | } 46 | UNUserNotificationCenter.current().delegate = self 47 | } 48 | 49 | func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { 50 | true 51 | } 52 | 53 | func applicationWillTerminate(_ notification: Notification) { 54 | persistenceManager.saveMainContext() 55 | } 56 | 57 | func userNotificationCenter(_ center: UNUserNotificationCenter, 58 | willPresent notification: UNNotification, 59 | withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { 60 | 61 | completionHandler([.banner, .list]) 62 | } 63 | 64 | func userNotificationCenter(_ center: UNUserNotificationCenter, 65 | didReceive response: UNNotificationResponse, 66 | withCompletionHandler completionHandler: @escaping () -> Void) { 67 | let categoryIdentifier = response.notification.request.content.categoryIdentifier 68 | switch categoryIdentifier { 69 | case LocalNotificationKeys.Category.openFileFromLocation: 70 | let userInfo = response.notification.request.content.userInfo 71 | 72 | if let fileLocation = userInfo["location"] as? String, let fileUrl = URL(string: fileLocation) { 73 | NSWorkspace.shared.open(fileUrl) 74 | } 75 | case LocalNotificationKeys.Category.activateMessage: 76 | let userInfo = response.notification.request.content.userInfo 77 | NotificationCenter.default.post(name: .activateAccountAndMessage, object: nil, userInfo: userInfo) 78 | NSApplication.shared.activate(ignoringOtherApps: true) 79 | window?.deminiaturize(nil) 80 | default: break 81 | } 82 | completionHandler() 83 | } 84 | 85 | } 86 | -------------------------------------------------------------------------------- /macOS/Extensions/Logger+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Logger.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 26/09/21. 6 | // 7 | 8 | import Foundation 9 | import OSLog 10 | 11 | extension Logger { 12 | static var subsystem = Bundle.main.bundleIdentifier! 13 | 14 | static let notifications = Logger(subsystem: subsystem, category: "Notifications") 15 | static let persistence = Logger(subsystem: subsystem, category: "Persistence") 16 | static let fileDownloadManager = Logger(subsystem: subsystem, category: String(describing: FileDownloadManager.self)) 17 | 18 | enum Services { 19 | static let accountService = Logger(subsystem: subsystem, category: String(describing: AccountService.self)) 20 | } 21 | 22 | enum Repositories { 23 | static let account = Logger(subsystem: subsystem, category: String(describing: AccountRepository.self)) 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /macOS/Extensions/MTAccount+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MTAccount+Extensions.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 22/09/21. 6 | // 7 | 8 | import Foundation 9 | import MailTMSwift 10 | 11 | extension MTAccount { 12 | init(from account: Account) { 13 | self = MTAccount(id: account.id, 14 | address: account.address, 15 | quotaLimit: Int(account.quotaLimit), 16 | quotaUsed: Int(account.quotaUsed), 17 | isDisabled: account.isDisabled, 18 | isDeleted: false, 19 | createdAt: account.createdAt, 20 | updatedAt: account.updatedAt) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /macOS/Extensions/MTLiveMessagesService+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MTLiveMessagesService+Extensions.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 01/10/21. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | import MailTMSwift 11 | 12 | protocol MTLiveMessageProtocol { 13 | 14 | var messagePublisher: AnyPublisher { get } 15 | var accountPublisher: AnyPublisher { get } 16 | var statePublisher: AnyPublisher { get } 17 | func start() 18 | func stop() 19 | func restart() 20 | 21 | } 22 | 23 | extension MTLiveMailService: MTLiveMessageProtocol { 24 | 25 | } 26 | -------------------------------------------------------------------------------- /macOS/Extensions/MTMessage+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MTMessage+Extensions.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 02/10/21. 6 | // 7 | 8 | import Foundation 9 | import MailTMSwift 10 | 11 | extension MTMessage { 12 | 13 | var textExcerpt: String { 14 | let maxCharCount = 600 15 | if let intro = intro { 16 | return intro 17 | } 18 | guard let text = text else { 19 | return "" 20 | } 21 | let start = text.startIndex 22 | let end = text.index(start, offsetBy: min(text.count, maxCharCount)) 23 | return String(text[start.. String { 13 | 14 | var allowedChars = "abcdefghijklmnopqrstuvwxyz0123456789" 15 | 16 | if allowsUpperCaseCharacters { 17 | allowedChars += "ABCDEFGHIJKLMNOPQRSTUVWXYZ" 18 | } 19 | 20 | if allowsSpecialCharacters { 21 | allowedChars += "$@#!&()[]" 22 | } 23 | let allowedCharsCount = UInt32(allowedChars.count) 24 | var randomString = "" 25 | 26 | for _ in 0 ..< length { 27 | let randomNum = Int(arc4random_uniform(allowedCharsCount)) 28 | let randomIndex = allowedChars.index(allowedChars.startIndex, offsetBy: randomNum) 29 | let newCharacter = allowedChars[randomIndex] 30 | randomString += String(newCharacter) 31 | } 32 | 33 | return randomString 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /macOS/Features/AddAccount/AddAccountView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AddAccountView.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 16/09/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct AddAccountView: View { 11 | 12 | @State var isPickerWindowOpen = false 13 | @StateObject private var controller = AddAccountViewController() 14 | 15 | var body: some View { 16 | Button(action: controller.openAddAccountWindow, label: { 17 | VStack(alignment: .leading) { 18 | HStack { 19 | Text("New Address") 20 | .padding(.leading, 4) 21 | .lineLimit(1) 22 | Spacer() 23 | Image(systemName: "plus.circle.fill") 24 | } 25 | .padding() 26 | .frame(maxWidth: .infinity) 27 | .background(Color.secondary.opacity(0.2)) 28 | .cornerRadius(6) 29 | } 30 | }) 31 | .buttonStyle(PlainButtonStyle()) 32 | .keyboardShortcut(.init("a", modifiers: [.command])) 33 | .sheet(isPresented: $controller.isAddAccountWindowOpen) { 34 | AddAccountWindow(controller: controller) 35 | } 36 | .alert(item: $controller.alertMessage) { alertData in 37 | var messageText: Text? 38 | if let message = alertData.message { 39 | messageText = Text(message) 40 | } 41 | return Alert(title: Text(alertData.title), message: messageText, dismissButton: .default(Text("OK"), action: { 42 | controller.alertMessage = nil 43 | })) 44 | } 45 | } 46 | } 47 | 48 | struct AddAccountButton_Previews: PreviewProvider { 49 | static var previews: some View { 50 | AddAccountView() 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /macOS/Features/AddAccount/AddAccountViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AddAccountViewController.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 22/09/21. 6 | // 7 | 8 | import Foundation 9 | import Resolver 10 | import Combine 11 | import AppKit 12 | import MailTMSwift 13 | import OSLog 14 | 15 | class AddAccountViewController: ObservableObject { 16 | static let logger = Logger(subsystem: Logger.subsystem, category: String(describing: AddAccountViewController.self)) 17 | @Published var isAddAccountWindowOpen = false 18 | 19 | private var accountService: AccountService 20 | 21 | // MARK: Error properties 22 | @Published var alertMessage: SimpleAlertData? 23 | 24 | // MARK: Domain properties 25 | var availableDomains: [String] = [] 26 | @Published var selectedDomain = "" 27 | @Published var isDomainsLoading = true 28 | 29 | // MARK: Address properties 30 | @Published var addressText = "" { 31 | didSet { 32 | if addressText != oldValue { 33 | addressText = addressText.lowercased() 34 | } 35 | } 36 | } 37 | 38 | // MARK: Password properties 39 | @Published var passwordText = "" 40 | @Published var shouldGenerateRandomPassword = true 41 | 42 | // MARK: Create Account properties 43 | @Published var isCreatingAccount = false 44 | var subscriptions: Set = [] 45 | 46 | var isPasswordValid: Bool { 47 | (passwordText != "" && passwordText.count >= 6) || shouldGenerateRandomPassword 48 | } 49 | 50 | var canCreate: Bool { 51 | !isDomainsLoading 52 | && selectedDomain != "" 53 | && addressText.count >= 5 54 | && isPasswordValid 55 | } 56 | 57 | // MARK: - Methods 58 | 59 | init(accountService: AccountService = Resolver.resolve()) { 60 | self.accountService = accountService 61 | 62 | self.accountService.isDomainsLoadingPublisher 63 | .assign(to: \.isDomainsLoading, on: self) 64 | .store(in: &subscriptions) 65 | 66 | self.accountService.availableDomainsPublisher 67 | .map { 68 | $0.map(\.domain) 69 | } 70 | .handleEvents(receiveOutput: { [weak self] domains in 71 | guard let self = self else { return } 72 | self.selectedDomain = domains.first ?? "" 73 | }) 74 | .assign(to: \.availableDomains, on: self) 75 | .store(in: &subscriptions) 76 | 77 | NotificationCenter.default.publisher(for: .newAddress) 78 | .sink { [weak self] _ in 79 | guard let self = self else { return } 80 | self.isAddAccountWindowOpen = true 81 | } 82 | .store(in: &subscriptions) 83 | } 84 | 85 | func openAddAccountWindow() { 86 | if accountService.totalAccountsCount < AppConfig.maxAccountsAllowed { 87 | isAddAccountWindowOpen = true 88 | } else { 89 | alertMessage = .init(title: "Max Account limit reached", message: "You cannot create more than \(AppConfig.maxAccountsAllowed) accounts") 90 | } 91 | } 92 | 93 | func closeAddAccountWindow() { 94 | isAddAccountWindowOpen = false 95 | alertMessage = nil 96 | addressText = "" 97 | passwordText = "" 98 | NSApp.mainWindow?.endSheet(NSApp.keyWindow!) 99 | } 100 | 101 | func generateRandomAddress() { 102 | addressText = String.random(length: 10, allowsUpperCaseCharacters: false) 103 | } 104 | 105 | func createNewAddress() { 106 | guard canCreate else { 107 | return 108 | } 109 | 110 | let address = addressText + "@" + selectedDomain 111 | let password: String 112 | if shouldGenerateRandomPassword { 113 | password = String.random(length: 12, allowsSpecialCharacters: true) 114 | } else { 115 | password = passwordText 116 | } 117 | 118 | let auth = MTAuth(address: address, password: password) 119 | isCreatingAccount = true 120 | self.accountService.createAccount(using: auth) 121 | .sink { [weak self] completion in 122 | guard let self = self else { return } 123 | self.isCreatingAccount = false 124 | if case .failure(let error) = completion { 125 | Self.logger.error("\(#function) \(#line): \(error.localizedDescription)") 126 | switch error { 127 | case MTError.mtError(let errorStr): 128 | if errorStr.contains("already used") 129 | || errorStr.contains("not valid") { 130 | self.alertMessage = "This address already exists! Please choose a different address" 131 | } else { 132 | self.alertMessage = .init(title: errorStr, message: nil) 133 | } 134 | default: 135 | self.alertMessage = "Something went wrong while creating a new address" 136 | } 137 | } 138 | } receiveValue: { [weak self] _ in 139 | guard let self = self else { return } 140 | self.closeAddAccountWindow() 141 | } 142 | .store(in: &subscriptions) 143 | 144 | } 145 | 146 | } 147 | -------------------------------------------------------------------------------- /macOS/Features/AddAccount/AddAccountWindow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AddAccountWindow.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 16/09/21. 6 | // 7 | 8 | import SwiftUI 9 | import MailTMSwift 10 | 11 | struct AddAccountWindow: View { 12 | 13 | @ObservedObject var controller: AddAccountViewController 14 | 15 | var body: some View { 16 | VStack { 17 | VStack(alignment: .leading) { 18 | Text("New Address") 19 | .font(.title) 20 | .padding(.bottom) 21 | HStack { 22 | TextField("Address", text: $controller.addressText) 23 | .textFieldStyle(RoundedBorderTextFieldStyle()) 24 | DomainView(isLoading: controller.isDomainsLoading, 25 | availableDomains: controller.availableDomains, 26 | selectedDomain: $controller.selectedDomain) 27 | 28 | } 29 | .disabled(controller.isCreatingAccount) 30 | 31 | PasswordView(randomPassword: $controller.shouldGenerateRandomPassword, 32 | password: $controller.passwordText) 33 | .padding(.top) 34 | .disabled(controller.isCreatingAccount) 35 | } 36 | .padding() 37 | HStack { 38 | Button(action: { 39 | controller.closeAddAccountWindow() 40 | }, label: { 41 | Text("Cancel") 42 | }) 43 | .keyboardShortcut(.cancelAction) 44 | .disabled(controller.isCreatingAccount) 45 | Spacer() 46 | Button(action: { 47 | controller.generateRandomAddress() 48 | }, label: { 49 | Text("Random address") 50 | }) 51 | .disabled(controller.isCreatingAccount) 52 | Button(action: { 53 | controller.createNewAddress() 54 | }, label: { 55 | if controller.isCreatingAccount { 56 | ProgressView() 57 | .controlSize(.small) 58 | } else { 59 | Text("Create") 60 | } 61 | }) 62 | .disabled(!controller.canCreate) 63 | .keyboardShortcut(.defaultAction) 64 | 65 | } 66 | .padding() 67 | } 68 | .padding() 69 | .frame(width: 600) 70 | 71 | } 72 | 73 | } 74 | 75 | private struct DomainView: View { 76 | 77 | var isLoading: Bool 78 | var availableDomains: [String] 79 | @Binding var selectedDomain: String 80 | 81 | var body: some View { 82 | HStack { 83 | Text("@") 84 | if isLoading { 85 | loadingView 86 | } else { 87 | Picker(selection: $selectedDomain, label: EmptyView(), content: { 88 | ForEach(availableDomains, id: \.self) { domain in 89 | Text(domain) 90 | .tag(domain) 91 | } 92 | }) 93 | } 94 | } 95 | } 96 | 97 | var loadingView: some View { 98 | HStack { 99 | Spacer() 100 | ProgressView() 101 | .controlSize(.small) 102 | Spacer() 103 | } 104 | .padding(.top) 105 | } 106 | 107 | } 108 | 109 | private struct PasswordView: View { 110 | 111 | @Binding var randomPassword: Bool 112 | @Binding var password: String 113 | 114 | var body: some View { 115 | if !randomPassword { 116 | SecureField("Password", text: $password) 117 | .textFieldStyle(RoundedBorderTextFieldStyle()) 118 | } 119 | Toggle("Generate random password", isOn: $randomPassword) 120 | HStack(alignment: .firstTextBaseline) { 121 | Image(systemName: "exclamationmark.triangle.fill") 122 | .renderingMode(.original) 123 | Text("The password once set cannot be reset or changed.") 124 | } 125 | } 126 | 127 | } 128 | 129 | struct AddAccountWindow_Previews: PreviewProvider { 130 | static var previews: some View { 131 | AddAccountWindow(controller: .init()) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /macOS/Features/Inbox/InboxCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InboxCell.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 16/09/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct InboxCell: View { 11 | 12 | var username: String 13 | var subject: String 14 | var excerpt: String 15 | var isSeen: Bool 16 | var date: Date 17 | 18 | var dateString: String { 19 | 20 | if Calendar.current.isDateInToday(date) { 21 | let formatter = RelativeDateTimeFormatter() 22 | formatter.unitsStyle = .full 23 | return formatter.localizedString(for: date, relativeTo: Date()) 24 | } else if Calendar.current.isDateInYesterday(date) { 25 | return "Yesterday" 26 | } else { 27 | let formatter = DateFormatter() 28 | formatter.dateStyle = .short 29 | return formatter.string(from: date) 30 | } 31 | } 32 | 33 | var body: some View { 34 | VStack(alignment: .leading, spacing: 6.0) { 35 | HStack { 36 | Circle() 37 | .foregroundColor(.accentColor) 38 | .frame(width: 8, height: 8) 39 | .padding(.leading, 2) 40 | .opacity(isSeen ? 0 : 1) 41 | 42 | Text(username) 43 | .font(.system(size: 13)) 44 | .fontWeight(.semibold) 45 | .lineLimit(1) 46 | Spacer() 47 | Text(dateString) 48 | .font(.system(size: 11)) 49 | .opacity(0.7) 50 | } 51 | 52 | Text(subject) 53 | .font(.system(size: 11)) 54 | .lineLimit(/*@START_MENU_TOKEN@*/1/*@END_MENU_TOKEN@*/) 55 | .padding(.leading, 20) 56 | 57 | Text(excerpt) 58 | .font(.system(size: 11)) 59 | .lineLimit(/*@START_MENU_TOKEN@*/2/*@END_MENU_TOKEN@*/) 60 | .opacity(0.7) 61 | .padding(.leading, 20) 62 | Divider() 63 | .padding(.leading, 20) 64 | } 65 | .padding(.leading, 8) 66 | } 67 | } 68 | 69 | struct InboxCell_Previews: PreviewProvider { 70 | static var previews: some View { 71 | InboxCell(username: "Swiggy", 72 | subject: "Your Swiggy order was delivered superfast!", 73 | excerpt: "Your order was delivered within 43 minutes." 74 | + "Rate this lightning fast delivery and add a tip for your delivery partner here", 75 | isSeen: true, 76 | date: Date()) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /macOS/Features/Inbox/InboxView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InboxView.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 16/09/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct InboxView: View { 11 | 12 | @EnvironmentObject var appController: AppController 13 | 14 | var isFilterBySeenEnabled: Bool { 15 | appController.filterNotSeen 16 | } 17 | 18 | var messagesCount: String { 19 | let count = appController.selectedAccountMessages.count 20 | let unreadCount = appController.selectedAccountMessages.filter { !$0.data.seen }.count 21 | if appController.filterNotSeen { 22 | return "\(count) Unread" 23 | } 24 | if unreadCount == 0 { 25 | return "\(count) " + (count == 1 ? "Message" : "Messages") 26 | } else { 27 | let unread = "\(unreadCount) unread" 28 | let messages = "\(count) " + (count == 1 ? "Message" : "Messages") 29 | return "\(messages), \(unread)" 30 | } 31 | 32 | } 33 | 34 | var body: some View { 35 | VStack { 36 | if appController.selectedAccount == nil { 37 | noAccountSelectedView 38 | } else if appController.selectedAccountMessages.isEmpty { 39 | noMessagesView 40 | } else { 41 | List(selection: $appController.selectedMessage) { 42 | ForEach(appController.selectedAccountMessages, id: \.id) { message in 43 | InboxCell(username: message.data.from.address, 44 | subject: message.data.subject, 45 | excerpt: message.data.textExcerpt, 46 | isSeen: message.data.seen, 47 | date: message.data.createdAt) 48 | .contextMenu { 49 | if !message.data.seen { 50 | Button { 51 | if let selectedAccount = appController.selectedAccount { 52 | appController.markMessageAsSeen(message: message, for: selectedAccount) 53 | } 54 | } label: { 55 | Label("Mark as seen", systemImage: "eyes.inverse") 56 | } 57 | } 58 | Button { 59 | if let selectedAccount = appController.selectedAccount { 60 | appController.deleteMessage(message: message, for: selectedAccount) 61 | } 62 | } label: { 63 | Label("Delete", systemImage: "trash") 64 | } 65 | 66 | } 67 | .tag(message) 68 | } 69 | } 70 | .listStyle(InsetListStyle()) 71 | } 72 | } 73 | .navigationTitle("Inbox") 74 | .navigationSubtitle(messagesCount) 75 | .toolbar(content: { 76 | ToolbarItem(placement: .primaryAction) { 77 | Button { 78 | appController.filterNotSeen.toggle() 79 | } label: { 80 | Label("Filter", 81 | systemImage: "line.horizontal.3.decrease.circle\(isFilterBySeenEnabled ? ".fill" : "")") 82 | .foregroundColor(isFilterBySeenEnabled ? Color.accentColor : Color.primary) 83 | .help("Filter by Unreads") 84 | } 85 | } 86 | }) 87 | 88 | } 89 | 90 | var noAccountSelectedView: some View { 91 | Text("No Account Selected") 92 | .opacity(0.6) 93 | } 94 | 95 | var noMessagesView: some View { 96 | Text(isFilterBySeenEnabled ? "No Unread Messages" : "No Messages") 97 | .opacity(0.6) 98 | } 99 | } 100 | 101 | struct InboxView_Previews: PreviewProvider { 102 | static var previews: some View { 103 | InboxView() 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /macOS/Features/MessageDetail/AttachmentsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AttachmentsView.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 05/10/21. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import MailTMSwift 11 | 12 | struct AttachmentsView: View { 13 | 14 | @ObservedObject var controller: AttachmentsViewController 15 | 16 | var title: String { 17 | let pluralizedWord = controller.attachments.count == 1 ? "Attachment" : "Attachments" 18 | return "\(controller.attachments.count) \(pluralizedWord)" 19 | } 20 | 21 | var body: some View { 22 | VStack(alignment: .leading) { 23 | Rectangle() 24 | .frame(height: 1) 25 | .opacity(0.2) 26 | HStack { 27 | Image(systemName: "paperclip") 28 | Text(title) 29 | .opacity(0.7) 30 | } 31 | .padding(.horizontal) 32 | 33 | ScrollView(.horizontal) { 34 | HStack { 35 | ForEach(controller.attachments, id: \.id) { attachment in 36 | if let downloadTask = controller.attachmentDownloadTasks[attachment] { 37 | AttachmentCell(attachment: attachment, downloadTask: downloadTask, controller: _controller) 38 | } 39 | } 40 | } 41 | .padding() 42 | } 43 | } 44 | } 45 | 46 | } 47 | 48 | fileprivate struct AttachmentCell: View { 49 | 50 | @State var downloadPercentage: Double = 0 51 | @ObservedObject var downloadTask: FileDownloadTask 52 | @ObservedObject var controller: AttachmentsViewController 53 | 54 | var attachment: MTAttachment 55 | 56 | init(attachment: MTAttachment, downloadTask: FileDownloadTask, controller: ObservedObject) { 57 | self.attachment = attachment 58 | self.downloadTask = downloadTask 59 | self._controller = controller 60 | } 61 | 62 | var isDownloading: Bool { 63 | downloadTask.state == .downloading 64 | } 65 | 66 | var body: some View { 67 | HStack { 68 | VStack(alignment: .leading, spacing: 4) { 69 | Text(attachment.filename) 70 | .truncationMode(.middle) 71 | .frame(maxWidth: 150, alignment: .leading) 72 | Text(humanReadableFileSize) 73 | .opacity(0.6) 74 | .font(.caption) 75 | } 76 | .frame(height: 30) 77 | .padding(.leading, 4) 78 | 79 | controlView 80 | .padding(.leading) 81 | 82 | } 83 | .padding() 84 | .frame(minWidth: 140, alignment: .leading) 85 | .background(Color.primary.opacity(0.2)) 86 | .background( 87 | Color.accentColor.opacity(0.4) 88 | .animation(.easeOut) 89 | .scaleEffect(x: downloadPercentage, y: 1, anchor: .leading) 90 | .opacity(isDownloading ? 1 : 0) 91 | ) 92 | .clipShape(RoundedRectangle(cornerRadius: 8)) 93 | .onTapGesture { 94 | controller.onAttachmentTap(attachment: attachment) 95 | } 96 | .onReceive(downloadTask.progress 97 | .publisher(for: \.fractionCompleted) 98 | .receive(on: DispatchQueue.main) 99 | ) { value in 100 | downloadPercentage = value 101 | } 102 | } 103 | 104 | @ViewBuilder 105 | var controlView: some View { 106 | switch downloadTask.state { 107 | case .idle: 108 | Image(systemName: "icloud.and.arrow.down") 109 | .padding(.leading) 110 | case .downloading: 111 | ProgressView() 112 | .controlSize(.small) 113 | default: 114 | EmptyView() 115 | } 116 | } 117 | 118 | var humanReadableFileSize: String { 119 | ByteCountFormatter.string(from: 120 | .init(value: Double(attachment.size), 121 | unit: .kilobytes), 122 | countStyle: .file) 123 | } 124 | 125 | } 126 | -------------------------------------------------------------------------------- /macOS/Features/MessageDetail/AttachmentsViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AttachmentsViewController.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 07/10/21. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | import Resolver 11 | import MailTMSwift 12 | import AppKit 13 | import OSLog 14 | import UserNotifications 15 | 16 | final class AttachmentsViewController: ObservableObject { 17 | static let logger = Logger(subsystem: Logger.subsystem, category: String(describing: AttachmentsViewController.self)) 18 | var account: Account 19 | @Published var attachments: [MTAttachment] 20 | @Published var attachmentDownloadTasks: [MTAttachment: FileDownloadTask] = [:] 21 | 22 | private var downloadManager: AttachmentDownloadManager = Resolver.resolve() 23 | 24 | var subscriptions: Set = [] 25 | 26 | init(account: Account, attachments: [MTAttachment]) { 27 | self.account = account 28 | self.attachments = attachments 29 | registerAttachments() 30 | restoreDownloadTasks() 31 | } 32 | 33 | func restoreDownloadTasks() { 34 | attachmentDownloadTasks = attachments.reduce(into: [MTAttachment: FileDownloadTask]()) { dict, attachment in 35 | if let file = downloadManager.file(for: attachment) { 36 | dict[attachment] = file 37 | } 38 | } 39 | 40 | attachmentDownloadTasks.values.forEach { file in 41 | file.$state.sink { [weak self] _ in 42 | guard let self = self else { return } 43 | self.objectWillChange.send() 44 | } 45 | .store(in: &subscriptions) 46 | } 47 | } 48 | 49 | func registerAttachments() { 50 | for attachment in attachments { 51 | try? downloadManager.add(attachment: attachment, for: account, afterDownload: { [weak self] task in 52 | guard let self = self else { return } 53 | self.triggerNotificationForDownloadedAttachment(fileName: task.fileName, savedLocation: task.savedFileLocation) 54 | }) 55 | } 56 | } 57 | 58 | func onAttachmentTap(attachment: MTAttachment) { 59 | guard let task = attachmentDownloadTasks[attachment], task.state != .downloading else { 60 | return 61 | } 62 | 63 | if task.state == .saved { 64 | openAttachment(attachment: attachment) 65 | } else { 66 | download(attachment: attachment) 67 | } 68 | } 69 | 70 | func openAttachment(attachment: MTAttachment) { 71 | guard let task = attachmentDownloadTasks[attachment], task.state == .saved else { 72 | return 73 | } 74 | NSWorkspace.shared.open(task.savedFileLocation) 75 | } 76 | 77 | func download(attachment: MTAttachment) { 78 | downloadManager.download(attachment: attachment, onRenew: { [weak self] newFileTask in 79 | guard let self = self else { return } 80 | self.attachmentDownloadTasks[attachment] = newFileTask 81 | self.objectWillChange.send() 82 | }) 83 | } 84 | 85 | func triggerNotificationForDownloadedAttachment(fileName: String, savedLocation: URL) { 86 | let center = UNUserNotificationCenter.current() 87 | let content = UNMutableNotificationContent() 88 | content.title = "Attachment downloaded." 89 | content.subtitle = fileName 90 | content.sound = nil 91 | content.categoryIdentifier = LocalNotificationKeys.Category.openFileFromLocation 92 | content.userInfo = ["location": savedLocation.absoluteString] 93 | 94 | let openAction = UNNotificationAction(identifier: "Open", title: "Open", options: .foreground) 95 | let category = UNNotificationCategory(identifier: LocalNotificationKeys.Identifiers.attachment, 96 | actions: [openAction], 97 | intentIdentifiers: []) 98 | 99 | let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) 100 | center.setNotificationCategories([category]) 101 | center.add(request) { error in 102 | if let error = error { 103 | Self.logger.error("\(#function) \(#line): Attachment Notification: \(error.localizedDescription)") 104 | } 105 | } 106 | } 107 | 108 | } 109 | -------------------------------------------------------------------------------- /macOS/Features/MessageDetail/MessageDetailHeader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageDetailHeader.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 02/10/21. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import MailTMSwift 11 | 12 | struct MessageDetailHeader: View { 13 | 14 | let viewModel: MessageDetailHeaderViewModel 15 | 16 | var body: some View { 17 | HStack { 18 | Color.accentColor 19 | .clipShape(Circle()) 20 | .frame(width: 40, height: 40) 21 | .overlay( 22 | Text(viewModel.imageCharacter) 23 | .font(.title2) 24 | .fontWeight(.bold) 25 | .foregroundColor(.white) 26 | ) 27 | .padding() 28 | 29 | VStack(alignment: .leading, spacing: 6.0) { 30 | HStack { 31 | Text("\(viewModel.fromName) (\(viewModel.fromAddress))") 32 | .font(.system(size: 16)) 33 | .lineLimit(1) 34 | Spacer() 35 | Text(viewModel.createdAtDate) 36 | .layoutPriority(1) 37 | .font(.caption) 38 | } 39 | Text(viewModel.subject) 40 | .font(.system(size: 12)) 41 | CCView(viewModel: viewModel) 42 | BCCView(viewModel: viewModel) 43 | } 44 | } 45 | } 46 | 47 | } 48 | 49 | // swiftlint:disable private_over_fileprivate 50 | fileprivate struct CCView: View { 51 | 52 | let viewModel: MessageDetailHeaderViewModel 53 | 54 | @State var showCCList = false 55 | 56 | var body: some View { 57 | if viewModel.ccList.isEmpty { 58 | EmptyView() 59 | } else { 60 | HStack(alignment: .firstTextBaseline, spacing: 2.0) { 61 | Text("CC: ") 62 | HStack { 63 | ForEach(viewModel.viewableCCList, id: \.self) { cc in 64 | Text("\(cc),") 65 | .opacity(0.8) 66 | } 67 | } 68 | if viewModel.isCCListBig { 69 | Button { 70 | showCCList = true 71 | } label: { 72 | Text("Show All") 73 | .foregroundColor(.accentColor) 74 | .font(.caption2) 75 | .padding(.leading, 2) 76 | } 77 | .popover(isPresented: $showCCList) { 78 | Text(viewModel.ccList.joined(separator: "\n")) 79 | .fixedSize() 80 | .padding() 81 | } 82 | .buttonStyle(PlainButtonStyle()) 83 | 84 | } 85 | } 86 | .font(.system(size: 12)) 87 | } 88 | } 89 | 90 | } 91 | 92 | fileprivate struct BCCView: View { 93 | 94 | let viewModel: MessageDetailHeaderViewModel 95 | 96 | @State var showBCCList = false 97 | 98 | var body: some View { 99 | if viewModel.bccList.isEmpty { 100 | EmptyView() 101 | } else { 102 | HStack(alignment: .firstTextBaseline, spacing: 2.0) { 103 | Text("CC: ") 104 | HStack { 105 | ForEach(viewModel.viewableBCCList, id: \.self) { bcc in 106 | Text("\(bcc),") 107 | .opacity(0.8) 108 | } 109 | } 110 | if viewModel.isCCListBig { 111 | Button { 112 | showBCCList = true 113 | } label: { 114 | Text("Show All") 115 | .foregroundColor(.accentColor) 116 | .font(.caption2) 117 | .padding(.leading, 2) 118 | } 119 | .popover(isPresented: $showBCCList) { 120 | Text(viewModel.bccList.joined(separator: "\n")) 121 | .fixedSize() 122 | .padding() 123 | } 124 | .buttonStyle(PlainButtonStyle()) 125 | 126 | } 127 | } 128 | .font(.system(size: 12)) 129 | } 130 | } 131 | 132 | } 133 | // swiftlint:enable private_over_fileprivate 134 | 135 | struct MessageDetailHeaderViewModel { 136 | let maxAccountListCount = 2 137 | 138 | var from: MTMessageUser? 139 | var cc: [MTMessageUser]? 140 | var bcc: [MTMessageUser]? 141 | var subject: String 142 | var date: Date 143 | 144 | var fromName: String { 145 | from?.name ?? "---" 146 | } 147 | 148 | var fromAddress: String { 149 | from?.address ?? "---" 150 | } 151 | 152 | var ccList: [String] { 153 | cc?.map(\.address) ?? [] 154 | } 155 | 156 | var bccList: [String] { 157 | bcc?.map(\.address) ?? [] 158 | } 159 | 160 | var isCCListBig: Bool { 161 | ccList.count > maxAccountListCount 162 | } 163 | 164 | var isBCCListBig: Bool { 165 | bccList.count > maxAccountListCount 166 | } 167 | 168 | var viewableCCList: [String] { 169 | return Array(ccList[..() 26 | 27 | @Published var currentDownloadingFileState = FileDownloadTask.State.idle 28 | 29 | @Published var currentDownloadingFile: FileDownloadTask? { 30 | didSet { 31 | if let currentDownloadingFile = currentDownloadingFile { 32 | currentDownloadingFile.$state 33 | .sink(receiveValue: { [weak self] state in 34 | guard let self = self else { return } 35 | self.currentDownloadingFileState = state 36 | }) 37 | .store(in: &subscriptions) 38 | } else { 39 | self.currentDownloadingFileState = .idle 40 | } 41 | } 42 | } 43 | 44 | var isDownloading: Bool { 45 | return currentDownloadingFileState == .downloading 46 | } 47 | 48 | var downloadProgress: Progress? { 49 | return currentDownloadingFile?.progress 50 | } 51 | 52 | init( 53 | mtMessageService: MTMessageService = Resolver.resolve(), 54 | downloadManager: MessageDownloadManager = Resolver.resolve(), 55 | message: Message, 56 | account: Account) { 57 | self.mtMessageService = mtMessageService 58 | self.downloadManager = downloadManager 59 | self.message = message 60 | self.account = account 61 | restoreDownloadIfAny() 62 | } 63 | 64 | func restoreDownloadIfAny() { 65 | currentDownloadingFile = downloadManager.file(for: self.message) 66 | } 67 | 68 | func downloadMessage(message: Message, for account: Account) { 69 | guard let request = mtMessageService.getSourceRequest(id: message.id, token: account.token) else { 70 | errorMessage = "Something went wrong, Please try again later" 71 | showError = true 72 | return 73 | } 74 | 75 | let downloadDirectory = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first! 76 | let panel = NSSavePanel() 77 | panel.directoryURL = downloadDirectory 78 | panel.nameFieldLabel = "Save file as:" 79 | panel.nameFieldStringValue = message.data.subject + ".eml" 80 | panel.canCreateDirectories = true 81 | panel.showsTagField = false 82 | panel.begin { [weak self] response in 83 | guard let self = self else { return } 84 | if response == NSApplication.ModalResponse.OK, let desiredUrl = panel.url { 85 | self.currentDownloadingFile = self.downloadManager.download(message: message, 86 | request: request, 87 | saveLocation: desiredUrl, afterDownload: { _ in 88 | self.triggerNotificationForDownloadedMessage(fileName: panel.nameFieldStringValue, savedLocation: desiredUrl) 89 | }) 90 | } 91 | } 92 | } 93 | 94 | private func saveSource(fileName: String, source: MTMessageSource) { 95 | 96 | let panel = NSSavePanel() 97 | panel.directoryURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first! 98 | panel.nameFieldLabel = "Save file as:" 99 | panel.nameFieldStringValue = fileName 100 | panel.canCreateDirectories = true 101 | panel.showsTagField = false 102 | 103 | panel.begin { response in 104 | if response == NSApplication.ModalResponse.OK, let fileUrl = panel.url { 105 | do { 106 | try source.data.write(to: fileUrl, atomically: true, encoding: .utf8) 107 | } catch { 108 | Self.logger.error("\(#function) \(#line): \(error.localizedDescription)") 109 | } 110 | } 111 | } 112 | } 113 | 114 | func triggerNotificationForDownloadedMessage(fileName: String, savedLocation: URL) { 115 | let center = UNUserNotificationCenter.current() 116 | let content = UNMutableNotificationContent() 117 | content.title = "Message downloaded" 118 | content.subtitle = fileName 119 | content.sound = nil 120 | content.categoryIdentifier = LocalNotificationKeys.Category.openFileFromLocation 121 | content.userInfo = ["location": savedLocation.absoluteString] 122 | 123 | let openAction = UNNotificationAction(identifier: "Open", title: "Open", options: .foreground) 124 | let category = UNNotificationCategory(identifier: LocalNotificationKeys.Identifiers.message, 125 | actions: [openAction], 126 | intentIdentifiers: []) 127 | 128 | let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) 129 | center.setNotificationCategories([category]) 130 | center.add(request) { error in 131 | if let error = error { 132 | Self.logger.error("\(#function) \(#line): Message Notification: \(error.localizedDescription)") 133 | } 134 | } 135 | } 136 | 137 | } 138 | -------------------------------------------------------------------------------- /macOS/Features/Sidebar/AccountInfoView/AccountInfoView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccountInfoView.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 03/10/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct AccountInfoView: View { 11 | 12 | var isActive: Bool 13 | var address: String 14 | var password: String 15 | var refresh: () -> Void 16 | 17 | @StateObject var controller = AccountInfoViewController() 18 | @State var isPasswordVisible = false 19 | @State var isHintShowing = false 20 | 21 | var body: some View { 22 | VStack(alignment: .leading) { 23 | HStack { 24 | Spacer() 25 | Button { 26 | isHintShowing = true 27 | } label: { 28 | Image(systemName: "info.circle") 29 | .foregroundColor(isHintShowing ? Color.accentColor : Color.primary) 30 | } 31 | .buttonStyle(PlainButtonStyle()) 32 | .popover(isPresented: $isHintShowing) { 33 | Text("If you wish to use this account on Web browser, " 34 | + "You can copy the credentials to use on Mail.tm official website. " 35 | + "Please note, the password cannot be reset or changed." 36 | ) 37 | .frame(width: 400) 38 | .padding() 39 | } 40 | } 41 | .padding(.bottom, 4) 42 | 43 | HStack { 44 | KeyView(key: "Status") 45 | Circle() 46 | .frame(width: 10, height: 10) 47 | .foregroundColor(isActive ? .green : .red) 48 | Text(isActive ? "Active" : "InActive") 49 | .lineLimit(1) 50 | if (!isActive) { 51 | Spacer() 52 | Button { 53 | refresh() 54 | } label: { 55 | Image(systemName: "arrow.clockwise.circle.fill") 56 | } 57 | } 58 | } 59 | 60 | HStack { 61 | KeyView(key: "Address") 62 | Text(address) 63 | .lineLimit(1) 64 | 65 | Spacer() 66 | 67 | Button { 68 | controller.copyStringToPasteboard(value: address) 69 | } label: { 70 | Image(systemName: "doc.on.doc.fill") 71 | } 72 | 73 | } 74 | 75 | HStack { 76 | KeyView(key: "Password") 77 | Text(password) 78 | .blur(radius: isPasswordVisible ? 0 : 3) 79 | .lineLimit(1) 80 | .onHover { isHovering in 81 | withAnimation { 82 | isPasswordVisible = isHovering 83 | } 84 | } 85 | 86 | Spacer() 87 | 88 | Button { 89 | controller.copyStringToPasteboard(value: password) 90 | } label: { 91 | Image(systemName: "doc.on.doc.fill") 92 | } 93 | } 94 | } 95 | .padding() 96 | .frame(maxWidth: .infinity) 97 | .background(Color.secondary.opacity(0.2)) 98 | .cornerRadius(6) 99 | } 100 | } 101 | 102 | fileprivate struct KeyView: View { 103 | 104 | var key: String 105 | 106 | var body: some View { 107 | Text("\(key):") 108 | .fixedSize() 109 | .lineLimit(1) 110 | .font(.headline) 111 | 112 | } 113 | } 114 | 115 | struct AccountInfoView_Previews: PreviewProvider { 116 | static var previews: some View { 117 | AccountInfoView(isActive: true, 118 | address: "Address", 119 | password: "Password") { 120 | print("Refresh...") 121 | } 122 | .previewLayout(.fixed(width: 400, height: 500)) 123 | 124 | AccountInfoView(isActive: false, 125 | address: "Address", 126 | password: "Password") { 127 | print("Refresh...") 128 | } 129 | .previewLayout(.fixed(width: 400, height: 500)) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /macOS/Features/Sidebar/AccountInfoView/AccountInfoViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccountInfoViewController.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 03/10/21. 6 | // 7 | 8 | import Foundation 9 | import AppKit 10 | 11 | class AccountInfoViewController: ObservableObject { 12 | 13 | let pasteboard: NSPasteboard 14 | 15 | init(pasteboard: NSPasteboard = .general) { 16 | self.pasteboard = pasteboard 17 | } 18 | 19 | func copyStringToPasteboard(value: String) { 20 | pasteboard.declareTypes([.string], owner: nil) 21 | pasteboard.setString(value, forType: .string) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /macOS/Features/Sidebar/QuotaView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // QuotaView.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 17/09/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct QuotaView: View { 11 | 12 | @State var isHintShowing = false 13 | 14 | var value: Int32 15 | var total: Int32 16 | 17 | var isQuotaAlmostComplete: Bool { 18 | (Float(value) / Float(total)) > 0.85 19 | } 20 | 21 | var valueInMb: String { 22 | ByteCountFormatter.string(from: Measurement(value: Double(value), unit: .bytes), 23 | countStyle: .file) 24 | } 25 | 26 | var totalInMb: String { 27 | ByteCountFormatter.string(from: Measurement(value: Double(total), unit: .bytes), 28 | countStyle: .file) 29 | } 30 | 31 | var body: some View { 32 | VStack(alignment: .leading) { 33 | HStack { 34 | Text("Quota left") 35 | .font(.headline) 36 | Spacer() 37 | Button { 38 | isHintShowing = true 39 | } label: { 40 | Image(systemName: "info.circle") 41 | .foregroundColor(isHintShowing ? Color.accentColor : Color.primary) 42 | } 43 | .buttonStyle(PlainButtonStyle()) 44 | .popover(isPresented: $isHintShowing) { 45 | Text("Once you reach your Quota limit, you cannot receive any more messages. " + 46 | "Deleting your previous messages will free up your used Quota.") 47 | .frame(width: 400) 48 | .padding() 49 | } 50 | } 51 | 52 | ProgressView(value: Float(value), total: Float(total)) { 53 | HStack(alignment: .center) { 54 | Text("\(valueInMb) / \(totalInMb)") 55 | if isQuotaAlmostComplete { 56 | Image(systemName: "exclamationmark.triangle.fill") 57 | } 58 | } 59 | } 60 | .padding(.top, 8) 61 | 62 | } 63 | .padding() 64 | .frame(maxWidth: .infinity) 65 | .background(Color.secondary.opacity(0.2)) 66 | .cornerRadius(6) 67 | } 68 | } 69 | 70 | struct QuotaView_Previews: PreviewProvider { 71 | static var previews: some View { 72 | QuotaView(value: 1_000_000, total: 100_000_000) 73 | QuotaView(value: 90_000_000, total: 100_000_000) 74 | .previewDisplayName("Quota almost complete") 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /macOS/Features/Sidebar/SidebarView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SidebarView.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 16/09/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SidebarView: View { 11 | 12 | @EnvironmentObject var appController: AppController 13 | 14 | var body: some View { 15 | 16 | VStack(alignment: .leading) { 17 | AddAccountView() 18 | .padding() 19 | List(selection: $appController.selectedAccount) { 20 | Section(header: Text("Active Accounts")) { 21 | 22 | ForEach(appController.activeAccounts, id: \.self) { account in 23 | AddressItemView(account: account) 24 | .tag(account) 25 | } 26 | } 27 | 28 | Section(header: Text("Archived Accounts")) { 29 | 30 | ForEach(appController.archivedAccounts, id: \.self) { account in 31 | AddressItemView(account: account) 32 | .tag(account) 33 | } 34 | } 35 | } 36 | .listStyle(SidebarListStyle()) 37 | Spacer() 38 | 39 | if let selectedAccount = appController.selectedAccount { 40 | VStack { 41 | AccountInfoView(isActive: appController.selectedAccountConnectionIsActive, 42 | address: selectedAccount.address, 43 | password: selectedAccount.password) { 44 | appController.refreshAccount(account: selectedAccount) 45 | } 46 | .padding(.horizontal) 47 | QuotaView(value: selectedAccount.quotaUsed, total: selectedAccount.quotaLimit) 48 | .padding() 49 | } 50 | } 51 | Divider() 52 | 53 | footer 54 | } 55 | .padding(.top) 56 | .toolbar { 57 | ToolbarItem(placement: ToolbarItemPlacement.automatic) { 58 | Button(action: toggleSidebar) { 59 | Label("Back", systemImage: "sidebar.squares.left") 60 | } 61 | .help("Toggle sidebar") 62 | } 63 | 64 | } 65 | 66 | } 67 | 68 | var footer: some View { 69 | HStack(alignment: .center) { 70 | Spacer() 71 | Text("Powered by Mail.tm") 72 | .padding() 73 | Spacer() 74 | } 75 | } 76 | } 77 | 78 | func toggleSidebar() { 79 | NSApp.keyWindow? 80 | .firstResponder? 81 | .tryToPerform(#selector(NSSplitViewController.toggleSidebar(_:)), with: nil) 82 | } 83 | 84 | struct AddressItemView: View { 85 | 86 | @EnvironmentObject var appController: AppController 87 | var account: Account 88 | 89 | @State var showConfirmationForRemove = false 90 | @State var showConfirmationForDelete = false 91 | 92 | var isMessagesFetching: Bool { 93 | appController.accountMessages[account]?.isFetching ?? false 94 | } 95 | 96 | var isMessagesFetchingFailed: Bool { 97 | appController.accountMessages[account]?.error != nil 98 | } 99 | 100 | var unreadMessagesCount: Int { 101 | appController.accountMessages[account]?.unreadMessagesCount ?? 0 102 | } 103 | 104 | var body: some View { 105 | HStack { 106 | Label(account.address, systemImage: "tray") 107 | Spacer() 108 | if !account.isArchived { 109 | if isMessagesFetching { 110 | ProgressView() 111 | .controlSize(.small) 112 | } else if isMessagesFetchingFailed { 113 | Image(systemName: "exclamationmark.triangle.fill") 114 | } else if unreadMessagesCount != 0 { 115 | BadgeView(model: .init(title: "\(unreadMessagesCount)", color: .secondary.opacity(0.5))) 116 | } 117 | } 118 | 119 | } 120 | .padding(.vertical, 8) 121 | .padding(.horizontal, 3) 122 | .contextMenu(menuItems: { 123 | Button(action: { 124 | if account.isArchived { 125 | appController.activateAccount(account: account) 126 | } else { 127 | appController.archiveAccount(account: account) 128 | } 129 | }, label: { 130 | Label(archiveActivateButtonText, systemImage: "tray") 131 | }) 132 | Button(action: { 133 | showConfirmationForRemove = true 134 | }, label: { 135 | Label("Remove", systemImage: "trash") 136 | }) 137 | 138 | Button(action: { 139 | showConfirmationForDelete = true 140 | }, label: { 141 | Label("Delete", systemImage: "trash") 142 | }) 143 | }) 144 | .background( 145 | EmptyView() 146 | .alert(isPresented: $showConfirmationForRemove) { 147 | Alert(title: Text("Remove \(account.address) from TempBox"), 148 | message: Text(""" 149 | This action will only remove your account from TempBox. 150 | You can still access the account using the email address and password on mail.tm. \n 151 | NOTE: Make sure you save a copy of email address and password before removing the account! 152 | """), 153 | primaryButton: .destructive(Text("Remove"), action: { appController.removeAccount(account: account) }), 154 | secondaryButton: .default(Text("Cancel"))) 155 | } 156 | ) 157 | .background( 158 | EmptyView() 159 | .alert(isPresented: $showConfirmationForDelete) { 160 | Alert(title: Text("Delete \(account.address) from TempBox"), 161 | message: Text("This action will delete your account permanently."), 162 | primaryButton: .destructive(Text("Delete"), action: { appController.deleteAccount(account: account) }), 163 | secondaryButton: .default(Text("Cancel"))) 164 | } 165 | ) 166 | } 167 | } 168 | 169 | extension AddressItemView { 170 | 171 | var archiveActivateButtonText: String { 172 | account.isArchived ? "Activate" : "Archive" 173 | } 174 | } 175 | 176 | struct SidebarView_Previews: PreviewProvider { 177 | static var previews: some View { 178 | SidebarView() 179 | .previewLayout(.fixed(width: 500, height: 800)) 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /macOS/Models/LocalNotificationKeys.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocalNotificationKeys.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 21/10/21. 6 | // 7 | 8 | import Foundation 9 | 10 | enum LocalNotificationKeys { 11 | 12 | enum Identifiers { 13 | static let message = "Message" 14 | static let attachment = "Attachment" 15 | } 16 | 17 | enum Category { 18 | static let openFileFromLocation = "openFileFromLocation" 19 | static let activateMessage = "activateMessage" 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /macOS/Models/Message.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Message.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 02/10/21. 6 | // 7 | 8 | import Foundation 9 | import MailTMSwift 10 | 11 | struct Message: Hashable, Identifiable { 12 | 13 | var isComplete: Bool = false 14 | var data: MTMessage 15 | 16 | var id: String { 17 | data.id 18 | } 19 | 20 | init(isComplete: Bool = false, data: MTMessage) { 21 | self.isComplete = isComplete 22 | self.data = data 23 | } 24 | 25 | } 26 | 27 | extension Message: Equatable { 28 | static func == (lhs: Message, rhs: Message) -> Bool { 29 | lhs.data.id == rhs.data.id 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /macOS/Models/MessageStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageStore.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 02/10/21. 6 | // 7 | 8 | import Foundation 9 | import MailTMSwift 10 | 11 | struct MessageStore { 12 | var isFetching: Bool = false 13 | var error: MTError? 14 | var messages: [Message] 15 | 16 | var unreadMessagesCount: Int { 17 | return messages.filter { !$0.data.seen }.count 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /macOS/Models/SimpleAlertData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SimpleAlertData.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 21/10/21. 6 | // 7 | 8 | import Foundation 9 | 10 | struct SimpleAlertData: Identifiable { 11 | var id: String { 12 | title 13 | } 14 | let title: String 15 | let message: String? 16 | } 17 | 18 | extension SimpleAlertData: ExpressibleByStringLiteral { 19 | init(stringLiteral value: StringLiteralType) { 20 | self.title = value 21 | self.message = nil 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /macOS/Persistence/Models/Account+CoreDataClass.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Account+CoreDataClass.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 22/09/21. 6 | // 7 | // 8 | 9 | import Foundation 10 | import CoreData 11 | 12 | public class Account: NSManagedObject { 13 | 14 | } 15 | -------------------------------------------------------------------------------- /macOS/Persistence/Models/Account+CoreDataProperties.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Account+CoreDataProperties.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 22/09/21. 6 | // 7 | // 8 | 9 | import Foundation 10 | import CoreData 11 | import MailTMSwift 12 | 13 | extension Account { 14 | 15 | @nonobjc public class func fetchRequest() -> NSFetchRequest { 16 | return NSFetchRequest(entityName: "Account") 17 | } 18 | 19 | @NSManaged public var id: String 20 | @NSManaged public var address: String 21 | @NSManaged public var password: String 22 | @NSManaged public var quotaLimit: Int32 23 | @NSManaged public var quotaUsed: Int32 24 | @NSManaged public var isDisabled: Bool 25 | @NSManaged public var createdAt: Date 26 | @NSManaged public var updatedAt: Date 27 | @NSManaged public var isArchived: Bool 28 | @NSManaged public var token: String 29 | 30 | func set(from mtAccount: MTAccount, password: String, token: String, isArchived: Bool = false) { 31 | self.id = mtAccount.id 32 | self.address = mtAccount.address 33 | self.quotaLimit = Int32(mtAccount.quotaLimit) 34 | self.quotaUsed = Int32(mtAccount.quotaUsed) 35 | self.isDisabled = mtAccount.isDisabled 36 | self.createdAt = mtAccount.createdAt 37 | self.updatedAt = mtAccount.updatedAt 38 | self.isArchived = isArchived 39 | self.token = token 40 | self.password = password 41 | } 42 | 43 | } 44 | 45 | extension Account: Identifiable { 46 | 47 | } 48 | -------------------------------------------------------------------------------- /macOS/Persistence/Persistence+Injection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Persistence+Injection.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 22/09/21. 6 | // 7 | 8 | import Resolver 9 | 10 | extension Resolver { 11 | 12 | static func registerPersistence() { 13 | register { 14 | PersistenceManager() 15 | } 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /macOS/Persistence/Persistence.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Persistence.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 17/09/21. 6 | // 7 | 8 | import CoreData 9 | import OSLog 10 | 11 | open class PersistenceManager { 12 | public static let modelName = "TempBox" 13 | public static let model: NSManagedObjectModel = { 14 | // swiftlint:disable force_unwrapping 15 | let modelURL = Bundle.main.url(forResource: modelName, withExtension: "momd")! 16 | return NSManagedObjectModel(contentsOf: modelURL)! 17 | // swiftlint:enable force_unwrapping 18 | }() 19 | 20 | public init() { 21 | } 22 | 23 | public lazy var mainContext: NSManagedObjectContext = { 24 | return self.storeContainer.viewContext 25 | }() 26 | 27 | public lazy var storeContainer: NSPersistentContainer = { 28 | let container = NSPersistentContainer(name: Self.modelName, managedObjectModel: Self.model) 29 | container.loadPersistentStores { _, error in 30 | if let error = error as NSError? { 31 | fatalError("Unable to load persistent store") 32 | } 33 | } 34 | 35 | return container 36 | }() 37 | 38 | public func newDerivedContext() -> NSManagedObjectContext { 39 | let context = storeContainer.newBackgroundContext() 40 | return context 41 | } 42 | 43 | public func saveMainContext() { 44 | saveContext(mainContext) 45 | } 46 | 47 | public func saveContext(_ context: NSManagedObjectContext) { 48 | if context != mainContext { 49 | saveDerivedContext(context) 50 | return 51 | } 52 | 53 | context.perform { 54 | do { 55 | try context.save() 56 | } catch let error as NSError { 57 | Logger.persistence.error("Error while saving main context: \(error), \(error.userInfo)") 58 | } 59 | } 60 | } 61 | 62 | public func saveDerivedContext(_ context: NSManagedObjectContext) { 63 | context.perform { 64 | do { 65 | try context.save() 66 | } catch let error as NSError { 67 | Logger.persistence.error("Error while saving derived/child context: \(error), \(error.userInfo)") 68 | } 69 | 70 | self.saveContext(self.mainContext) 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /macOS/Persistence/TempBox.xcdatamodeld/TempBox.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /macOS/Repositories/AccountRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccountRepository.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 17/09/21. 6 | // 7 | 8 | import Foundation 9 | import MailTMSwift 10 | import CoreData 11 | import OSLog 12 | 13 | protocol AccountRepositoryProtocol { 14 | 15 | func isAccountExists(id: String) -> Bool 16 | func isAccountExists(forAddress address: String) -> Bool 17 | func getAll() -> [Account] 18 | func getAllActiveAccounts() -> [Account] 19 | func getAllArchivedAccounts() -> [Account] 20 | func getAccount(fromId accountId: String) -> Account? 21 | @discardableResult func create(account mtAccount: MTAccount, password: String, token: String) -> Account 22 | func update(account: Account) 23 | func delete(account: Account) 24 | func deleteAll() 25 | 26 | } 27 | 28 | final class AccountRepository: AccountRepositoryProtocol { 29 | 30 | private var persistenceManager: PersistenceManager 31 | 32 | init(persistenceManager: PersistenceManager) { 33 | self.persistenceManager = persistenceManager 34 | } 35 | 36 | func getAll() -> [Account] { 37 | Logger.Repositories.account.info("\(#function) called") 38 | let fetchRequest: NSFetchRequest = Account.fetchRequest() 39 | let results: [Account] 40 | do { 41 | results = try persistenceManager.mainContext.fetch(fetchRequest) 42 | } catch let error { 43 | Logger.Repositories.account.error("\(#fileID) \(#function) \(#line): \(error.localizedDescription)") 44 | results = [] 45 | } 46 | return results 47 | } 48 | 49 | func getAllActiveAccounts() -> [Account] { 50 | Logger.Repositories.account.info("\(#function) called") 51 | let predicate = NSPredicate(format: "%K = NO", #keyPath(Account.isArchived)) 52 | return getAllAccounts(withPredicate: predicate) 53 | } 54 | 55 | func getAllArchivedAccounts() -> [Account] { 56 | Logger.Repositories.account.info("\(#function) called") 57 | let predicate = NSPredicate(format: "%K = YES", #keyPath(Account.isArchived)) 58 | return getAllAccounts(withPredicate: predicate) 59 | } 60 | 61 | private func getAllAccounts(withPredicate predicate: NSPredicate) -> [Account] { 62 | Logger.Repositories.account.info("\(#function) called withPredicate: \(predicate)") 63 | let fetchRequest: NSFetchRequest = Account.fetchRequest() 64 | fetchRequest.predicate = predicate 65 | let results: [Account] 66 | do { 67 | results = try persistenceManager.mainContext.fetch(fetchRequest) 68 | } catch let error { 69 | Logger.Repositories.account.error("\(#fileID) \(#function) \(#line): \(error.localizedDescription)") 70 | results = [] 71 | } 72 | return results 73 | } 74 | 75 | func getAccount(fromId accountId: String) -> Account? { 76 | Logger.Repositories.account.info("\(#function) called fromId: \(accountId)") 77 | let fetchRequest: NSFetchRequest = Account.fetchRequest() 78 | fetchRequest.predicate = NSPredicate(format: "id = %@", accountId) 79 | var account: Account? 80 | do { 81 | let results = try persistenceManager.mainContext.fetch(fetchRequest) 82 | account = results.first 83 | } catch let error { 84 | Logger.Repositories.account.error("\(#fileID) \(#function) \(#line): \(error.localizedDescription)") 85 | } 86 | return account 87 | } 88 | 89 | func isAccountExists(id: String) -> Bool { 90 | Logger.Repositories.account.info("\(#function) called id: \(id)") 91 | let fetchRequest: NSFetchRequest = Account.fetchRequest() 92 | fetchRequest.predicate = NSPredicate(format: "id = %@", id) 93 | do { 94 | let resultCount = try persistenceManager.mainContext.count(for: fetchRequest) 95 | return resultCount > 0 96 | } catch let error { 97 | Logger.Repositories.account.error("\(#fileID) \(#function) \(#line): \(error.localizedDescription)") 98 | } 99 | return false 100 | } 101 | 102 | func isAccountExists(forAddress address: String) -> Bool { 103 | Logger.Repositories.account.info("\(#function) called forAddress: \(address)") 104 | let fetchRequest: NSFetchRequest = Account.fetchRequest() 105 | fetchRequest.predicate = NSPredicate(format: "%K = %@", #keyPath(Account.address), address) 106 | do { 107 | let resultCount = try persistenceManager.mainContext.count(for: fetchRequest) 108 | return resultCount > 0 109 | } catch let error { 110 | Logger.Repositories.account.error("\(#fileID) \(#function) \(#line): \(error.localizedDescription)") 111 | } 112 | return false 113 | } 114 | 115 | @discardableResult 116 | func create(account mtAccount: MTAccount, password: String, token: String) -> Account { 117 | Logger.Repositories.account.info("\(#function) called mtAccount: \(mtAccount.address)") 118 | let context = persistenceManager.mainContext 119 | let account = Account(context: context) 120 | context.performAndWait { 121 | account.set(from: mtAccount, password: password, token: token) 122 | } 123 | persistenceManager.saveMainContext() 124 | return account 125 | } 126 | 127 | func update(account: Account) { 128 | Logger.Repositories.account.info("\(#function) called account: \(account)") 129 | persistenceManager.saveMainContext() 130 | } 131 | 132 | func delete(account: Account) { 133 | Logger.Repositories.account.info("\(#function) called account: \(account)") 134 | persistenceManager.mainContext.performAndWait { 135 | self.persistenceManager.mainContext.delete(account) 136 | } 137 | self.persistenceManager.saveMainContext() 138 | } 139 | 140 | func deleteAll() { 141 | Logger.Repositories.account.info("\(#function) called") 142 | let fetchRequest: NSFetchRequest = Account.fetchRequest() 143 | let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) 144 | let context = persistenceManager.mainContext 145 | context.performAndWait { 146 | do { 147 | try context.executeAndMergeChanges(using: deleteRequest) 148 | } catch let error { 149 | Logger.Repositories.account.error("\(#fileID) \(#function) \(#line): \(error.localizedDescription)") 150 | } 151 | } 152 | } 153 | 154 | } 155 | -------------------------------------------------------------------------------- /macOS/Repositories/Respositories+Injection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Respositories+Injection.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 22/09/21. 6 | // 7 | 8 | import Resolver 9 | 10 | extension Resolver { 11 | 12 | static func registerRepositories() { 13 | register { 14 | AccountRepository(persistenceManager: resolve()) 15 | }.implements(AccountRepositoryProtocol.self) 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /macOS/RootNavigationView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RootNavigationView.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 16/09/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct RootNavigationView: View { 11 | 12 | @EnvironmentObject var appController: AppController 13 | 14 | var body: some View { 15 | NavigationView { 16 | SidebarView() 17 | .frame(minWidth: 250) 18 | 19 | InboxView() 20 | .frame(minWidth: 500) 21 | 22 | if let selectedMessage = appController.selectedMessage, let selectedAccount = appController.selectedAccount { 23 | MessageDetailView(controller: MessageDetailViewController(message: selectedMessage, account: selectedAccount)) 24 | .frame(minWidth: 500) 25 | } else { 26 | Text("No Message Selected") 27 | .font(.largeTitle) 28 | .opacity(0.4) 29 | .frame(minWidth: 500) 30 | } 31 | 32 | } 33 | .frame(minHeight: 600, idealHeight: 800) 34 | .alert(item: $appController.alertData, content: { alertData in 35 | var messageText: Text? 36 | if let messsage = alertData.message { 37 | messageText = Text(messsage) 38 | } 39 | return Alert(title: Text(alertData.title), message: messageText, dismissButton: .default(Text("OK"), action: { 40 | appController.alertData = nil 41 | })) 42 | }) 43 | 44 | } 45 | } 46 | 47 | struct RootNavigationView_Previews: PreviewProvider { 48 | static var previews: some View { 49 | RootNavigationView() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /macOS/Services/AccountService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccountService.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 22/09/21. 6 | // 7 | 8 | import Foundation 9 | import Resolver 10 | import Combine 11 | import MailTMSwift 12 | import CoreData 13 | import OSLog 14 | 15 | protocol AccountServiceProtocol { 16 | var activeAccountsPublisher: AnyPublisher<[Account], Never> { get } 17 | var archivedAccountsPublisher: AnyPublisher<[Account], Never> { get } 18 | var availableDomainsPublisher: AnyPublisher<[MTDomain], Never> { get } 19 | 20 | var activeAccounts: [Account] { get } 21 | var archivedAccounts: [Account] { get } 22 | var availableDomains: [MTDomain] { get } 23 | var isDomainsLoading: Bool { get } 24 | 25 | func archiveAccount(account: Account) 26 | func activateAccount(account: Account) 27 | func removeAccount(account: Account) 28 | func deleteAndRemoveAccount(account: Account) -> AnyPublisher 29 | func refreshAccount(with account: Account) -> AnyPublisher 30 | } 31 | 32 | class AccountService: NSObject, AccountServiceProtocol { 33 | // MARK: Account properties 34 | static let logger = Logger(subsystem: Logger.subsystem, category: String(describing: AccountService.self)) 35 | var activeAccountsPublisher: AnyPublisher<[Account], Never> { 36 | $activeAccounts.eraseToAnyPublisher() 37 | } 38 | 39 | var archivedAccountsPublisher: AnyPublisher<[Account], Never> { 40 | $archivedAccounts.eraseToAnyPublisher() 41 | } 42 | 43 | var availableDomainsPublisher: AnyPublisher<[MTDomain], Never> { 44 | $availableDomains.eraseToAnyPublisher() 45 | } 46 | 47 | var isDomainsLoadingPublisher: AnyPublisher { 48 | $isDomainsLoading.eraseToAnyPublisher() 49 | } 50 | 51 | var totalAccountsCount = 0 52 | @Published var activeAccounts: [Account] = [] 53 | @Published var archivedAccounts: [Account] = [] 54 | 55 | // MARK: Domain properties 56 | @Published var availableDomains: [MTDomain] = [] 57 | @Published var isDomainsLoading = false 58 | 59 | private var persistenceManager: PersistenceManager 60 | private var repository: AccountRepositoryProtocol 61 | private var mtAccountService: MTAccountService 62 | private var domainService: MTDomainService 63 | 64 | var subscriptions = Set() 65 | 66 | private let fetchRequest: NSFetchRequest = Account.fetchRequest() 67 | 68 | var fetchController: NSFetchedResultsController 69 | 70 | init( 71 | persistenceManager: PersistenceManager, 72 | repository: AccountRepositoryProtocol, 73 | accountService: MTAccountService, 74 | domainService: MTDomainService, 75 | fetchController: NSFetchedResultsController? = nil) { 76 | 77 | fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Account.createdAt, ascending: false)] 78 | if let fetchController = fetchController { 79 | self.fetchController = fetchController 80 | } else { 81 | self.fetchController = NSFetchedResultsController(fetchRequest: fetchRequest, 82 | managedObjectContext: persistenceManager.mainContext, 83 | sectionNameKeyPath: nil, 84 | cacheName: nil) 85 | } 86 | self.repository = repository 87 | self.domainService = domainService 88 | self.mtAccountService = accountService 89 | self.persistenceManager = persistenceManager 90 | super.init() 91 | 92 | self.getDomains() 93 | self.fetchController.delegate = self 94 | try? self.fetchController.performFetch() 95 | accountsdidChange() 96 | } 97 | 98 | private func getDomains() { 99 | isDomainsLoading = true 100 | domainService.getAllDomains() 101 | .sink { [weak self] completion in 102 | guard let self = self else { return } 103 | self.isDomainsLoading = false 104 | if case let .failure(error) = completion { 105 | Self.logger.error("\(#function) \(#line): \(error.localizedDescription)") 106 | } 107 | } receiveValue: { [weak self] domains in 108 | guard let self = self else { return } 109 | self.availableDomains = domains 110 | .filter { $0.isActive && !$0.isPrivate } 111 | } 112 | .store(in: &subscriptions) 113 | 114 | } 115 | 116 | func refreshAccount(with account: Account) -> AnyPublisher { 117 | let auth = MTAuth(address: account.address, password: account.password) 118 | 119 | return Future { promise in 120 | self.mtAccountService.login(using: auth) { [weak self] result in 121 | switch result { 122 | case .success(let token): 123 | account.token = token 124 | self?.repository.update(account: account) 125 | promise(.success(true)) 126 | case .failure(let error): 127 | promise(.failure(error)) 128 | } 129 | } 130 | } 131 | .eraseToAnyPublisher() 132 | } 133 | 134 | func createAccount(using auth: MTAuth) -> AnyPublisher { 135 | guard !self.repository.isAccountExists(forAddress: auth.address) else { 136 | return Future { promise in 137 | promise(.failure(.mtError("This account already exists! Please choose a different address"))) 138 | }.eraseToAnyPublisher() 139 | } 140 | 141 | return self.mtAccountService.createAccount(using: auth) 142 | .flatMap { account in 143 | Publishers.Zip( 144 | Deferred { 145 | Future { promise in 146 | promise(.success(account)) 147 | } 148 | }, 149 | 150 | self.mtAccountService.login(using: auth) 151 | ) 152 | } 153 | .eraseToAnyPublisher() 154 | .compactMap { [weak self] (account, token) -> Account? in 155 | guard let self = self else { return nil } 156 | return self.repository.create(account: account, password: auth.password, token: token) 157 | } 158 | .handleEvents(receiveOutput: { [weak self] account in 159 | guard let self = self else { return } 160 | self.activeAccounts.append(account) 161 | }) 162 | .eraseToAnyPublisher() 163 | } 164 | 165 | func archiveAccount(account: Account) { 166 | account.isArchived = true 167 | repository.update(account: account) 168 | } 169 | 170 | func activateAccount(account: Account) { 171 | account.isArchived = false 172 | repository.update(account: account) 173 | } 174 | 175 | func removeAccount(account: Account) { 176 | repository.delete(account: account) 177 | } 178 | 179 | func deleteAndRemoveAccount(account: Account) -> AnyPublisher { 180 | self.mtAccountService.deleteAccount(id: account.id, token: account.token) 181 | .share() 182 | .ignoreOutput() 183 | .handleEvents(receiveCompletion: { [weak self] completion in 184 | guard let self = self else { return } 185 | if case .finished = completion { 186 | self.removeAccount(account: account) 187 | } 188 | }) 189 | .eraseToAnyPublisher() 190 | } 191 | 192 | private func accountsdidChange() { 193 | guard let results = fetchController.fetchedObjects else { 194 | return 195 | } 196 | var tempActiveAccounts = [Account]() 197 | var tempArchivedAccounts = [Account]() 198 | for result in results where !result.isDeleted { 199 | if result.isArchived { 200 | tempArchivedAccounts.append(result) 201 | } else { 202 | tempActiveAccounts.append(result) 203 | } 204 | } 205 | 206 | activeAccounts = tempActiveAccounts 207 | archivedAccounts = tempArchivedAccounts 208 | self.totalAccountsCount = results.count 209 | } 210 | 211 | } 212 | 213 | extension AccountService: NSFetchedResultsControllerDelegate { 214 | 215 | func controllerDidChangeContent(_ controller: NSFetchedResultsController) { 216 | accountsdidChange() 217 | } 218 | 219 | } 220 | -------------------------------------------------------------------------------- /macOS/Services/AttachmentDownloadManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AttachmentDownloadManager.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 06/10/21. 6 | // 7 | 8 | import Foundation 9 | import MailTMSwift 10 | 11 | class AttachmentDownloadManager { 12 | 13 | enum Error: Swift.Error { 14 | case alreadyScheduled 15 | case notScheduled 16 | } 17 | 18 | private var attachmentTasks: [MTAttachment.ID: FileDownloadTask] = [:] 19 | 20 | let fileDownloadManager: FileDownloadManager 21 | 22 | init(fileDownloadManger: FileDownloadManager) { 23 | self.fileDownloadManager = fileDownloadManger 24 | } 25 | 26 | func file(for attachment: MTAttachment) -> FileDownloadTask? { 27 | guard let fileTask = attachmentTasks[attachment.id] else { 28 | return nil 29 | } 30 | return fileDownloadManager.tasks[fileTask.id] 31 | } 32 | 33 | func add(attachment: MTAttachment, for account: Account, afterDownload: ((FileDownloadTask) -> Void)? = nil) throws { 34 | guard attachmentTasks[attachment.id] == nil else { 35 | throw Error.alreadyScheduled 36 | } 37 | 38 | guard !attachment.downloadURL.isEmpty else { 39 | return 40 | } 41 | 42 | let downloadUrl = "https://api.mail.tm" + attachment.downloadURL 43 | 44 | guard let url = URL(string: downloadUrl) else { 45 | return 46 | } 47 | 48 | var request = URLRequest(url: url) 49 | request.allHTTPHeaderFields = ["Authorization": "Bearer \(account.token)"] 50 | request.httpMethod = "GET" 51 | let task = fileDownloadManager.schedule(with: request, fileName: attachment.filename, afterDownload: afterDownload) 52 | attachmentTasks[attachment.id] = task 53 | } 54 | 55 | func download(attachment: MTAttachment, onRenew: ((FileDownloadTask) -> Void)?) { 56 | guard let task = attachmentTasks[attachment.id] else { 57 | return 58 | } 59 | if task.state == .idle { 60 | task.download() 61 | } else { 62 | if let task = renewTask(attachment: attachment, oldTask: task) { 63 | onRenew?(task) 64 | task.download() 65 | } 66 | } 67 | } 68 | 69 | private func renewTask(attachment: MTAttachment, oldTask task: FileDownloadTask) -> FileDownloadTask? { 70 | guard let request = task.task.originalRequest else { 71 | return nil 72 | } 73 | 74 | let afterDownload = task.afterDownload 75 | let task = fileDownloadManager.schedule(with: request, fileName: attachment.filename, afterDownload: afterDownload) 76 | attachmentTasks[attachment.id] = task 77 | return task 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /macOS/Services/FileDownloadManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileDownloader.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 06/10/21. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | import OSLog 11 | import AppKit 12 | 13 | class FileDownloadTask: ObservableObject { 14 | 15 | enum State { 16 | case idle 17 | case downloading 18 | case saved 19 | case error 20 | } 21 | 22 | var id: Int { 23 | task.taskIdentifier 24 | } 25 | 26 | let task: URLSessionDownloadTask 27 | let savedFileLocation: URL 28 | let fileName: String 29 | var error: Error? 30 | var beforeSave: ((URL) -> URL?)? 31 | var afterDownload: ((FileDownloadTask) -> Void)? 32 | 33 | @Published var state: State = .idle 34 | 35 | var progress: Progress { 36 | task.progress 37 | } 38 | 39 | init (task: URLSessionDownloadTask, fileName: String, savedFileLocation: URL, 40 | beforeSave: ((URL) -> URL?)? = nil, afterDownload: ((FileDownloadTask) -> Void)? = nil) { 41 | self.task = task 42 | self.savedFileLocation = savedFileLocation 43 | self.fileName = fileName 44 | self.error = nil 45 | self.beforeSave = beforeSave 46 | self.afterDownload = afterDownload 47 | } 48 | 49 | func download() { 50 | guard state != .downloading else { 51 | return 52 | } 53 | state = .downloading 54 | task.resume() 55 | } 56 | 57 | } 58 | 59 | final class FileDownloadManager: NSObject { 60 | 61 | var tasks: [Int: FileDownloadTask] = [:] 62 | 63 | lazy var session: URLSession = { 64 | URLSession(configuration: .default, delegate: self, delegateQueue: .main) 65 | }() 66 | 67 | private static var downloadDirectoryURL: URL { 68 | FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first! 69 | } 70 | 71 | func schedule(with request: URLRequest, fileName: String, saveLocation: URL? = nil, 72 | beforeSave: ((URL) -> URL?)? = nil, afterDownload: ((FileDownloadTask) -> Void)? = nil) -> FileDownloadTask { 73 | let downloadTask = session.downloadTask(with: request) 74 | let fileURL: URL 75 | if let saveLocation = saveLocation { 76 | fileURL = saveLocation 77 | } else { 78 | fileURL = Self.downloadDirectoryURL.appendingPathComponent(fileName) 79 | } 80 | let fileDownloadTask = FileDownloadTask(task: downloadTask, 81 | fileName: fileName, 82 | savedFileLocation: fileURL, 83 | beforeSave: beforeSave, 84 | afterDownload: afterDownload) 85 | tasks[fileDownloadTask.id] = fileDownloadTask 86 | // swiftlint:disable line_length 87 | Logger.fileDownloadManager.debug("Scheduled downloading task: \(fileDownloadTask.id) fileName: \(fileDownloadTask.fileName) fileUrl: \(fileDownloadTask.savedFileLocation)") 88 | // swiftlint:enable line_length 89 | return fileDownloadTask 90 | } 91 | 92 | } 93 | 94 | extension FileDownloadManager: URLSessionDownloadDelegate { 95 | 96 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { 97 | guard let task = tasks[downloadTask.taskIdentifier] else { 98 | return 99 | } 100 | Logger.fileDownloadManager.debug("Task downloaded: \(task.id)") 101 | let sourceFile = task.beforeSave?(location) ?? location 102 | 103 | let savingLocation = task.savedFileLocation 104 | 105 | do { 106 | let isFileExists = FileManager.default.fileExists(atPath: savingLocation.path) 107 | if isFileExists { 108 | _ = try FileManager.default.replaceItemAt(savingLocation, withItemAt: sourceFile) 109 | Logger.fileDownloadManager.debug("Task file exists: \(task.id), Replacing with \(savingLocation)") 110 | } else { 111 | try FileManager.default.moveItem(at: sourceFile, to: savingLocation) 112 | Logger.fileDownloadManager.debug("Task file saved: \(task.id), location: \(savingLocation)") 113 | } 114 | task.state = .saved 115 | task.afterDownload?(task) 116 | } catch { 117 | Logger.fileDownloadManager.error("\(#function) \(#line) \(error.localizedDescription)") 118 | task.state = .error 119 | task.error = error 120 | } 121 | } 122 | 123 | func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { 124 | guard let error = error, let file = tasks[task.taskIdentifier] else { 125 | return 126 | } 127 | // swiftlint:disable line_length 128 | Logger.fileDownloadManager.error("Error while downloading task, taskId: \(file.id), fileName: \(file.fileName), Error: \(error.localizedDescription)") 129 | // swiftlint:enable line_length 130 | file.state = .error 131 | file.error = error 132 | 133 | } 134 | 135 | } 136 | -------------------------------------------------------------------------------- /macOS/Services/MessageDownloadManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageDownloadManager.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 09/10/21. 6 | // 7 | 8 | import Foundation 9 | import Resolver 10 | import MailTMSwift 11 | import OSLog 12 | 13 | class MessageDownloadManager { 14 | static let logger = Logger(subsystem: Logger.subsystem, category: String(describing: MessageDownloadManager.self)) 15 | private var messageDownloadTasks: [Message.ID: FileDownloadTask] = [:] 16 | 17 | private let fileDownloadManager: FileDownloadManager 18 | 19 | private let decoder = JSONDecoder() 20 | 21 | init(fileDownloadManger: FileDownloadManager = Resolver.resolve()) { 22 | self.fileDownloadManager = fileDownloadManger 23 | } 24 | 25 | func file(for message: Message) -> FileDownloadTask? { 26 | guard let fileTask = messageDownloadTasks[message.id] else { 27 | return nil 28 | } 29 | return fileDownloadManager.tasks[fileTask.id] 30 | } 31 | 32 | func download(message: Message, request: URLRequest, saveLocation: URL, afterDownload: ((FileDownloadTask) -> Void)? = nil) -> FileDownloadTask { 33 | let fileName: String 34 | if message.data.subject.isEmpty { 35 | fileName = "message.eml" 36 | } else { 37 | fileName = "\(message.data.subject).eml" 38 | } 39 | let task = fileDownloadManager.schedule(with: request, fileName: fileName, saveLocation: saveLocation, 40 | beforeSave: extractSource(location:), afterDownload: afterDownload) 41 | messageDownloadTasks[message.id] = task 42 | task.download() 43 | return task 44 | } 45 | 46 | func extractSource(location: URL) -> URL? { 47 | guard FileManager.default.fileExists(atPath: location.path) else { 48 | return nil 49 | } 50 | 51 | do { 52 | guard let data = try String(contentsOf: location).data(using: .utf8) else { 53 | return nil 54 | } 55 | let sourceObj = try decoder.decode(MTMessageSource.self, from: data) 56 | 57 | let temporaryDirectoryURL = 58 | try FileManager.default.url(for: .itemReplacementDirectory, 59 | in: .userDomainMask, 60 | appropriateFor: location, 61 | create: true) 62 | 63 | let temporaryFilename = UUID().uuidString 64 | 65 | let temporaryFileURL = 66 | temporaryDirectoryURL.appendingPathComponent(temporaryFilename) 67 | 68 | try sourceObj.data.write(to: temporaryFileURL, atomically: true, encoding: .utf8) 69 | return temporaryFileURL 70 | } catch { 71 | Self.logger.error("\(#function) \(#line) \(error.localizedDescription)") 72 | } 73 | return nil 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /macOS/Services/MessagesListenerService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MessageFetchingService.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 28/09/21. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | import Resolver 11 | import MailTMSwift 12 | 13 | struct MessageReceived { 14 | let account: Account 15 | let message: MTMessage 16 | } 17 | 18 | class MessagesListenerService { 19 | 20 | private var accountService: AccountServiceProtocol 21 | private var accountRepository: AccountRepository 22 | private var channels: [Account: MTLiveMessageProtocol] = [:] 23 | @Published var channelsStatus: [Account: MTLiveMailService.State] = [:] 24 | 25 | private var subscriptions = Set() 26 | 27 | var onMessageReceivedPublisher: AnyPublisher { 28 | _messageReceivedPublisher.eraseToAnyPublisher() 29 | } 30 | 31 | var onMessageDeletedPublisher: AnyPublisher { 32 | _onMessageDeletedPublisher.eraseToAnyPublisher() 33 | } 34 | 35 | private let _messageReceivedPublisher = PassthroughSubject() 36 | private let _onMessageDeletedPublisher = PassthroughSubject() 37 | 38 | init(accountService: AccountServiceProtocol, accountRepository: AccountRepository) { 39 | self.accountRepository = accountRepository 40 | self.accountService = accountService 41 | listenToAccounts() 42 | } 43 | 44 | func listenToAccounts() { 45 | accountService 46 | .activeAccountsPublisher 47 | .sink { [weak self] accounts in 48 | guard let self = self else { return } 49 | let exisitingChannels = Array(self.channels.keys) 50 | let difference = accounts.difference(from: exisitingChannels) 51 | difference.insertions.forEach { change in 52 | if case let .insert(offset: _, element: account, associatedWith: _) = change { 53 | self.addChannelAndStartListening(account: account) 54 | } 55 | } 56 | 57 | difference.removals.forEach { change in 58 | if case let .remove(offset: _, element: account, associatedWith: _) = change { 59 | self.stopListeningAndRemoveChannel(account: account) 60 | } 61 | } 62 | } 63 | .store(in: &subscriptions) 64 | } 65 | 66 | func addChannelAndStartListening(account: Account) { 67 | let messageListener = createListener(withToken: account.token, accountId: account.id) 68 | channels[account] = messageListener 69 | channelsStatus[account] = .closed 70 | 71 | messageListener.messagePublisher.sink { [weak self] message in 72 | guard let self = self else { return } 73 | if message.isDeleted { 74 | self._onMessageDeletedPublisher.send(MessageReceived(account: account, message: message)) 75 | } else { 76 | self._messageReceivedPublisher.send(MessageReceived(account: account, message: message)) 77 | } 78 | } 79 | .store(in: &subscriptions) 80 | 81 | messageListener.accountPublisher.sink { [weak self] mtAccount in 82 | guard let self = self else { return } 83 | if let account = self.accountRepository.getAccount(fromId: mtAccount.id) { 84 | account.set(from: mtAccount, password: account.password, token: account.token) 85 | self.accountRepository.update(account: account) 86 | } 87 | } 88 | .store(in: &subscriptions) 89 | 90 | messageListener.statePublisher 91 | .sink { [weak self] state in 92 | guard let self = self else { return } 93 | self.channelsStatus[account] = state 94 | } 95 | .store(in: &subscriptions) 96 | 97 | messageListener.start() 98 | } 99 | 100 | func stopListeningAndRemoveChannel(account: Account) { 101 | if let existingListener = channels[account] { 102 | existingListener.stop() 103 | channels.removeValue(forKey: account) 104 | channelsStatus.removeValue(forKey: account) 105 | } 106 | } 107 | 108 | func restartChannel(account: Account) { 109 | if let existingListener = channels[account] { 110 | existingListener.restart() 111 | } 112 | } 113 | 114 | internal func createListener(withToken token: String, accountId: String) -> MTLiveMessageProtocol { 115 | // NOTE: When testing, This class is replaced with Fake 116 | return MTLiveMailService(token: token, accountId: accountId) 117 | } 118 | 119 | } 120 | -------------------------------------------------------------------------------- /macOS/Services/Services+Injection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Services+Injection.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 22/09/21. 6 | // 7 | 8 | import Resolver 9 | 10 | extension Resolver { 11 | public static func registerServices() { 12 | register { 13 | AccountService(persistenceManager: resolve(), 14 | repository: resolve(), 15 | accountService: resolve(), 16 | domainService: resolve()) 17 | }.implements(AccountServiceProtocol.self) 18 | 19 | register { 20 | MessagesListenerService(accountService: resolve(), accountRepository: resolve()) 21 | } 22 | 23 | register { 24 | FileDownloadManager() 25 | } 26 | 27 | register { 28 | AttachmentDownloadManager(fileDownloadManger: resolve()) 29 | } 30 | 31 | register { 32 | MessageDownloadManager(fileDownloadManger: resolve()) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /macOS/Shared Views/BadgeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BadgeView.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 17/09/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BadgeView: View { 11 | let model: BadgeViewModel 12 | 13 | let font = Font.system(size: 10, weight: .regular) 14 | let padding = EdgeInsets(top: 2, leading: 6, bottom: 2, trailing: 6) 15 | 16 | var body: some View { 17 | Text(model.title) 18 | .font(font) 19 | .foregroundColor(model.textColor) 20 | .padding(padding) 21 | .background(model.color) 22 | .cornerRadius(20) 23 | } 24 | } 25 | 26 | struct BadgeView_Previews: PreviewProvider { 27 | static var previews: some View { 28 | Group { 29 | BadgeView(model: BadgeViewModel(title: "DEBUG", color: Color.red.opacity(0.3))) 30 | } 31 | } 32 | } 33 | 34 | struct BadgeViewModel { 35 | let title: String 36 | let color: Color 37 | var textColor: Color = .primary 38 | } 39 | -------------------------------------------------------------------------------- /macOS/Shared Views/ToolbarDivider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToolbarDivider.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 03/10/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ToolbarDivider: ToolbarContent { 11 | var body: some ToolbarContent { 12 | ToolbarItem { 13 | Rectangle() 14 | .frame(width: 1, height: 18) 15 | .opacity(0.1) 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /macOS/Shared Views/WebView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WebView.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 17/09/21. 6 | // 7 | 8 | import SwiftUI 9 | import WebKit 10 | 11 | struct WebView: View { 12 | var html: String 13 | 14 | var body: some View { 15 | WebViewWrapper(html: html) 16 | } 17 | } 18 | 19 | struct WebViewWrapper: NSViewRepresentable { 20 | let html: String 21 | 22 | func makeNSView(context: Context) -> WKWebView { 23 | return WKWebView() 24 | } 25 | 26 | func updateNSView(_ nsView: WKWebView, context: Context) { 27 | nsView.loadHTMLString(html, baseURL: nil) 28 | } 29 | } 30 | 31 | struct WebView_Previews: PreviewProvider { 32 | static var previews: some View { 33 | WebView(html: "

It works?

") 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /macOS/Supporting files/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | }, 6 | { 7 | "appearances" : [ 8 | { 9 | "appearance" : "luminosity", 10 | "value" : "dark" 11 | } 12 | ], 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /macOS/Supporting files/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_16@1x.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "icon_16@2x.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "icon_32@1x.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "icon_32@2x.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "icon_128@1x.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "icon_128@2x.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "icon_256@1x.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "icon_256@2x.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "icon_512@1x.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "icon_512@2x.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /macOS/Supporting files/Assets.xcassets/AppIcon.appiconset/icon_128@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devwaseem/TempBox/65cbfa0ce60f64f1dd881c8a6839cd463f8ad476/macOS/Supporting files/Assets.xcassets/AppIcon.appiconset/icon_128@1x.png -------------------------------------------------------------------------------- /macOS/Supporting files/Assets.xcassets/AppIcon.appiconset/icon_128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devwaseem/TempBox/65cbfa0ce60f64f1dd881c8a6839cd463f8ad476/macOS/Supporting files/Assets.xcassets/AppIcon.appiconset/icon_128@2x.png -------------------------------------------------------------------------------- /macOS/Supporting files/Assets.xcassets/AppIcon.appiconset/icon_16@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devwaseem/TempBox/65cbfa0ce60f64f1dd881c8a6839cd463f8ad476/macOS/Supporting files/Assets.xcassets/AppIcon.appiconset/icon_16@1x.png -------------------------------------------------------------------------------- /macOS/Supporting files/Assets.xcassets/AppIcon.appiconset/icon_16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devwaseem/TempBox/65cbfa0ce60f64f1dd881c8a6839cd463f8ad476/macOS/Supporting files/Assets.xcassets/AppIcon.appiconset/icon_16@2x.png -------------------------------------------------------------------------------- /macOS/Supporting files/Assets.xcassets/AppIcon.appiconset/icon_256@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devwaseem/TempBox/65cbfa0ce60f64f1dd881c8a6839cd463f8ad476/macOS/Supporting files/Assets.xcassets/AppIcon.appiconset/icon_256@1x.png -------------------------------------------------------------------------------- /macOS/Supporting files/Assets.xcassets/AppIcon.appiconset/icon_256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devwaseem/TempBox/65cbfa0ce60f64f1dd881c8a6839cd463f8ad476/macOS/Supporting files/Assets.xcassets/AppIcon.appiconset/icon_256@2x.png -------------------------------------------------------------------------------- /macOS/Supporting files/Assets.xcassets/AppIcon.appiconset/icon_32@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devwaseem/TempBox/65cbfa0ce60f64f1dd881c8a6839cd463f8ad476/macOS/Supporting files/Assets.xcassets/AppIcon.appiconset/icon_32@1x.png -------------------------------------------------------------------------------- /macOS/Supporting files/Assets.xcassets/AppIcon.appiconset/icon_32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devwaseem/TempBox/65cbfa0ce60f64f1dd881c8a6839cd463f8ad476/macOS/Supporting files/Assets.xcassets/AppIcon.appiconset/icon_32@2x.png -------------------------------------------------------------------------------- /macOS/Supporting files/Assets.xcassets/AppIcon.appiconset/icon_512@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devwaseem/TempBox/65cbfa0ce60f64f1dd881c8a6839cd463f8ad476/macOS/Supporting files/Assets.xcassets/AppIcon.appiconset/icon_512@1x.png -------------------------------------------------------------------------------- /macOS/Supporting files/Assets.xcassets/AppIcon.appiconset/icon_512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devwaseem/TempBox/65cbfa0ce60f64f1dd881c8a6839cd463f8ad476/macOS/Supporting files/Assets.xcassets/AppIcon.appiconset/icon_512@2x.png -------------------------------------------------------------------------------- /macOS/Supporting files/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /macOS/Supporting files/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | $(MARKETING_VERSION) 21 | CFBundleURLTypes 22 | 23 | 24 | CFBundleTypeRole 25 | Editor 26 | CFBundleURLSchemes 27 | 28 | tempbox 29 | 30 | 31 | 32 | CFBundleVersion 33 | 1 34 | LSMinimumSystemVersion 35 | $(MACOSX_DEPLOYMENT_TARGET) 36 | SUFeedURL 37 | https://tempbox.waseem.works/appcast.xml 38 | SUPublicEDKey 39 | PT4SSsorf7XqzStOwHacUK94e0TYMueytNvSQWfvPgQ= 40 | 41 | 42 | -------------------------------------------------------------------------------- /macOS/Supporting files/macOS.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.downloads.read-write 8 | 9 | com.apple.security.files.user-selected.read-write 10 | 11 | com.apple.security.network.client 12 | 13 | com.apple.security.network.server 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /macOS/TempBoxApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TempBoxApp.swift 3 | // Shared 4 | // 5 | // Created by Waseem Akram on 16/09/21. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | import Resolver 11 | import Sparkle 12 | 13 | @main 14 | struct TempBoxApp: App { 15 | 16 | // swiftlint:disable weak_delegate 17 | @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate 18 | // swiftlint:enable weak_delegate 19 | 20 | @StateObject var appController = AppController() 21 | @StateObject var updaterViewController = UpdaterViewController() 22 | 23 | var body: some Scene { 24 | WindowGroup { 25 | RootNavigationView() 26 | .environmentObject(appController) 27 | } 28 | .commands { 29 | SidebarCommands() 30 | CommandGroup(replacing: .help) { 31 | Button("Github") { 32 | NSWorkspace.shared.open(URL(string: "https://github.com/devwaseem/TempBox")!) 33 | } 34 | Button("Website") { 35 | NSWorkspace.shared.open(URL(string: "https://tempbox.waseem.works")!) 36 | } 37 | Button("Developer") { 38 | NSWorkspace.shared.open(URL(string: "https://waseem.works")!) 39 | } 40 | Divider() 41 | Button("API") { 42 | NSWorkspace.shared.open(URL(string: "https://docs.mail.tm")!) 43 | } 44 | Button("FAQ") { 45 | NSWorkspace.shared.open(URL(string: "https://mail.tm/en/faq/")!) 46 | } 47 | Button("Privacy policy") { 48 | NSWorkspace.shared.open(URL(string: "https://mail.tm/en/privacy/")!) 49 | } 50 | Button("Contact Mail.tm") { 51 | NSWorkspace.shared.open(URL(string: "https://mail.tm/en/contact/")!) 52 | } 53 | } 54 | 55 | CommandGroup(replacing: .newItem) { 56 | Button("New Address") { 57 | NotificationCenter.default.post(name: .newAddress, object: nil) 58 | } 59 | .keyboardShortcut("n") 60 | 61 | } 62 | 63 | CommandGroup(after: .appInfo) { 64 | CheckForUpdatesView(updaterViewController: updaterViewController) 65 | } 66 | } 67 | 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /macOS/Updater/CheckForUpdatesView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CheckForUpdatesView.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 28/10/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CheckForUpdatesView: View { 11 | @ObservedObject var updaterViewController: UpdaterViewController 12 | 13 | var body: some View { 14 | Button("Check For Updates…", action: updaterViewController.checkForUpdates) 15 | .disabled(!updaterViewController.canCheckForUpdates) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /macOS/Updater/UpdaterViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UpdaterViewController.swift 3 | // TempBox (macOS) 4 | // 5 | // Created by Waseem Akram on 28/10/21. 6 | // 7 | 8 | import Foundation 9 | import Sparkle 10 | import SwiftUI 11 | 12 | final class UpdaterViewController: ObservableObject { 13 | private let updaterController: SPUStandardUpdaterController 14 | 15 | @Published var canCheckForUpdates = false 16 | 17 | init() { 18 | // If you want to start the updater manually, pass false to startingUpdater and call .startUpdater() later 19 | // This is where you can also pass an updater delegate if you need one 20 | updaterController = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) 21 | 22 | updaterController.updater.publisher(for: \.canCheckForUpdates) 23 | .assign(to: &$canCheckForUpdates) 24 | } 25 | 26 | func checkForUpdates() { 27 | updaterController.checkForUpdates(nil) 28 | } 29 | } 30 | --------------------------------------------------------------------------------