├── .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 | 
9 |
10 |
11 |
12 |
13 |
14 |
15 | 
16 |
17 | 
18 |
19 | 
20 |
21 | 
22 |
23 | 
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 | [](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 |
--------------------------------------------------------------------------------