diff --git a/Wino.Core.Domain/Entities/MailAccount.cs b/Wino.Core.Domain/Entities/MailAccount.cs index dbeac65c..e668a6a3 100644 --- a/Wino.Core.Domain/Entities/MailAccount.cs +++ b/Wino.Core.Domain/Entities/MailAccount.cs @@ -44,6 +44,11 @@ namespace Wino.Core.Domain.Entities /// public string AccountColorHex { get; set; } + /// + /// Base64 encoded profile picture of the account. + /// + public string Base64ProfilePictureData { get; set; } + /// /// Gets or sets the listing order of the account in the accounts list. /// @@ -78,5 +83,15 @@ namespace Wino.Core.Domain.Entities /// [Ignore] public MailAccountPreferences Preferences { get; set; } + + /// + /// Gets whether the account can perform ProfileInformation sync type. + /// + public bool IsProfileInfoSyncSupported => ProviderType == MailProviderType.Outlook || ProviderType == MailProviderType.Office365 || ProviderType == MailProviderType.Gmail; + + /// + /// Gets whether the account can perform AliasInformation sync type. + /// + public bool IsAliasSyncSupported => ProviderType == MailProviderType.Gmail; } } diff --git a/Wino.Core.Domain/Entities/MailAccountAlias.cs b/Wino.Core.Domain/Entities/MailAccountAlias.cs new file mode 100644 index 00000000..984d3460 --- /dev/null +++ b/Wino.Core.Domain/Entities/MailAccountAlias.cs @@ -0,0 +1,56 @@ +using System; +using SQLite; + +namespace Wino.Core.Domain.Entities +{ + public class RemoteAccountAlias + { + /// + /// Display address of the alias. + /// + public string AliasAddress { get; set; } + + /// + /// Address to be included in Reply-To header when alias is used for sending messages. + /// + public string ReplyToAddress { get; set; } + + /// + /// Whether this alias is the primary alias for the account. + /// + public bool IsPrimary { get; set; } + + /// + /// Whether the alias is verified by the server. + /// Only Gmail aliases are verified for now. + /// Non-verified alias messages might be rejected by SMTP server. + /// + public bool IsVerified { get; set; } + + /// + /// Whether this alias is the root alias for the account. + /// Root alias means the first alias that was created for the account. + /// It can't be deleted or changed. + /// + public bool IsRootAlias { get; set; } + } + + public class MailAccountAlias : RemoteAccountAlias + { + /// + /// Unique Id for the alias. + /// + [PrimaryKey] + public Guid Id { get; set; } + + /// + /// Account id that this alias is attached to. + /// + public Guid AccountId { get; set; } + + /// + /// Root aliases can't be deleted. + /// + public bool CanDelete => !IsRootAlias; + } +} diff --git a/Wino.Core.Domain/Entities/MailCopy.cs b/Wino.Core.Domain/Entities/MailCopy.cs index 489bc916..eeb81ccd 100644 --- a/Wino.Core.Domain/Entities/MailCopy.cs +++ b/Wino.Core.Domain/Entities/MailCopy.cs @@ -141,7 +141,7 @@ namespace Wino.Core.Domain.Entities /// [Ignore] public MailAccount AssignedAccount { get; set; } - public IEnumerable GetContainingIds() => new[] { UniqueId }; + public IEnumerable GetContainingIds() => [UniqueId]; public override string ToString() => $"{Subject} <-> {Id}"; } } diff --git a/Wino.Core.Domain/Enums/AccountCreationDialogState.cs b/Wino.Core.Domain/Enums/AccountCreationDialogState.cs index af724f47..947449d4 100644 --- a/Wino.Core.Domain/Enums/AccountCreationDialogState.cs +++ b/Wino.Core.Domain/Enums/AccountCreationDialogState.cs @@ -9,6 +9,7 @@ ManuelSetupWaiting, TestingConnection, AutoDiscoverySetup, - AutoDiscoveryInProgress + AutoDiscoveryInProgress, + FetchingProfileInformation } } diff --git a/Wino.Core.Domain/Enums/SynchronizationType.cs b/Wino.Core.Domain/Enums/SynchronizationType.cs index c95b7d52..a8cfba1d 100644 --- a/Wino.Core.Domain/Enums/SynchronizationType.cs +++ b/Wino.Core.Domain/Enums/SynchronizationType.cs @@ -4,8 +4,10 @@ { FoldersOnly, // Only synchronize folder metadata. ExecuteRequests, // Run the queued requests, and then synchronize if needed. - Inbox, // Only Inbox + Inbox, // Only Inbox, Sent and Draft folders. Custom, // Only sync folders that are specified in the options. - Full, // Synchronize everything + Full, // Synchronize all folders. This won't update profile or alias information. + UpdateProfile, // Only update profile information + Alias, // Only update alias information } } diff --git a/Wino.Core.Domain/Enums/WinoPage.cs b/Wino.Core.Domain/Enums/WinoPage.cs index 2a7264a2..cf3b5292 100644 --- a/Wino.Core.Domain/Enums/WinoPage.cs +++ b/Wino.Core.Domain/Enums/WinoPage.cs @@ -23,5 +23,6 @@ LanguageTimePage, AppPreferencesPage, SettingOptionsPage, + AliasManagementPage } } diff --git a/Wino.Core.Domain/Exceptions/MissingAliasException.cs b/Wino.Core.Domain/Exceptions/MissingAliasException.cs new file mode 100644 index 00000000..f81cb8c6 --- /dev/null +++ b/Wino.Core.Domain/Exceptions/MissingAliasException.cs @@ -0,0 +1,7 @@ +namespace Wino.Core.Domain.Exceptions +{ + public class MissingAliasException : System.Exception + { + public MissingAliasException() : base(Translator.Exception_MissingAlias) { } + } +} diff --git a/Wino.Core.Domain/Interfaces/IAccountService.cs b/Wino.Core.Domain/Interfaces/IAccountService.cs index 78682cda..128dac82 100644 --- a/Wino.Core.Domain/Interfaces/IAccountService.cs +++ b/Wino.Core.Domain/Interfaces/IAccountService.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using Wino.Core.Domain.Entities; +using Wino.Core.Domain.Models.Accounts; namespace Wino.Core.Domain.Interfaces { @@ -100,5 +101,59 @@ namespace Wino.Core.Domain.Interfaces /// /// AccountId-OrderNumber pair for all accounts. Task UpdateAccountOrdersAsync(Dictionary accountIdOrderPair); + + /// + /// Returns the account aliases. + /// + /// Account id. + /// A list of MailAccountAlias that has e-mail aliases. + Task> GetAccountAliasesAsync(Guid accountId); + + /// + /// Updated account's aliases. + /// + /// Account id to update aliases for. + /// Full list of updated aliases. + /// + Task UpdateAccountAliasesAsync(Guid accountId, List aliases); + + /// + /// Delete account alias. + /// + /// Alias to remove. + Task DeleteAccountAliasAsync(Guid aliasId); + + /// + /// Updated profile information of the account. + /// + /// Account id to update info for. + /// Info data. + /// + Task UpdateProfileInformationAsync(Guid accountId, ProfileInformation profileInformation); + + + /// + /// Creates a root + primary alias for the account. + /// This is only called when the account is created. + /// + /// Account id. + /// Address to create root primary alias from. + Task CreateRootAliasAsync(Guid accountId, string address); + + /// + /// Will compare local-remote aliases and update the local ones or add/delete new ones. + /// + /// Remotely fetched basic alias info from synchronizer. + /// Account to update remote aliases for.. + Task UpdateRemoteAliasInformationAsync(MailAccount account, List remoteAccountAliases); + + /// + /// Gets the primary account alias for the given account id. + /// Used when creating draft messages. + /// + /// Account id. + /// Primary alias for the account. + Task GetPrimaryAccountAliasAsync(Guid accountId); + } } diff --git a/Wino.Core.Domain/Interfaces/IBaseSynchronizer.cs b/Wino.Core.Domain/Interfaces/IBaseSynchronizer.cs index 21ca1f8a..fe0044a5 100644 --- a/Wino.Core.Domain/Interfaces/IBaseSynchronizer.cs +++ b/Wino.Core.Domain/Interfaces/IBaseSynchronizer.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using MailKit; using Wino.Core.Domain.Entities; using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.Synchronization; @@ -43,6 +44,13 @@ namespace Wino.Core.Domain.Interfaces /// Result summary of synchronization. Task SynchronizeAsync(SynchronizationOptions options, CancellationToken cancellationToken = default); + /// + /// Synchronizes profile information with the server. + /// Sender name and Profile picture are updated. + /// + /// Profile information model that holds the values. + Task GetProfileInformationAsync(); + /// /// Downloads a single MIME message from the server and saves it to disk. /// diff --git a/Wino.Core.Domain/Interfaces/ICreateAccountAliasDialog.cs b/Wino.Core.Domain/Interfaces/ICreateAccountAliasDialog.cs new file mode 100644 index 00000000..d27a0229 --- /dev/null +++ b/Wino.Core.Domain/Interfaces/ICreateAccountAliasDialog.cs @@ -0,0 +1,9 @@ +using Wino.Core.Domain.Entities; + +namespace Wino.Core.Domain.Interfaces +{ + public interface ICreateAccountAliasDialog + { + public MailAccountAlias CreatedAccountAlias { get; set; } + } +} diff --git a/Wino.Core.Domain/Interfaces/AfterRequestExecutionSynchronizationInterfaces.cs b/Wino.Core.Domain/Interfaces/ICustomFolderSynchronizationRequest.cs similarity index 100% rename from Wino.Core.Domain/Interfaces/AfterRequestExecutionSynchronizationInterfaces.cs rename to Wino.Core.Domain/Interfaces/ICustomFolderSynchronizationRequest.cs diff --git a/Wino.Core.Domain/Interfaces/IDialogService.cs b/Wino.Core.Domain/Interfaces/IDialogService.cs index a2986bd9..9d5fad1b 100644 --- a/Wino.Core.Domain/Interfaces/IDialogService.cs +++ b/Wino.Core.Domain/Interfaces/IDialogService.cs @@ -53,5 +53,6 @@ namespace Wino.Core.Domain.Interfaces /// /// Signature information. Null if canceled. Task ShowSignatureEditorDialog(AccountSignature signatureModel = null); + Task ShowCreateAccountAliasDialogAsync(); } } diff --git a/Wino.Core.Domain/Models/Accounts/AccountCreationDialogResult.cs b/Wino.Core.Domain/Models/Accounts/AccountCreationDialogResult.cs index a1081b9e..428d1307 100644 --- a/Wino.Core.Domain/Models/Accounts/AccountCreationDialogResult.cs +++ b/Wino.Core.Domain/Models/Accounts/AccountCreationDialogResult.cs @@ -2,5 +2,5 @@ namespace Wino.Core.Domain.Models.Accounts { - public record AccountCreationDialogResult(MailProviderType ProviderType, string AccountName, string SenderName, string AccountColorHex = ""); + public record AccountCreationDialogResult(MailProviderType ProviderType, string AccountName, string AccountColorHex = ""); } diff --git a/Wino.Core.Domain/Models/Accounts/ProfileInformation.cs b/Wino.Core.Domain/Models/Accounts/ProfileInformation.cs new file mode 100644 index 00000000..b9fb5677 --- /dev/null +++ b/Wino.Core.Domain/Models/Accounts/ProfileInformation.cs @@ -0,0 +1,9 @@ +namespace Wino.Core.Domain.Models.Accounts +{ + /// + /// Encapsulates the profile information of an account. + /// + /// Display sender name for the account. + /// Base 64 encoded profile picture data of the account. Thumbnail size. + public record ProfileInformation(string SenderName, string Base64ProfilePictureData); +} diff --git a/Wino.Core.Domain/Models/Accounts/ProviderDetail.cs b/Wino.Core.Domain/Models/Accounts/ProviderDetail.cs index 4f794749..dc9a5824 100644 --- a/Wino.Core.Domain/Models/Accounts/ProviderDetail.cs +++ b/Wino.Core.Domain/Models/Accounts/ProviderDetail.cs @@ -14,7 +14,6 @@ namespace Wino.Core.Domain.Models.Accounts public string ProviderImage => $"ms-appx:///Assets/Providers/{Type}.png"; public bool IsSupported => Type == MailProviderType.Outlook || Type == MailProviderType.Gmail || Type == MailProviderType.IMAP4; - public bool RequireSenderNameOnCreationDialog => Type != MailProviderType.IMAP4; public ProviderDetail(MailProviderType type) { diff --git a/Wino.Core.Domain/Models/Authorization/GoogleAuthorizationRequest.cs b/Wino.Core.Domain/Models/Authorization/GoogleAuthorizationRequest.cs index 455fff47..bef11d59 100644 --- a/Wino.Core.Domain/Models/Authorization/GoogleAuthorizationRequest.cs +++ b/Wino.Core.Domain/Models/Authorization/GoogleAuthorizationRequest.cs @@ -33,7 +33,7 @@ namespace Wino.Core.Domain.Models.Authorization ClientId = clientId; // Creates the OAuth 2.0 authorization request. - return string.Format("{0}?response_type=code&scope=https://mail.google.com/ https://www.googleapis.com/auth/gmail.labels&redirect_uri={1}&client_id={2}&state={3}&code_challenge={4}&code_challenge_method={5}", + return string.Format("{0}?response_type=code&scope=https://mail.google.com/ https://www.googleapis.com/auth/gmail.labels https://www.googleapis.com/auth/userinfo.profile&redirect_uri={1}&client_id={2}&state={3}&code_challenge={4}&code_challenge_method={5}", authorizationEndpoint, Uri.EscapeDataString(RedirectUri), ClientId, diff --git a/Wino.Core.Domain/Models/MailItem/DraftPreparationRequest.cs b/Wino.Core.Domain/Models/MailItem/DraftPreparationRequest.cs index 8d7809b6..af8bf6b1 100644 --- a/Wino.Core.Domain/Models/MailItem/DraftPreparationRequest.cs +++ b/Wino.Core.Domain/Models/MailItem/DraftPreparationRequest.cs @@ -2,13 +2,18 @@ using System.Text.Json.Serialization; using MimeKit; using Wino.Core.Domain.Entities; +using Wino.Core.Domain.Enums; using Wino.Core.Domain.Extensions; namespace Wino.Core.Domain.Models.MailItem; public class DraftPreparationRequest { - public DraftPreparationRequest(MailAccount account, MailCopy createdLocalDraftCopy, string base64EncodedMimeMessage, MailCopy referenceMailCopy = null) + public DraftPreparationRequest(MailAccount account, + MailCopy createdLocalDraftCopy, + string base64EncodedMimeMessage, + DraftCreationReason reason, + MailCopy referenceMailCopy = null) { Account = account ?? throw new ArgumentNullException(nameof(account)); @@ -19,6 +24,7 @@ public class DraftPreparationRequest // This is additional work when deserialization needed, but not much to do atm. Base64LocalDraftMimeMessage = base64EncodedMimeMessage; + Reason = reason; } [JsonConstructor] @@ -29,6 +35,7 @@ public class DraftPreparationRequest public MailCopy ReferenceMailCopy { get; set; } public string Base64LocalDraftMimeMessage { get; set; } + public DraftCreationReason Reason { get; set; } [JsonIgnore] private MimeMessage createdLocalDraftMimeMessage; @@ -44,5 +51,5 @@ public class DraftPreparationRequest } } - public MailAccount Account { get; } + public MailAccount Account { get; set; } } diff --git a/Wino.Core.Domain/Models/MailItem/SendDraftPreparationRequest.cs b/Wino.Core.Domain/Models/MailItem/SendDraftPreparationRequest.cs index 41f7a17b..7cabb4eb 100644 --- a/Wino.Core.Domain/Models/MailItem/SendDraftPreparationRequest.cs +++ b/Wino.Core.Domain/Models/MailItem/SendDraftPreparationRequest.cs @@ -5,45 +5,17 @@ using Wino.Core.Domain.Extensions; namespace Wino.Core.Domain.Models.MailItem { - public class SendDraftPreparationRequest + public record SendDraftPreparationRequest(MailCopy MailItem, + MailAccountAlias SendingAlias, + MailItemFolder SentFolder, + MailItemFolder DraftFolder, + MailAccountPreferences AccountPreferences, + string Base64MimeMessage) { - public MailCopy MailItem { get; set; } - public string Base64MimeMessage { get; set; } - public MailItemFolder SentFolder { get; set; } - public MailItemFolder DraftFolder { get; set; } - public MailAccountPreferences AccountPreferences { get; set; } - - public SendDraftPreparationRequest(MailCopy mailItem, - MailItemFolder sentFolder, - MailItemFolder draftFolder, - MailAccountPreferences accountPreferences, - string base64MimeMessage) - { - MailItem = mailItem; - SentFolder = sentFolder; - DraftFolder = draftFolder; - AccountPreferences = accountPreferences; - Base64MimeMessage = base64MimeMessage; - } - - [JsonConstructor] - private SendDraftPreparationRequest() { } - [JsonIgnore] private MimeMessage mime; [JsonIgnore] - public MimeMessage Mime - { - get - { - if (mime == null) - { - mime = Base64MimeMessage.GetMimeMessageFromBase64(); - } - - return mime; - } - } + public MimeMessage Mime => mime ??= Base64MimeMessage.GetMimeMessageFromBase64(); } } diff --git a/Wino.Core.Domain/Models/Requests/ServerRequestPackage.cs b/Wino.Core.Domain/Models/Requests/ServerRequestPackage.cs index 98ca5792..31a408a2 100644 --- a/Wino.Core.Domain/Models/Requests/ServerRequestPackage.cs +++ b/Wino.Core.Domain/Models/Requests/ServerRequestPackage.cs @@ -3,14 +3,11 @@ using Wino.Core.Domain.Interfaces; namespace Wino.Core.Domain.Models.Requests { - /// /// Encapsulates request to queue and account for synchronizer. /// - /// - /// + /// Which account to execute this request for. /// Prepared request for the server. - /// Whihc account to execute this request for. public record ServerRequestPackage(Guid AccountId, IRequestBase Request) : IClientMessage { public override string ToString() => $"Server Package: {Request.GetType().Name}"; diff --git a/Wino.Core.Domain/Models/Synchronization/SynchronizationResult.cs b/Wino.Core.Domain/Models/Synchronization/SynchronizationResult.cs index 19e45eb7..0e98844a 100644 --- a/Wino.Core.Domain/Models/Synchronization/SynchronizationResult.cs +++ b/Wino.Core.Domain/Models/Synchronization/SynchronizationResult.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Text.Json.Serialization; using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.MailItem; namespace Wino.Core.Domain.Models.Synchronization @@ -15,14 +16,23 @@ namespace Wino.Core.Domain.Models.Synchronization /// It's ignored in serialization. Client should not react to this. /// [JsonIgnore] - public IEnumerable DownloadedMessages { get; set; } = new List(); + public IEnumerable DownloadedMessages { get; set; } = []; + + public ProfileInformation ProfileInformation { get; set; } + public SynchronizationCompletedState CompletedState { get; set; } public static SynchronizationResult Empty => new() { CompletedState = SynchronizationCompletedState.Success }; - public static SynchronizationResult Completed(IEnumerable downloadedMessages) - => new() { DownloadedMessages = downloadedMessages, CompletedState = SynchronizationCompletedState.Success }; + public static SynchronizationResult Completed(IEnumerable downloadedMessages, ProfileInformation profileInformation = null) + => new() + { + DownloadedMessages = downloadedMessages, + ProfileInformation = profileInformation, + CompletedState = SynchronizationCompletedState.Success + }; public static SynchronizationResult Canceled => new() { CompletedState = SynchronizationCompletedState.Canceled }; + public static SynchronizationResult Failed => new() { CompletedState = SynchronizationCompletedState.Failed }; } } diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index 2f6699c7..7f61605a 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -3,6 +3,7 @@ "AccountCreationDialog_Initializing": "initializing", "AccountCreationDialog_PreparingFolders": "We are getting folder information at the moment.", "AccountCreationDialog_SigninIn": "Account information is being saved.", + "AccountCreationDialog_FetchingProfileInformation": "Fetching profile details.", "AccountEditDialog_Message": "Account Name", "AccountEditDialog_Title": "Edit Account", "AccountPickerDialog_Title": "Pick an account", @@ -21,6 +22,8 @@ "BasicIMAPSetupDialog_Password": "Password", "BasicIMAPSetupDialog_Title": "IMAP Account", "Buttons_AddAccount": "Add Account", + "Buttons_AddNewAlias": "Add New Alias", + "Buttons_SyncAliases": "Synchronize Aliases", "Buttons_ApplyTheme": "Apply Theme", "Buttons_Browse": "Browse", "Buttons_Cancel": "Cancel", @@ -64,6 +67,16 @@ "CustomThemeBuilder_WallpaperTitle": "Set custom wallpaper", "DialogMessage_AccountLimitMessage": "You have reached the account creation limit.\nWould you like to purchase 'Unlimited Account' add-on to continue?", "DialogMessage_AccountLimitTitle": "Account Limit Reached", + "DialogMessage_AliasNotSelectedTitle": "Missing Alias", + "DialogMessage_AliasNotSelectedMessage": "You must select an alias before sending a message.", + "DialogMessage_AliasExistsTitle": "Existing Alias", + "DialogMessage_AliasExistsMessage": "This alias is already in use.", + "DialogMessage_InvalidAliasTitle": "Invalid Alias", + "DialogMessage_InvalidAliasMessage": "This alias is not valid. Make sure all addresses of the alias are valid e-mail addresses.", + "DialogMessage_CantDeleteRootAliasTitle": "Can't Delete Alias", + "DialogMessage_CantDeleteRootAliasMessage": "Root alias can't be deleted. This is your main identity associated with your account setup.", + "DialogMessage_AliasCreatedTitle": "Created New Alias", + "DialogMessage_AliasCreatedMessage": "New alias is succesfully created.", "DialogMessage_CleanupFolderMessage": "Do you want to permanently delete all the mails in this folder?", "DialogMessage_CleanupFolderTitle": "Cleanup Folder", "DialogMessage_ComposerMissingRecipientMessage": "Message has no recipient.", @@ -92,9 +105,16 @@ "DialogMessage_UnsubscribeConfirmationGoToWebsiteConfirmButton": "Go to website", "DialogMessage_UnsubscribeConfirmationMailtoMessage": "Do you want to stop getting messages from {0}? Wino will unsubscribe for you by sending an email from your email account to {1}.", "Dialog_DontAskAgain": "Don't ask again", + "CreateAccountAliasDialog_Title": "Create Account Alias", + "CreateAccountAliasDialog_Description": "Make sure your outgoing server allows sending mails from this alias.", + "CreateAccountAliasDialog_AliasAddress": "Address", + "CreateAccountAliasDialog_AliasAddressPlaceholder": "eg. support@mydomain.com", + "CreateAccountAliasDialog_ReplyToAddress": "Reply-To Address", + "CreateAccountAliasDialog_ReplyToAddressPlaceholder": "admin@mydomain.com", "DiscordChannelDisclaimerMessage": "Wino doesn't have it's own Discord server, but special 'wino-mail' channel is hosted at 'Developer Sanctuary' server.\nTo get the updates about Wino please join Developer Sanctuary server and follow 'wino-mail' channel under 'Community Projects'\n\nYou will be directed to server URL since Discord doesn't support channel invites.", "DiscordChannelDisclaimerTitle": "Important Discord Information", "Draft": "Draft", + "Busy": "Busy", "EditorToolbarOption_Draw": "Draw", "EditorToolbarOption_Format": "Format", "EditorToolbarOption_Insert": "Insert", @@ -106,6 +126,7 @@ "ElementTheme_Light": "Light mode", "Emoji": "Emoji", "Exception_WinoServerException": "Wino server failed.", + "Exception_MailProcessing": "This mail is still being processed. Please try again after few seconds.", "Exception_ImapAutoDiscoveryFailed": "Couldn't find mailbox settings.", "Exception_ImapClientPoolFailed": "IMAP Client Pool failed.", "Exception_AuthenticationCanceled": "Authentication canceled", @@ -113,6 +134,9 @@ "Exception_CustomThemeMissingName": "You must provide a name.", "Exception_CustomThemeMissingWallpaper": "You must provide a custom background image.", "Exception_FailedToSynchronizeFolders": "Failed to synchronize folders", + "Exception_FailedToSynchronizeAliases": "Failed to synchronize aliases", + "Exception_MissingAlias": "Primary alias does not exist for this account. Creating draft failed.", + "Exception_FailedToSynchronizeProfileInformation": "Failed to synchronize profile information", "Exception_GoogleAuthCallbackNull": "Callback uri is null on activation.", "Exception_GoogleAuthCorruptedCode": "Corrupted authorization response.", "Exception_GoogleAuthError": "OAuth authorization error: {0}", @@ -253,6 +277,7 @@ "Info_UnsubscribeLinkInvalidMessage": "This unsubscribe link is invalid. Failed to unsubscribe from the list.", "Info_UnsubscribeSuccessMessage": "Successfully unsubscribed from {0}.", "Info_UnsubscribeErrorMessage": "Failed to unsubscribe", + "Info_CantDeletePrimaryAliasMessage": "Primary alias can't be deleted. Please change your alias before deleting this one", "ImapAdvancedSetupDialog_AuthenticationMethod": "Authentication method", "ImapAdvancedSetupDialog_ConnectionSecurity": "Connection security", "ImapAuthenticationMethod_Auto": "Auto", @@ -396,6 +421,8 @@ "SettingsFolderSync_Title": "Folder Synchronization", "SettingsFolderOptions_Title": "Folder Configuration", "SettingsFolderOptions_Description": "Change individual folder settings like enable/disable sync or show/hide unread badge.", + "SettingsManageAliases_Title": "Aliases", + "SettingsManageAliases_Description": "See e-mail aliases assigned for this account, update or delete them.", "SettingsHoverActionCenter": "Center Action", "SettingsHoverActionLeft": "Left Action", "SettingsHoverActionRight": "Right Action", @@ -406,6 +433,11 @@ "SettingsLanguageTime_Title": "Language & Time", "SettingsLanguageTime_Description": "Wino display language, preferred time format.", "CategoriesFolderNameOverride": "Categories", + "AccountAlias_Column_Verified": "Verified", + "AccountAlias_Column_Alias": "Alias", + "AccountAlias_Column_IsPrimaryAlias": "Primary", + "AccountAlias_Disclaimer_FirstLine": "Wino can only import aliases for your Gmail accounts.", + "AccountAlias_Disclaimer_SecondLine": "If you want to use aliases for your Outlook or IMAP account, please add them yourself.", "MoreFolderNameOverride": "More", "SettingsOptions_Title": "Settings", "SettingsLinkAccounts_Description": "Merge multiple accounts into one. See mails from one Inbox together.", diff --git a/Wino.Core.Domain/Translator.Designer.cs b/Wino.Core.Domain/Translator.Designer.cs index 6fc65a03..fa0c50f4 100644 --- a/Wino.Core.Domain/Translator.Designer.cs +++ b/Wino.Core.Domain/Translator.Designer.cs @@ -38,6 +38,11 @@ namespace Wino.Core.Domain /// public static string AccountCreationDialog_SigninIn => Resources.GetTranslatedString(@"AccountCreationDialog_SigninIn"); + /// + /// Fetching profile details. + /// + public static string AccountCreationDialog_FetchingProfileInformation => Resources.GetTranslatedString(@"AccountCreationDialog_FetchingProfileInformation"); + /// /// Account Name /// @@ -128,6 +133,16 @@ namespace Wino.Core.Domain /// public static string Buttons_AddAccount => Resources.GetTranslatedString(@"Buttons_AddAccount"); + /// + /// Add New Alias + /// + public static string Buttons_AddNewAlias => Resources.GetTranslatedString(@"Buttons_AddNewAlias"); + + /// + /// Synchronize Aliases + /// + public static string Buttons_SyncAliases => Resources.GetTranslatedString(@"Buttons_SyncAliases"); + /// /// Apply Theme /// @@ -343,6 +358,56 @@ namespace Wino.Core.Domain /// public static string DialogMessage_AccountLimitTitle => Resources.GetTranslatedString(@"DialogMessage_AccountLimitTitle"); + /// + /// Missing Alias + /// + public static string DialogMessage_AliasNotSelectedTitle => Resources.GetTranslatedString(@"DialogMessage_AliasNotSelectedTitle"); + + /// + /// You must select an alias before sending a message. + /// + public static string DialogMessage_AliasNotSelectedMessage => Resources.GetTranslatedString(@"DialogMessage_AliasNotSelectedMessage"); + + /// + /// Existing Alias + /// + public static string DialogMessage_AliasExistsTitle => Resources.GetTranslatedString(@"DialogMessage_AliasExistsTitle"); + + /// + /// This alias is already in use. + /// + public static string DialogMessage_AliasExistsMessage => Resources.GetTranslatedString(@"DialogMessage_AliasExistsMessage"); + + /// + /// Invalid Alias + /// + public static string DialogMessage_InvalidAliasTitle => Resources.GetTranslatedString(@"DialogMessage_InvalidAliasTitle"); + + /// + /// This alias is not valid. Make sure all addresses of the alias are valid e-mail addresses. + /// + public static string DialogMessage_InvalidAliasMessage => Resources.GetTranslatedString(@"DialogMessage_InvalidAliasMessage"); + + /// + /// Can't Delete Alias + /// + public static string DialogMessage_CantDeleteRootAliasTitle => Resources.GetTranslatedString(@"DialogMessage_CantDeleteRootAliasTitle"); + + /// + /// Root alias can't be deleted. This is your main identity associated with your account setup. + /// + public static string DialogMessage_CantDeleteRootAliasMessage => Resources.GetTranslatedString(@"DialogMessage_CantDeleteRootAliasMessage"); + + /// + /// Created New Alias + /// + public static string DialogMessage_AliasCreatedTitle => Resources.GetTranslatedString(@"DialogMessage_AliasCreatedTitle"); + + /// + /// New alias is succesfully created. + /// + public static string DialogMessage_AliasCreatedMessage => Resources.GetTranslatedString(@"DialogMessage_AliasCreatedMessage"); + /// /// Do you want to permanently delete all the mails in this folder? /// @@ -434,7 +499,7 @@ namespace Wino.Core.Domain public static string DialogMessage_UnlinkAccountsConfirmationTitle => Resources.GetTranslatedString(@"DialogMessage_UnlinkAccountsConfirmationTitle"); /// - /// Missin Subject + /// Missing Subject /// public static string DialogMessage_EmptySubjectConfirmation => Resources.GetTranslatedString(@"DialogMessage_EmptySubjectConfirmation"); @@ -483,6 +548,36 @@ namespace Wino.Core.Domain /// public static string Dialog_DontAskAgain => Resources.GetTranslatedString(@"Dialog_DontAskAgain"); + /// + /// Create Account Alias + /// + public static string CreateAccountAliasDialog_Title => Resources.GetTranslatedString(@"CreateAccountAliasDialog_Title"); + + /// + /// Make sure your outgoing server allows sending mails from this alias. + /// + public static string CreateAccountAliasDialog_Description => Resources.GetTranslatedString(@"CreateAccountAliasDialog_Description"); + + /// + /// Address + /// + public static string CreateAccountAliasDialog_AliasAddress => Resources.GetTranslatedString(@"CreateAccountAliasDialog_AliasAddress"); + + /// + /// eg. support@mydomain.com + /// + public static string CreateAccountAliasDialog_AliasAddressPlaceholder => Resources.GetTranslatedString(@"CreateAccountAliasDialog_AliasAddressPlaceholder"); + + /// + /// Reply-To Address + /// + public static string CreateAccountAliasDialog_ReplyToAddress => Resources.GetTranslatedString(@"CreateAccountAliasDialog_ReplyToAddress"); + + /// + /// admin@mydomain.com + /// + public static string CreateAccountAliasDialog_ReplyToAddressPlaceholder => Resources.GetTranslatedString(@"CreateAccountAliasDialog_ReplyToAddressPlaceholder"); + /// /// Wino doesn't have it's own Discord server, but special 'wino-mail' channel is hosted at 'Developer Sanctuary' server. To get the updates about Wino please join Developer Sanctuary server and follow 'wino-mail' channel under 'Community Projects' You will be directed to server URL since Discord doesn't support channel invites. /// @@ -498,6 +593,11 @@ namespace Wino.Core.Domain /// public static string Draft => Resources.GetTranslatedString(@"Draft"); + /// + /// Busy + /// + public static string Busy => Resources.GetTranslatedString(@"Busy"); + /// /// Draw /// @@ -553,6 +653,11 @@ namespace Wino.Core.Domain /// public static string Exception_WinoServerException => Resources.GetTranslatedString(@"Exception_WinoServerException"); + /// + /// This mail is still being processed. Please try again after few seconds. + /// + public static string Exception_MailProcessing => Resources.GetTranslatedString(@"Exception_MailProcessing"); + /// /// Couldn't find mailbox settings. /// @@ -588,6 +693,21 @@ namespace Wino.Core.Domain /// public static string Exception_FailedToSynchronizeFolders => Resources.GetTranslatedString(@"Exception_FailedToSynchronizeFolders"); + /// + /// Failed to synchronize aliases + /// + public static string Exception_FailedToSynchronizeAliases => Resources.GetTranslatedString(@"Exception_FailedToSynchronizeAliases"); + + /// + /// Primary alias does not exist for this account. Creating draft failed. + /// + public static string Exception_MissingAlias => Resources.GetTranslatedString(@"Exception_MissingAlias"); + + /// + /// Failed to synchronize profile information + /// + public static string Exception_FailedToSynchronizeProfileInformation => Resources.GetTranslatedString(@"Exception_FailedToSynchronizeProfileInformation"); + /// /// Callback uri is null on activation. /// @@ -1288,6 +1408,11 @@ namespace Wino.Core.Domain /// public static string Info_UnsubscribeErrorMessage => Resources.GetTranslatedString(@"Info_UnsubscribeErrorMessage"); + /// + /// Primary alias can't be deleted. Please change your alias before deleting this one + /// + public static string Info_CantDeletePrimaryAliasMessage => Resources.GetTranslatedString(@"Info_CantDeletePrimaryAliasMessage"); + /// /// Authentication method /// @@ -2003,6 +2128,16 @@ namespace Wino.Core.Domain /// public static string SettingsFolderOptions_Description => Resources.GetTranslatedString(@"SettingsFolderOptions_Description"); + /// + /// Aliases + /// + public static string SettingsManageAliases_Title => Resources.GetTranslatedString(@"SettingsManageAliases_Title"); + + /// + /// See e-mail aliases assigned for this account, update or delete them. + /// + public static string SettingsManageAliases_Description => Resources.GetTranslatedString(@"SettingsManageAliases_Description"); + /// /// Center Action /// @@ -2053,6 +2188,31 @@ namespace Wino.Core.Domain /// public static string CategoriesFolderNameOverride => Resources.GetTranslatedString(@"CategoriesFolderNameOverride"); + /// + /// Verified + /// + public static string AccountAlias_Column_Verified => Resources.GetTranslatedString(@"AccountAlias_Column_Verified"); + + /// + /// Alias + /// + public static string AccountAlias_Column_Alias => Resources.GetTranslatedString(@"AccountAlias_Column_Alias"); + + /// + /// Primary + /// + public static string AccountAlias_Column_IsPrimaryAlias => Resources.GetTranslatedString(@"AccountAlias_Column_IsPrimaryAlias"); + + /// + /// Wino can only import aliases for your Gmail accounts. + /// + public static string AccountAlias_Disclaimer_FirstLine => Resources.GetTranslatedString(@"AccountAlias_Disclaimer_FirstLine"); + + /// + /// If you want to use aliases for your Outlook or IMAP account, please add them yourself. + /// + public static string AccountAlias_Disclaimer_SecondLine => Resources.GetTranslatedString(@"AccountAlias_Disclaimer_SecondLine"); + /// /// More /// diff --git a/Wino.Core/Authenticators/OutlookAuthenticator.cs b/Wino.Core/Authenticators/OutlookAuthenticator.cs index 52bd8654..4fa78378 100644 --- a/Wino.Core/Authenticators/OutlookAuthenticator.cs +++ b/Wino.Core/Authenticators/OutlookAuthenticator.cs @@ -28,7 +28,7 @@ namespace Wino.Core.Authenticators public string ClientId { get; } = "b19c2035-d740-49ff-b297-de6ec561b208"; - private readonly string[] MailScope = ["email", "mail.readwrite", "offline_access", "mail.send"]; + private readonly string[] MailScope = ["email", "mail.readwrite", "offline_access", "mail.send", "Mail.Send.Shared", "Mail.ReadWrite.Shared"]; public override MailProviderType ProviderType => MailProviderType.Outlook; diff --git a/Wino.Core/Extensions/FolderTreeExtensions.cs b/Wino.Core/Extensions/FolderTreeExtensions.cs deleted file mode 100644 index 18cf16ca..00000000 --- a/Wino.Core/Extensions/FolderTreeExtensions.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Wino.Core.Domain.Interfaces; -using Wino.Core.Domain.Models.Folders; -using Wino.Core.MenuItems; - -namespace Wino.Core.Extensions -{ - public static class FolderTreeExtensions - { - private static MenuItemBase GetMenuItemByFolderRecursive(IMailItemFolder structure, AccountMenuItem parentAccountMenuItem, IMenuItem parentFolderItem) - { - MenuItemBase parentMenuItem = new FolderMenuItem(structure, parentAccountMenuItem.Parameter, parentFolderItem); - - var childStructures = structure.ChildFolders; - - foreach (var childFolder in childStructures) - { - if (childFolder == null) continue; - - // Folder menu item. - var subChildrenFolderTree = GetMenuItemByFolderRecursive(childFolder, parentAccountMenuItem, parentMenuItem); - - if (subChildrenFolderTree is FolderMenuItem folderItem) - { - parentMenuItem.SubMenuItems.Add(folderItem); - } - } - - return parentMenuItem; - } - } -} diff --git a/Wino.Core/Extensions/GoogleIntegratorExtensions.cs b/Wino.Core/Extensions/GoogleIntegratorExtensions.cs index d03877b9..1aa319e3 100644 --- a/Wino.Core/Extensions/GoogleIntegratorExtensions.cs +++ b/Wino.Core/Extensions/GoogleIntegratorExtensions.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Web; using Google.Apis.Gmail.v1.Data; @@ -205,44 +204,16 @@ namespace Wino.Core.Extensions }; } - public static Tuple> GetMailDetails(this Message message) + public static List GetRemoteAliases(this ListSendAsResponse response) { - MimeMessage mimeMessage = message.GetGmailMimeMessage(); - - if (mimeMessage == null) + return response?.SendAs?.Select(a => new RemoteAccountAlias() { - // This should never happen. - Debugger.Break(); - - return default; - } - - bool isUnread = message.GetIsUnread(); - bool isFocused = message.GetIsFocused(); - bool isFlagged = message.GetIsFlagged(); - bool isDraft = message.GetIsDraft(); - - var mailCopy = new MailCopy() - { - CreationDate = mimeMessage.Date.UtcDateTime, - Subject = HttpUtility.HtmlDecode(mimeMessage.Subject), - FromName = MailkitClientExtensions.GetActualSenderName(mimeMessage), - FromAddress = MailkitClientExtensions.GetActualSenderAddress(mimeMessage), - PreviewText = HttpUtility.HtmlDecode(message.Snippet), - ThreadId = message.ThreadId, - Importance = (MailImportance)mimeMessage.Importance, - Id = message.Id, - IsDraft = isDraft, - HasAttachments = mimeMessage.Attachments.Any(), - IsRead = !isUnread, - IsFlagged = isFlagged, - IsFocused = isFocused, - InReplyTo = mimeMessage.InReplyTo, - MessageId = mimeMessage.MessageId, - References = mimeMessage.References.GetReferences() - }; - - return new Tuple>(mailCopy, mimeMessage, message.LabelIds); + AliasAddress = a.SendAsEmail, + IsRootAlias = a.IsDefault.GetValueOrDefault(), + IsPrimary = a.IsPrimary.GetValueOrDefault(), + ReplyToAddress = a.ReplyToAddress, + IsVerified = a.VerificationStatus == "accepted" || a.IsDefault.GetValueOrDefault(), + }).ToList(); } } } diff --git a/Wino.Core/Extensions/OutlookIntegratorExtensions.cs b/Wino.Core/Extensions/OutlookIntegratorExtensions.cs index 2d542849..12e635c4 100644 --- a/Wino.Core/Extensions/OutlookIntegratorExtensions.cs +++ b/Wino.Core/Extensions/OutlookIntegratorExtensions.cs @@ -1,5 +1,9 @@ using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; using Microsoft.Graph.Models; +using MimeKit; using Wino.Core.Domain.Entities; using Wino.Core.Domain.Enums; @@ -61,5 +65,160 @@ namespace Wino.Core.Extensions return mailCopy; } + + public static Message AsOutlookMessage(this MimeMessage mime, bool includeInternetHeaders) + { + var fromAddress = GetRecipients(mime.From).ElementAt(0); + var toAddresses = GetRecipients(mime.To).ToList(); + var ccAddresses = GetRecipients(mime.Cc).ToList(); + var bccAddresses = GetRecipients(mime.Bcc).ToList(); + var replyToAddresses = GetRecipients(mime.ReplyTo).ToList(); + + var message = new Message() + { + Subject = mime.Subject, + Importance = GetImportance(mime.Importance), + Body = new ItemBody() { ContentType = BodyType.Html, Content = mime.HtmlBody }, + IsDraft = false, + IsRead = true, // Sent messages are always read. + ToRecipients = toAddresses, + CcRecipients = ccAddresses, + BccRecipients = bccAddresses, + From = fromAddress, + InternetMessageId = GetProperId(mime.MessageId), + ReplyTo = replyToAddresses, + Attachments = [] + }; + + // Headers are only included when creating the draft. + // When sending, they are not included. Graph will throw an error. + + if (includeInternetHeaders) + { + message.InternetMessageHeaders = GetHeaderList(mime); + } + + foreach (var part in mime.BodyParts) + { + if (part.IsAttachment) + { + // File attachment. + + using var memory = new MemoryStream(); + ((MimePart)part).Content.DecodeTo(memory); + + var bytes = memory.ToArray(); + + var fileAttachment = new FileAttachment() + { + ContentId = part.ContentId, + Name = part.ContentDisposition?.FileName ?? part.ContentType.Name, + ContentBytes = bytes, + }; + + message.Attachments.Add(fileAttachment); + } + else if (part.ContentDisposition != null && part.ContentDisposition.Disposition == "inline") + { + // Inline attachment. + + using var memory = new MemoryStream(); + ((MimePart)part).Content.DecodeTo(memory); + + var bytes = memory.ToArray(); + var inlineAttachment = new FileAttachment() + { + IsInline = true, + ContentId = part.ContentId, + Name = part.ContentDisposition?.FileName ?? part.ContentType.Name, + ContentBytes = bytes + }; + + message.Attachments.Add(inlineAttachment); + } + } + + return message; + } + + #region Mime to Outlook Message Helpers + + private static IEnumerable GetRecipients(this InternetAddressList internetAddresses) + { + foreach (var address in internetAddresses) + { + if (address is MailboxAddress mailboxAddress) + yield return new Recipient() { EmailAddress = new EmailAddress() { Address = mailboxAddress.Address, Name = mailboxAddress.Name } }; + else if (address is GroupAddress groupAddress) + { + // TODO: Group addresses are not directly supported. + // It'll be individually added. + + foreach (var mailbox in groupAddress.Members) + if (mailbox is MailboxAddress groupMemberMailAddress) + yield return new Recipient() { EmailAddress = new EmailAddress() { Address = groupMemberMailAddress.Address, Name = groupMemberMailAddress.Name } }; + } + } + } + + private static Importance? GetImportance(MessageImportance importance) + { + return importance switch + { + MessageImportance.Low => Importance.Low, + MessageImportance.Normal => Importance.Normal, + MessageImportance.High => Importance.High, + _ => null + }; + } + + private static List GetHeaderList(this MimeMessage mime) + { + // Graph API only allows max of 5 headers. + // Here we'll try to ignore some headers that are not neccessary. + // Outlook API will generate them automatically. + + // Some headers also require to start with X- or x-. + + string[] headersToIgnore = ["Date", "To", "MIME-Version", "From", "Subject", "Message-Id"]; + string[] headersToModify = ["In-Reply-To", "Reply-To", "References", "Thread-Topic"]; + + var headers = new List(); + + int includedHeaderCount = 0; + + foreach (var header in mime.Headers) + { + if (!headersToIgnore.Contains(header.Field)) + { + if (headersToModify.Contains(header.Field)) + { + headers.Add(new InternetMessageHeader() { Name = $"X-{header.Field}", Value = header.Value }); + } + else + { + headers.Add(new InternetMessageHeader() { Name = header.Field, Value = header.Value }); + } + + includedHeaderCount++; + } + + if (includedHeaderCount >= 5) break; + } + + return headers; + } + + private static string GetProperId(string id) + { + // Outlook requires some identifiers to start with "X-" or "x-". + if (string.IsNullOrEmpty(id)) return string.Empty; + + if (!id.StartsWith("x-") || !id.StartsWith("X-")) + return $"X-{id}"; + + return id; + } + #endregion } } diff --git a/Wino.Core/Integration/ImapClientPool.cs b/Wino.Core/Integration/ImapClientPool.cs index b49556f7..1038a950 100644 --- a/Wino.Core/Integration/ImapClientPool.cs +++ b/Wino.Core/Integration/ImapClientPool.cs @@ -33,11 +33,13 @@ namespace Wino.Core.Integration // Later on maybe we can make it configurable and leave it to the user with passing // real implementation details. - private readonly ImapImplementation _implementation = new ImapImplementation() + private readonly ImapImplementation _implementation = new() { - Version = "1.0", + Version = "1.8.0", OS = "Windows", - Vendor = "Wino" + Vendor = "Wino", + SupportUrl = "https://www.winomail.app", + Name = "Wino Mail User", }; private readonly int MinimumPoolSize = 5; diff --git a/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs b/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs index 14ebc1de..7c39fcb5 100644 --- a/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs +++ b/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs @@ -19,6 +19,7 @@ namespace Wino.Core.Integration.Processors /// public interface IDefaultChangeProcessor { + Task UpdateAccountAsync(MailAccount account); Task UpdateAccountDeltaSynchronizationIdentifierAsync(Guid accountId, string deltaSynchronizationIdentifier); Task CreateAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId); Task DeleteAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId); @@ -39,12 +40,14 @@ namespace Wino.Core.Integration.Processors /// All folders. Task> GetLocalFoldersAsync(Guid accountId); + Task> GetSynchronizationFoldersAsync(SynchronizationOptions options); Task MapLocalDraftAsync(Guid accountId, Guid localDraftCopyUniqueId, string newMailCopyId, string newDraftId, string newThreadId); Task UpdateFolderLastSyncDateAsync(Guid folderId); Task> GetExistingFoldersAsync(Guid accountId); + Task UpdateRemoteAliasInformationAsync(MailAccount account, List remoteAccountAliases); } public interface IGmailChangeProcessor : IDefaultChangeProcessor @@ -172,5 +175,11 @@ namespace Wino.Core.Integration.Processors public Task UpdateFolderLastSyncDateAsync(Guid folderId) => FolderService.UpdateFolderLastSyncDateAsync(folderId); + + public Task UpdateAccountAsync(MailAccount account) + => AccountService.UpdateAccountAsync(account); + + public Task UpdateRemoteAliasInformationAsync(MailAccount account, List remoteAccountAliases) + => AccountService.UpdateRemoteAliasInformationAsync(account, remoteAccountAliases); } } diff --git a/Wino.Core/MenuItems/AccountMenuItem.cs b/Wino.Core/MenuItems/AccountMenuItem.cs index 045038f4..c06da2f5 100644 --- a/Wino.Core/MenuItems/AccountMenuItem.cs +++ b/Wino.Core/MenuItems/AccountMenuItem.cs @@ -47,6 +47,12 @@ namespace Wino.Core.MenuItems set => SetProperty(Parameter.Name, value, Parameter, (u, n) => u.Name = n); } + public string Base64ProfilePicture + { + get => Parameter.Name; + set => SetProperty(Parameter.Base64ProfilePictureData, value, Parameter, (u, n) => u.Base64ProfilePictureData = n); + } + public IEnumerable HoldingAccounts => new List { Parameter }; public AccountMenuItem(MailAccount account, IMenuItem parent = null) : base(account, account.Id, parent) @@ -59,6 +65,7 @@ namespace Wino.Core.MenuItems Parameter = account; AccountName = account.Name; AttentionReason = account.AttentionReason; + Base64ProfilePicture = account.Base64ProfilePictureData; if (SubMenuItems == null) return; diff --git a/Wino.Core/Services/AccountService.cs b/Wino.Core/Services/AccountService.cs index 63e1055b..e4e504e8 100644 --- a/Wino.Core/Services/AccountService.cs +++ b/Wino.Core/Services/AccountService.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Threading.Tasks; using CommunityToolkit.Diagnostics; @@ -10,6 +9,7 @@ using SqlKata; using Wino.Core.Domain.Entities; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Accounts; using Wino.Core.Extensions; using Wino.Messaging.Client.Accounts; using Wino.Messaging.UI; @@ -233,6 +233,33 @@ namespace Wino.Core.Services return accounts; } + public async Task CreateRootAliasAsync(Guid accountId, string address) + { + var rootAlias = new MailAccountAlias() + { + AccountId = accountId, + AliasAddress = address, + IsPrimary = true, + IsRootAlias = true, + IsVerified = true, + ReplyToAddress = address, + Id = Guid.NewGuid() + }; + + await Connection.InsertAsync(rootAlias).ConfigureAwait(false); + + Log.Information("Created root alias for the account {AccountId}", accountId); + } + + public async Task> GetAccountAliasesAsync(Guid accountId) + { + var query = new Query(nameof(MailAccountAlias)) + .Where(nameof(MailAccountAlias.AccountId), accountId) + .OrderByDesc(nameof(MailAccountAlias.IsRootAlias)); + + return await Connection.QueryAsync(query.GetRawQuery()).ConfigureAwait(false); + } + private Task GetMergedInboxInformationAsync(Guid mergedInboxId) => Connection.Table().FirstOrDefaultAsync(a => a.Id == mergedInboxId); @@ -245,6 +272,7 @@ namespace Wino.Core.Services await Connection.Table().Where(a => a.AccountId == account.Id).DeleteAsync(); await Connection.Table().DeleteAsync(a => a.MailAccountId == account.Id); await Connection.Table().DeleteAsync(a => a.MailAccountId == account.Id); + await Connection.Table().DeleteAsync(a => a.AccountId == account.Id); // Account belongs to a merged inbox. // In case of there'll be a single account in the merged inbox, remove the merged inbox as well. @@ -295,6 +323,19 @@ namespace Wino.Core.Services ReportUIChange(new AccountRemovedMessage(account)); } + public async Task UpdateProfileInformationAsync(Guid accountId, ProfileInformation profileInformation) + { + var account = await GetAccountAsync(accountId).ConfigureAwait(false); + + if (account != null) + { + account.SenderName = profileInformation.SenderName; + account.Base64ProfilePictureData = profileInformation.Base64ProfilePictureData; + + await UpdateAccountAsync(account).ConfigureAwait(false); + } + } + public async Task GetAccountAsync(Guid accountId) { var account = await Connection.Table().FirstOrDefaultAsync(a => a.Id == accountId); @@ -321,17 +362,98 @@ namespace Wino.Core.Services public async Task UpdateAccountAsync(MailAccount account) { - if (account.Preferences == null) - { - Debugger.Break(); - } - - await Connection.UpdateAsync(account.Preferences); - await Connection.UpdateAsync(account); + await Connection.UpdateAsync(account.Preferences).ConfigureAwait(false); + await Connection.UpdateAsync(account).ConfigureAwait(false); ReportUIChange(new AccountUpdatedMessage(account)); } + public async Task UpdateAccountAliasesAsync(Guid accountId, List aliases) + { + // Delete existing ones. + await Connection.Table().DeleteAsync(a => a.AccountId == accountId).ConfigureAwait(false); + + // Insert new ones. + foreach (var alias in aliases) + { + await Connection.InsertAsync(alias).ConfigureAwait(false); + } + } + + public async Task UpdateRemoteAliasInformationAsync(MailAccount account, List remoteAccountAliases) + { + var localAliases = await GetAccountAliasesAsync(account.Id).ConfigureAwait(false); + var rootAlias = localAliases.Find(a => a.IsRootAlias); + + foreach (var remoteAlias in remoteAccountAliases) + { + var existingAlias = localAliases.Find(a => a.AccountId == account.Id && a.AliasAddress == remoteAlias.AliasAddress); + + if (existingAlias == null) + { + // Create new alias. + var newAlias = new MailAccountAlias() + { + AccountId = account.Id, + AliasAddress = remoteAlias.AliasAddress, + IsPrimary = remoteAlias.IsPrimary, + IsVerified = remoteAlias.IsVerified, + ReplyToAddress = remoteAlias.ReplyToAddress, + Id = Guid.NewGuid(), + IsRootAlias = remoteAlias.IsRootAlias + }; + + await Connection.InsertAsync(newAlias); + localAliases.Add(newAlias); + } + else + { + // Update existing alias. + existingAlias.IsPrimary = remoteAlias.IsPrimary; + existingAlias.IsVerified = remoteAlias.IsVerified; + existingAlias.ReplyToAddress = remoteAlias.ReplyToAddress; + + await Connection.UpdateAsync(existingAlias); + } + } + + // Make sure there is only 1 root alias and 1 primary alias selected. + + bool shouldUpdatePrimary = localAliases.Count(a => a.IsPrimary) != 1; + bool shouldUpdateRoot = localAliases.Count(a => a.IsRootAlias) != 1; + + if (shouldUpdatePrimary) + { + localAliases.ForEach(a => a.IsPrimary = false); + + var idealPrimaryAlias = localAliases.Find(a => a.AliasAddress == account.Address) ?? localAliases.First(); + + idealPrimaryAlias.IsPrimary = true; + await Connection.UpdateAsync(idealPrimaryAlias).ConfigureAwait(false); + } + + if (shouldUpdateRoot) + { + localAliases.ForEach(a => a.IsRootAlias = false); + + var idealRootAlias = localAliases.Find(a => a.AliasAddress == account.Address) ?? localAliases.First(); + + idealRootAlias.IsRootAlias = true; + await Connection.UpdateAsync(idealRootAlias).ConfigureAwait(false); + } + } + + public async Task DeleteAccountAliasAsync(Guid aliasId) + { + // Create query to delete alias. + + var query = new Query("MailAccountAlias") + .Where("Id", aliasId) + .AsDelete(); + + await Connection.ExecuteAsync(query.GetRawQuery()).ConfigureAwait(false); + } + public async Task CreateAccountAsync(MailAccount account, TokenInformation tokenInformation, CustomServerInformation customServerInformation) { Guard.IsNotNull(account); @@ -385,7 +507,7 @@ namespace Wino.Core.Services // Outlook token cache is managed by MSAL. // Don't save it to database. - if (tokenInformation != null && account.ProviderType != MailProviderType.Outlook) + if (tokenInformation != null && (account.ProviderType != MailProviderType.Outlook || account.ProviderType == MailProviderType.Office365)) await Connection.InsertAsync(tokenInformation); } @@ -437,5 +559,14 @@ namespace Wino.Core.Services Messenger.Send(new AccountMenuItemsReordered(accountIdOrderPair)); } + + public async Task GetPrimaryAccountAliasAsync(Guid accountId) + { + var aliases = await GetAccountAliasesAsync(accountId); + + if (aliases == null || aliases.Count == 0) return null; + + return aliases.FirstOrDefault(a => a.IsPrimary) ?? aliases.First(); + } } } diff --git a/Wino.Core/Services/DatabaseService.cs b/Wino.Core/Services/DatabaseService.cs index 246f9a63..e21a88eb 100644 --- a/Wino.Core/Services/DatabaseService.cs +++ b/Wino.Core/Services/DatabaseService.cs @@ -61,7 +61,8 @@ namespace Wino.Core.Services typeof(CustomServerInformation), typeof(AccountSignature), typeof(MergedInbox), - typeof(MailAccountPreferences) + typeof(MailAccountPreferences), + typeof(MailAccountAlias) ); } } diff --git a/Wino.Core/Services/MailService.cs b/Wino.Core/Services/MailService.cs index 16a18d6e..98e1101d 100644 --- a/Wino.Core/Services/MailService.cs +++ b/Wino.Core/Services/MailService.cs @@ -10,6 +10,7 @@ using SqlKata; using Wino.Core.Domain; using Wino.Core.Domain.Entities; using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Exceptions; using Wino.Core.Domain.Extensions; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Comparers; @@ -62,12 +63,14 @@ namespace Wino.Core.Services // This header will be used to map the local draft copy with the remote draft copy. var mimeUniqueId = createdDraftMimeMessage.Headers[Constants.WinoLocalDraftHeader]; + var primaryAlias = await _accountService.GetPrimaryAccountAliasAsync(accountId).ConfigureAwait(false); + var copy = new MailCopy { UniqueId = Guid.Parse(mimeUniqueId), Id = Guid.NewGuid().ToString(), // This will be replaced after network call with the remote draft id. CreationDate = DateTime.UtcNow, - FromAddress = composerAccount.Address, + FromAddress = primaryAlias?.AliasAddress ?? composerAccount.Address, FromName = composerAccount.SenderName, HasAttachments = false, Importance = MailImportance.Normal, @@ -621,12 +624,17 @@ namespace Wino.Core.Services // This unique id is stored in mime headers for Wino to identify remote message with local copy. // Same unique id will be used for the local copy as well. // Synchronizer will map this unique id to the local draft copy after synchronization. + var message = new MimeMessage() { Headers = { { Constants.WinoLocalDraftHeader, Guid.NewGuid().ToString() } }, - From = { new MailboxAddress(account.SenderName, account.Address) } }; + var primaryAlias = await _accountService.GetPrimaryAccountAliasAsync(account.Id) ?? throw new MissingAliasException(); + + // Set FromName and FromAddress by alias. + message.From.Add(new MailboxAddress(account.SenderName, primaryAlias.AliasAddress)); + var builder = new BodyBuilder(); var signature = await GetSignature(account, draftCreationOptions.Reason); diff --git a/Wino.Core/Services/MimeFileService.cs b/Wino.Core/Services/MimeFileService.cs index 02768349..42a93f65 100644 --- a/Wino.Core/Services/MimeFileService.cs +++ b/Wino.Core/Services/MimeFileService.cs @@ -109,12 +109,9 @@ namespace Wino.Core.Services var resourcePath = await GetMimeResourcePathAsync(accountId, fileId).ConfigureAwait(false); var completeFilePath = GetEMLPath(resourcePath); - var fileStream = File.Create(completeFilePath); + using var fileStream = File.Open(completeFilePath, FileMode.OpenOrCreate); - using (fileStream) - { - await mimeMessage.WriteToAsync(fileStream).ConfigureAwait(false); - } + await mimeMessage.WriteToAsync(fileStream).ConfigureAwait(false); return true; } diff --git a/Wino.Core/Synchronizers/BaseSynchronizer.cs b/Wino.Core/Synchronizers/BaseSynchronizer.cs index acd7f213..39d53f66 100644 --- a/Wino.Core/Synchronizers/BaseSynchronizer.cs +++ b/Wino.Core/Synchronizers/BaseSynchronizer.cs @@ -3,6 +3,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; using CommunityToolkit.Mvvm.Messaging; @@ -12,6 +13,7 @@ using Wino.Core.Domain; using Wino.Core.Domain.Entities; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Integration; @@ -69,8 +71,65 @@ namespace Wino.Core.Synchronizers /// Cancellation token public abstract Task ExecuteNativeRequestsAsync(IEnumerable> batchedRequests, CancellationToken cancellationToken = default); - public abstract Task SynchronizeInternalAsync(SynchronizationOptions options, CancellationToken cancellationToken = default); + /// + /// Refreshes remote mail account profile if possible. + /// Profile picture, sender name and mailbox settings (todo) will be handled in this step. + /// + public virtual Task GetProfileInformationAsync() => default; + /// + /// Refreshes the aliases of the account. + /// Only available for Gmail right now. + /// + protected virtual Task SynchronizeAliasesAsync() => Task.CompletedTask; + + /// + /// Returns the base64 encoded profile picture of the account from the given URL. + /// + /// URL to retrieve picture from. + /// base64 encoded profile picture + protected async Task GetProfilePictureBase64EncodedAsync(string url) + { + using var client = new HttpClient(); + + var response = await client.GetAsync(url).ConfigureAwait(false); + var byteContent = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false); + + return Convert.ToBase64String(byteContent); + } + + /// + /// Internally synchronizes the account with the given options. + /// Not exposed and overriden for each synchronizer. + /// + /// Synchronization options. + /// Cancellation token. + /// Synchronization result that contains summary of the sync. + protected abstract Task SynchronizeInternalAsync(SynchronizationOptions options, CancellationToken cancellationToken = default); + + /// + /// Safely updates account's profile information. + /// Database changes are reflected after this call. + /// + private async Task SynchronizeProfileInformationInternalAsync() + { + var profileInformation = await GetProfileInformationAsync(); + + if (profileInformation != null) + { + Account.SenderName = profileInformation.SenderName; + Account.Base64ProfilePictureData = profileInformation.Base64ProfilePictureData; + } + + return profileInformation; + } + + /// + /// Batches network requests, executes them, and does the needed synchronization after the batch request execution. + /// + /// Synchronization options. + /// Cancellation token. + /// Synchronization result that contains summary of the sync. public async Task SynchronizeAsync(SynchronizationOptions options, CancellationToken cancellationToken = default) { try @@ -104,6 +163,48 @@ namespace Wino.Core.Synchronizers await synchronizationSemaphore.WaitAsync(activeSynchronizationCancellationToken); + // Handle special synchronization types. + + // Profile information sync. + if (options.Type == SynchronizationType.UpdateProfile) + { + if (!Account.IsProfileInfoSyncSupported) return SynchronizationResult.Empty; + + ProfileInformation newProfileInformation = null; + + try + { + newProfileInformation = await SynchronizeProfileInformationInternalAsync(); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to update profile information for {Name}", Account.Name); + + return SynchronizationResult.Failed; + } + + return SynchronizationResult.Completed(null, newProfileInformation); + } + + // Alias sync. + if (options.Type == SynchronizationType.Alias) + { + if (!Account.IsAliasSyncSupported) return SynchronizationResult.Empty; + + try + { + await SynchronizeAliasesAsync(); + + return SynchronizationResult.Empty; + } + catch (Exception ex) + { + Log.Error(ex, "Failed to update aliases for {Name}", Account.Name); + + return SynchronizationResult.Failed; + } + } + // Let servers to finish their job. Sometimes the servers doesn't respond immediately. bool shouldDelayExecution = batches.Any(a => a.DelayExecution); @@ -150,6 +251,10 @@ namespace Wino.Core.Synchronizers private void PublishUnreadItemChanges() => WeakReferenceMessenger.Default.Send(new RefreshUnreadCountsMessage(Account.Id)); + /// + /// Sends a message to the shell to update the synchronization progress. + /// + /// Percentage of the progress. public void PublishSynchronizationProgress(double progress) => WeakReferenceMessenger.Default.Send(new AccountSynchronizationProgressUpdatedMessage(Account.Id, progress)); diff --git a/Wino.Core/Synchronizers/GmailSynchronizer.cs b/Wino.Core/Synchronizers/GmailSynchronizer.cs index 3ee7e044..d0cf7b2f 100644 --- a/Wino.Core/Synchronizers/GmailSynchronizer.cs +++ b/Wino.Core/Synchronizers/GmailSynchronizer.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Google.Apis.Gmail.v1; using Google.Apis.Gmail.v1.Data; using Google.Apis.Http; +using Google.Apis.PeopleService.v1; using Google.Apis.Requests; using Google.Apis.Services; using MailKit; @@ -18,6 +19,7 @@ using Wino.Core.Domain.Entities; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Exceptions; using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.Requests; using Wino.Core.Domain.Models.Synchronization; @@ -37,8 +39,10 @@ namespace Wino.Core.Synchronizers // https://github.com/googleapis/google-api-dotnet-client/issues/2603 private const uint MaximumAllowedBatchRequestSize = 10; - private readonly ConfigurableHttpClient _gmailHttpClient; + private readonly ConfigurableHttpClient _googleHttpClient; private readonly GmailService _gmailService; + private readonly PeopleServiceService _peopleService; + private readonly IAuthenticator _authenticator; private readonly IGmailChangeProcessor _gmailChangeProcessor; private readonly ILogger _logger = Log.ForContext(); @@ -54,15 +58,48 @@ namespace Wino.Core.Synchronizers HttpClientFactory = this }; - _gmailHttpClient = new ConfigurableHttpClient(messageHandler); + _googleHttpClient = new ConfigurableHttpClient(messageHandler); + _gmailService = new GmailService(initializer); + _peopleService = new PeopleServiceService(initializer); + _authenticator = authenticator; _gmailChangeProcessor = gmailChangeProcessor; } - public ConfigurableHttpClient CreateHttpClient(CreateHttpClientArgs args) => _gmailHttpClient; + public ConfigurableHttpClient CreateHttpClient(CreateHttpClientArgs args) => _googleHttpClient; - public override async Task SynchronizeInternalAsync(SynchronizationOptions options, CancellationToken cancellationToken = default) + public override async Task GetProfileInformationAsync() + { + var profileRequest = _peopleService.People.Get("people/me"); + profileRequest.PersonFields = "names,photos"; + + string senderName = string.Empty, base64ProfilePicture = string.Empty; + + var userProfile = await profileRequest.ExecuteAsync(); + + senderName = userProfile.Names?.FirstOrDefault()?.DisplayName ?? Account.SenderName; + + var profilePicture = userProfile.Photos?.FirstOrDefault()?.Url ?? string.Empty; + + if (!string.IsNullOrEmpty(profilePicture)) + { + base64ProfilePicture = await GetProfilePictureBase64EncodedAsync(profilePicture).ConfigureAwait(false); + } + + return new ProfileInformation(senderName, base64ProfilePicture); + } + + protected override async Task SynchronizeAliasesAsync() + { + var sendAsListRequest = _gmailService.Users.Settings.SendAs.List("me"); + var sendAsListResponse = await sendAsListRequest.ExecuteAsync(); + var remoteAliases = sendAsListResponse.GetRemoteAliases(); + + await _gmailChangeProcessor.UpdateRemoteAliasInformationAsync(Account, remoteAliases).ConfigureAwait(false); + } + + protected override async Task SynchronizeInternalAsync(SynchronizationOptions options, CancellationToken cancellationToken = default) { _logger.Information("Internal synchronization started for {Name}", Account.Name); diff --git a/Wino.Core/Synchronizers/ImapSynchronizer.cs b/Wino.Core/Synchronizers/ImapSynchronizer.cs index 0b8cd2f6..e0ef6810 100644 --- a/Wino.Core/Synchronizers/ImapSynchronizer.cs +++ b/Wino.Core/Synchronizers/ImapSynchronizer.cs @@ -405,7 +405,7 @@ namespace Wino.Core.Synchronizers ]; } - public override async Task SynchronizeInternalAsync(SynchronizationOptions options, CancellationToken cancellationToken = default) + protected override async Task SynchronizeInternalAsync(SynchronizationOptions options, CancellationToken cancellationToken = default) { var downloadedMessageIds = new List(); diff --git a/Wino.Core/Synchronizers/OutlookSynchronizer.cs b/Wino.Core/Synchronizers/OutlookSynchronizer.cs index 554e9dcf..7a4cde6e 100644 --- a/Wino.Core/Synchronizers/OutlookSynchronizer.cs +++ b/Wino.Core/Synchronizers/OutlookSynchronizer.cs @@ -4,8 +4,8 @@ using System.Diagnostics; using System.IO; using System.Linq; using System.Net; -using System.Net.Http; using System.Text; +using System.Text.Json.Nodes; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; @@ -18,11 +18,11 @@ using Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options; using MimeKit; using MoreLinq.Extensions; using Serilog; -using Wino.Core.Domain; using Wino.Core.Domain.Entities; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Exceptions; using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.Requests; using Wino.Core.Domain.Models.Synchronization; @@ -128,7 +128,7 @@ namespace Wino.Core.Synchronizers #endregion - public override async Task SynchronizeInternalAsync(SynchronizationOptions options, CancellationToken cancellationToken = default) + protected override async Task SynchronizeInternalAsync(SynchronizationOptions options, CancellationToken cancellationToken = default) { var downloadedMessageIds = new List(); @@ -473,6 +473,36 @@ namespace Wino.Core.Synchronizers } } + /// + /// Get the user's profile picture + /// + /// Base64 encoded profile picture. + private async Task GetUserProfilePictureAsync() + { + var photoStream = await _graphClient.Me.Photos["48x48"].Content.GetAsync(); + + using var memoryStream = new MemoryStream(); + await photoStream.CopyToAsync(memoryStream); + var byteArray = memoryStream.ToArray(); + + return Convert.ToBase64String(byteArray); + } + + private async Task GetSenderNameAsync() + { + var userInfo = await _graphClient.Users["me"].GetAsync(); + + return userInfo.DisplayName; + } + + public override async Task GetProfileInformationAsync() + { + var profilePictureData = await GetUserProfilePictureAsync().ConfigureAwait(false); + var senderName = await GetSenderNameAsync().ConfigureAwait(false); + + return new ProfileInformation(senderName, profilePictureData); + } + #region Mail Integration public override bool DelaySendOperationSynchronization() => true; @@ -572,22 +602,50 @@ namespace Wino.Core.Synchronizers { if (item is CreateDraftRequest createDraftRequest) { - createDraftRequest.DraftPreperationRequest.CreatedLocalDraftMimeMessage.Prepare(EncodingConstraint.None); + var reason = createDraftRequest.DraftPreperationRequest.Reason; + var message = createDraftRequest.DraftPreperationRequest.CreatedLocalDraftMimeMessage.AsOutlookMessage(true); - var plainTextBytes = Encoding.UTF8.GetBytes(createDraftRequest.DraftPreperationRequest.CreatedLocalDraftMimeMessage.ToString()); - var base64Encoded = Convert.ToBase64String(plainTextBytes); + if (reason == DraftCreationReason.Empty) + { + return _graphClient.Me.Messages.ToPostRequestInformation(message); + } + else if (reason == DraftCreationReason.Reply) + { + return _graphClient.Me.Messages[createDraftRequest.DraftPreperationRequest.ReferenceMailCopy.Id].CreateReply.ToPostRequestInformation(new Microsoft.Graph.Me.Messages.Item.CreateReply.CreateReplyPostRequestBody() + { + Message = message + }); + } + else if (reason == DraftCreationReason.ReplyAll) + { + return _graphClient.Me.Messages[createDraftRequest.DraftPreperationRequest.ReferenceMailCopy.Id].CreateReplyAll.ToPostRequestInformation(new Microsoft.Graph.Me.Messages.Item.CreateReplyAll.CreateReplyAllPostRequestBody() + { + Message = message + }); + } + else if (reason == DraftCreationReason.Forward) + { + return _graphClient.Me.Messages[createDraftRequest.DraftPreperationRequest.ReferenceMailCopy.Id].CreateForward.ToPostRequestInformation(new Microsoft.Graph.Me.Messages.Item.CreateForward.CreateForwardPostRequestBody() + { + Message = message + }); + //createDraftRequest.DraftPreperationRequest.CreatedLocalDraftMimeMessage.Prepare(EncodingConstraint.None); - var requestInformation = _graphClient.Me.Messages.ToPostRequestInformation(new Message()); + //var plainTextBytes = Encoding.UTF8.GetBytes(createDraftRequest.DraftPreperationRequest.CreatedLocalDraftMimeMessage.ToString()); + //var base64Encoded = Convert.ToBase64String(plainTextBytes); - requestInformation.Headers.Clear();// replace the json content header - requestInformation.Headers.Add("Content-Type", "text/plain"); + //var requestInformation = _graphClient.Me.Messages.ToPostRequestInformation(new Message()); - requestInformation.SetStreamContent(new MemoryStream(Encoding.UTF8.GetBytes(base64Encoded)), "text/plain"); + //requestInformation.Headers.Clear();// replace the json content header + //requestInformation.Headers.Add("Content-Type", "text/plain"); - return requestInformation; + //requestInformation.SetStreamContent(new MemoryStream(Encoding.UTF8.GetBytes(base64Encoded)), "text/plain"); + + //return requestInformation; + } } - return default; + throw new Exception("Invalid create draft request type."); }); } @@ -602,50 +660,43 @@ namespace Wino.Core.Synchronizers var mailCopyId = sendDraftPreparationRequest.MailItem.Id; var mimeMessage = sendDraftPreparationRequest.Mime; - var batchDeleteRequest = new BatchDeleteRequest(new List() + // Convert mime message to Outlook message. + // Outlook synchronizer does not send MIME messages directly anymore. + // Alias support is lacking with direct MIMEs. + // Therefore we convert the MIME message to Outlook message and use proper APIs. + + var outlookMessage = mimeMessage.AsOutlookMessage(false); + + // Update draft. + + var patchDraftRequest = _graphClient.Me.Messages[mailCopyId].ToPatchRequestInformation(outlookMessage); + var patchDraftRequestBundle = new HttpRequestBundle(patchDraftRequest, request); + + // Send draft. + + // POST requests are handled differently in batches in Graph SDK. + // Batch basically ignores the step's coontent-type and body. + // Manually create a POST request with empty body and send it. + + var sendDraftRequest = _graphClient.Me.Messages[mailCopyId].Send.ToPostRequestInformation((config) => { - new DeleteRequest(sendDraftPreparationRequest.MailItem) + config.Headers.Add("Content-Type", "application/json"); }); - var deleteBundle = Delete(batchDeleteRequest).ElementAt(0); + sendDraftRequest.Headers.Clear(); - mimeMessage.Prepare(EncodingConstraint.None); + sendDraftRequest.Content = new MemoryStream(Encoding.UTF8.GetBytes("{}")); + sendDraftRequest.HttpMethod = Method.POST; + sendDraftRequest.Headers.Add("Content-Type", "application/json"); - var plainTextBytes = Encoding.UTF8.GetBytes(mimeMessage.ToString()); - var base64Encoded = Convert.ToBase64String(plainTextBytes); + var sendDraftRequestBundle = new HttpRequestBundle(sendDraftRequest, request); - var outlookMessage = new Message() - { - ConversationId = sendDraftPreparationRequest.MailItem.ThreadId - }; - - // Apply importance here as well just in case. - if (mimeMessage.Importance != MessageImportance.Normal) - outlookMessage.Importance = mimeMessage.Importance == MessageImportance.High ? Importance.High : Importance.Low; - - var body = new Microsoft.Graph.Me.SendMail.SendMailPostRequestBody() - { - Message = outlookMessage - }; - - var sendRequest = _graphClient.Me.SendMail.ToPostRequestInformation(body); - - sendRequest.Headers.Clear(); - sendRequest.Headers.Add("Content-Type", "text/plain"); - - var stream = new MemoryStream(Encoding.UTF8.GetBytes(base64Encoded)); - sendRequest.SetStreamContent(stream, "text/plain"); - - var sendMailRequest = new HttpRequestBundle(sendRequest, request); - - return [deleteBundle, sendMailRequest]; + return [patchDraftRequestBundle, sendDraftRequestBundle]; } public override IEnumerable> Archive(BatchArchiveRequest request) => Move(new BatchMoveRequest(request.Items, request.FromFolder, request.ToFolder)); - - public override async Task DownloadMissingMimeMessageAsync(IMailItem mailItem, MailKit.ITransferProgress transferProgress = null, CancellationToken cancellationToken = default) @@ -697,26 +748,41 @@ namespace Wino.Core.Synchronizers request.ApplyUIChanges(); - await batchContent.AddBatchRequestStepAsync(nativeRequest).ConfigureAwait(false); + var batchRequestId = await batchContent.AddBatchRequestStepAsync(nativeRequest).ConfigureAwait(false); // Map BundleId to batch request step's key. // This is how we can identify which step succeeded or failed in the bundle. - bundle.BundleId = batchContent.BatchRequestSteps.ElementAt(i).Key; + bundle.BundleId = batchRequestId; } if (!batchContent.BatchRequestSteps.Any()) continue; + // Set execution type to serial instead of parallel if needed. + // Each step will depend on the previous one. + + if (itemCount > 1) + { + for (int i = 1; i < itemCount; i++) + { + var currentStep = batchContent.BatchRequestSteps.ElementAt(i); + var previousStep = batchContent.BatchRequestSteps.ElementAt(i - 1); + + currentStep.Value.DependsOn = [previousStep.Key]; + } + + } + // Execute batch. This will collect responses from network call for each batch step. - var batchRequestResponse = await _graphClient.Batch.PostAsync(batchContent).ConfigureAwait(false); + var batchRequestResponse = await _graphClient.Batch.PostAsync(batchContent, cancellationToken).ConfigureAwait(false); // Check responses for each bundle id. // Each bundle id must return some HttpResponseMessage ideally. var bundleIds = batchContent.BatchRequestSteps.Select(a => a.Key); - // TODO: Handling responses. They used to work in v1 core, but not in v2. + var exceptionBag = new List(); foreach (var bundleId in bundleIds) { @@ -727,45 +793,31 @@ namespace Wino.Core.Synchronizers var httpResponseMessage = await batchRequestResponse.GetResponseByIdAsync(bundleId); + if (httpResponseMessage == null) + continue; + using (httpResponseMessage) { - await ProcessSingleNativeRequestResponseAsync(bundle, httpResponseMessage, cancellationToken).ConfigureAwait(false); + if (!httpResponseMessage.IsSuccessStatusCode) + { + var content = await httpResponseMessage.Content.ReadAsStringAsync(); + var errorJson = JsonObject.Parse(content); + var errorString = $"({httpResponseMessage.StatusCode}) {errorJson["error"]["code"]} - {errorJson["error"]["message"]}"; + + exceptionBag.Add(errorString); + } } } + + if (exceptionBag.Any()) + { + var formattedErrorString = string.Join("\n", exceptionBag.Select((item, index) => $"{index + 1}. {item}")); + + throw new SynchronizerException(formattedErrorString); + } } } - private async Task ProcessSingleNativeRequestResponseAsync(IRequestBundle bundle, - HttpResponseMessage httpResponseMessage, - CancellationToken cancellationToken = default) - { - if (httpResponseMessage == null) return; - - if (!httpResponseMessage.IsSuccessStatusCode) - { - throw new SynchronizerException(string.Format(Translator.Exception_SynchronizerFailureHTTP, httpResponseMessage.StatusCode)); - } - else if (bundle is HttpRequestBundle messageBundle) - { - var outlookMessage = await messageBundle.DeserializeBundleAsync(httpResponseMessage, cancellationToken); - - if (outlookMessage == null) return; - - // TODO: Handle new message added or updated. - } - else if (bundle is HttpRequestBundle folderBundle) - { - var outlookFolder = await folderBundle.DeserializeBundleAsync(httpResponseMessage, cancellationToken); - - if (outlookFolder == null) return; - - // TODO: Handle new folder added or updated. - } - else if (bundle is HttpRequestBundle mimeBundle) - { - // TODO: Handle mime retrieve message. - } - } private async Task DownloadMimeMessageAsync(string messageId, CancellationToken cancellationToken = default) { diff --git a/Wino.Core/Wino.Core.csproj b/Wino.Core/Wino.Core.csproj index be8ca55c..edfaccdd 100644 --- a/Wino.Core/Wino.Core.csproj +++ b/Wino.Core/Wino.Core.csproj @@ -17,7 +17,8 @@ - + + all @@ -26,9 +27,9 @@ - - - + + + diff --git a/Wino.Mail.ViewModels/AccountDetailsPageViewModel.cs b/Wino.Mail.ViewModels/AccountDetailsPageViewModel.cs index eb8e0fc0..306f3bff 100644 --- a/Wino.Mail.ViewModels/AccountDetailsPageViewModel.cs +++ b/Wino.Mail.ViewModels/AccountDetailsPageViewModel.cs @@ -56,7 +56,11 @@ namespace Wino.Mail.ViewModels [RelayCommand] private void EditSignature() - => Messenger.Send(new BreadcrumbNavigationRequested("Signature", WinoPage.SignatureManagementPage, Account.Id)); + => Messenger.Send(new BreadcrumbNavigationRequested(Translator.SettingsSignature_Title, WinoPage.SignatureManagementPage, Account.Id)); + + [RelayCommand] + private void EditAliases() + => Messenger.Send(new BreadcrumbNavigationRequested(Translator.SettingsManageAliases_Title, WinoPage.AliasManagementPage, Account.Id)); public Task FolderSyncToggledAsync(IMailItemFolder folderStructure, bool isEnabled) => _folderService.ChangeFolderSynchronizationStateAsync(folderStructure.Id, isEnabled); diff --git a/Wino.Mail.ViewModels/AccountManagementViewModel.cs b/Wino.Mail.ViewModels/AccountManagementViewModel.cs index e503b99b..a1dee843 100644 --- a/Wino.Mail.ViewModels/AccountManagementViewModel.cs +++ b/Wino.Mail.ViewModels/AccountManagementViewModel.cs @@ -154,15 +154,12 @@ namespace Wino.Mail.ViewModels { creationDialog = _dialogService.GetAccountCreationDialog(accountCreationDialogResult.ProviderType); - // _accountService.ExternalAuthenticationAuthenticator = _authenticationProvider.GetAuthenticator(accountCreationDialogResult.ProviderType); - CustomServerInformation customServerInformation = null; createdAccount = new MailAccount() { ProviderType = accountCreationDialogResult.ProviderType, Name = accountCreationDialogResult.AccountName, - SenderName = accountCreationDialogResult.SenderName, AccountColorHex = accountCreationDialogResult.AccountColorHex, Id = Guid.NewGuid() }; @@ -208,30 +205,81 @@ namespace Wino.Mail.ViewModels await _accountService.CreateAccountAsync(createdAccount, tokenInformation, customServerInformation); // Local account has been created. - // Create new synchronizer and start synchronization. + + if (createdAccount.ProviderType != MailProviderType.IMAP4) + { + // Start profile information synchronization. + // It's only available for Outlook and Gmail synchronizers. + + var profileSyncOptions = new SynchronizationOptions() + { + AccountId = createdAccount.Id, + Type = SynchronizationType.UpdateProfile + }; + + var profileSynchronizationResponse = await _winoServerConnectionManager.GetResponseAsync(new NewSynchronizationRequested(profileSyncOptions, SynchronizationSource.Client)); + + var profileSynchronizationResult = profileSynchronizationResponse.Data; + + if (profileSynchronizationResult.CompletedState != SynchronizationCompletedState.Success) + throw new Exception(Translator.Exception_FailedToSynchronizeProfileInformation); + + createdAccount.SenderName = profileSynchronizationResult.ProfileInformation.SenderName; + createdAccount.Base64ProfilePictureData = profileSynchronizationResult.ProfileInformation.Base64ProfilePictureData; + + await _accountService.UpdateProfileInformationAsync(createdAccount.Id, profileSynchronizationResult.ProfileInformation); + } if (creationDialog is ICustomServerAccountCreationDialog customServerAccountCreationDialog) customServerAccountCreationDialog.ShowPreparingFolders(); else creationDialog.State = AccountCreationDialogState.PreparingFolders; - var options = new SynchronizationOptions() + // Start synchronizing folders. + var folderSyncOptions = new SynchronizationOptions() { AccountId = createdAccount.Id, Type = SynchronizationType.FoldersOnly }; - var synchronizationResultResponse = await _winoServerConnectionManager.GetResponseAsync(new NewSynchronizationRequested(options, SynchronizationSource.Client)); + var folderSynchronizationResponse = await _winoServerConnectionManager.GetResponseAsync(new NewSynchronizationRequested(folderSyncOptions, SynchronizationSource.Client)); - var synchronizationResult = synchronizationResultResponse.Data; - if (synchronizationResult.CompletedState != SynchronizationCompletedState.Success) + var folderSynchronizationResult = folderSynchronizationResponse.Data; + + if (folderSynchronizationResult.CompletedState != SynchronizationCompletedState.Success) throw new Exception(Translator.Exception_FailedToSynchronizeFolders); - // Check if Inbox folder is available for the account after synchronization. - var isInboxAvailable = await _folderService.IsInboxAvailableForAccountAsync(createdAccount.Id); + if (createdAccount.IsAliasSyncSupported) + { + // Try to synchronize aliases for the account. - if (!isInboxAvailable) - throw new Exception(Translator.Exception_InboxNotAvailable); + var aliasSyncOptions = new SynchronizationOptions() + { + AccountId = createdAccount.Id, + Type = SynchronizationType.Alias + }; + + var aliasSyncResponse = await _winoServerConnectionManager.GetResponseAsync(new NewSynchronizationRequested(aliasSyncOptions, SynchronizationSource.Client)); + var aliasSynchronizationResult = folderSynchronizationResponse.Data; + + if (aliasSynchronizationResult.CompletedState != SynchronizationCompletedState.Success) + throw new Exception(Translator.Exception_FailedToSynchronizeAliases); + } + else + { + // Create root primary alias for the account. + // This is only available for accounts that do not support alias synchronization. + + await _accountService.CreateRootAliasAsync(createdAccount.Id, createdAccount.Address); + } + + // TODO: Temporary disabled. Is this even needed? Users can configure special folders manually later on if discovery fails. + // Check if Inbox folder is available for the account after synchronization. + + //var isInboxAvailable = await _folderService.IsInboxAvailableForAccountAsync(createdAccount.Id); + + //if (!isInboxAvailable) + // throw new Exception(Translator.Exception_InboxNotAvailable); // Send changes to listeners. ReportUIChange(new AccountCreatedMessage(createdAccount)); diff --git a/Wino.Mail.ViewModels/AliasManagementPageViewModel.cs b/Wino.Mail.ViewModels/AliasManagementPageViewModel.cs new file mode 100644 index 00000000..c11b0daa --- /dev/null +++ b/Wino.Mail.ViewModels/AliasManagementPageViewModel.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using EmailValidation; +using Wino.Core.Domain; +using Wino.Core.Domain.Entities; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Navigation; +using Wino.Core.Domain.Models.Synchronization; +using Wino.Messaging.Server; + +namespace Wino.Mail.ViewModels +{ + public partial class AliasManagementPageViewModel : BaseViewModel + { + private readonly IAccountService _accountService; + private readonly IWinoServerConnectionManager _winoServerConnectionManager; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanSynchronizeAliases))] + private MailAccount account; + + [ObservableProperty] + private List accountAliases = []; + + public bool CanSynchronizeAliases => Account?.IsAliasSyncSupported ?? false; + + public AliasManagementPageViewModel(IDialogService dialogService, + IAccountService accountService, + IWinoServerConnectionManager winoServerConnectionManager) : base(dialogService) + { + _accountService = accountService; + _winoServerConnectionManager = winoServerConnectionManager; + } + + public override async void OnNavigatedTo(NavigationMode mode, object parameters) + { + base.OnNavigatedTo(mode, parameters); + + if (parameters is Guid accountId) + Account = await _accountService.GetAccountAsync(accountId); + + if (Account == null) return; + + await LoadAliasesAsync(); + } + + private async Task LoadAliasesAsync() + { + AccountAliases = await _accountService.GetAccountAliasesAsync(Account.Id); + } + + [RelayCommand] + private async Task SetAliasPrimaryAsync(MailAccountAlias alias) + { + if (alias.IsPrimary) return; + + AccountAliases.ForEach(a => + { + a.IsPrimary = a == alias; + }); + + await _accountService.UpdateAccountAliasesAsync(Account.Id, AccountAliases); + await LoadAliasesAsync(); + } + + [RelayCommand] + private async Task SyncAliasesAsync() + { + if (!CanSynchronizeAliases) return; + + var aliasSyncOptions = new SynchronizationOptions() + { + AccountId = Account.Id, + Type = SynchronizationType.Alias + }; + + var aliasSyncResponse = await _winoServerConnectionManager.GetResponseAsync(new NewSynchronizationRequested(aliasSyncOptions, SynchronizationSource.Client)); + + if (aliasSyncResponse.IsSuccess) + await LoadAliasesAsync(); + else + DialogService.InfoBarMessage(Translator.GeneralTitle_Error, aliasSyncResponse.Message, InfoBarMessageType.Error); + } + + [RelayCommand] + private async Task AddNewAliasAsync() + { + var createdAliasDialog = await DialogService.ShowCreateAccountAliasDialogAsync(); + + if (createdAliasDialog.CreatedAccountAlias == null) return; + + var newAlias = createdAliasDialog.CreatedAccountAlias; + + // Check existence. + if (AccountAliases.Any(a => a.AliasAddress == newAlias.AliasAddress)) + { + await DialogService.ShowMessageAsync(Translator.DialogMessage_AliasExistsTitle, Translator.DialogMessage_AliasExistsMessage); + return; + } + + // Validate all addresses. + if (!EmailValidator.Validate(newAlias.AliasAddress) || (!string.IsNullOrEmpty(newAlias.ReplyToAddress) && !EmailValidator.Validate(newAlias.ReplyToAddress))) + { + await DialogService.ShowMessageAsync(Translator.DialogMessage_InvalidAliasMessage, Translator.DialogMessage_InvalidAliasTitle); + return; + } + + newAlias.AccountId = Account.Id; + + AccountAliases.Add(newAlias); + + await _accountService.UpdateAccountAliasesAsync(Account.Id, AccountAliases); + DialogService.InfoBarMessage(Translator.DialogMessage_AliasCreatedTitle, Translator.DialogMessage_AliasCreatedMessage, InfoBarMessageType.Success); + + await LoadAliasesAsync(); + } + + [RelayCommand] + private async Task DeleteAliasAsync(MailAccountAlias alias) + { + // Primary aliases can't be deleted. + if (alias.IsPrimary) + { + await DialogService.ShowMessageAsync(Translator.Info_CantDeletePrimaryAliasMessage, Translator.GeneralTitle_Warning); + return; + } + + // Root aliases can't be deleted. + if (alias.IsRootAlias) + { + await DialogService.ShowMessageAsync(Translator.DialogMessage_CantDeleteRootAliasTitle, Translator.DialogMessage_CantDeleteRootAliasMessage); + return; + } + + await _accountService.DeleteAccountAliasAsync(alias.Id); + await LoadAliasesAsync(); + } + } +} diff --git a/Wino.Mail.ViewModels/AppShellViewModel.cs b/Wino.Mail.ViewModels/AppShellViewModel.cs index 5e7b0b53..0acc62da 100644 --- a/Wino.Mail.ViewModels/AppShellViewModel.cs +++ b/Wino.Mail.ViewModels/AppShellViewModel.cs @@ -729,17 +729,26 @@ namespace Wino.Mail.ViewModels operationAccount = accounts.FirstOrDefault(); else { - // There are multiple accounts and there is no selection. - // Don't list all accounts, but only accounts that belong to Merged Inbox. - if (latestSelectedAccountMenuItem is MergedAccountMenuItem selectedMergedAccountMenuItem) { + // There are multiple accounts and there is no selection. + // Don't list all accounts, but only accounts that belong to Merged Inbox. + var mergedAccounts = accounts.Where(a => a.MergedInboxId == selectedMergedAccountMenuItem.EntityId); if (!mergedAccounts.Any()) return; Messenger.Send(new CreateNewMailWithMultipleAccountsRequested(mergedAccounts.ToList())); } + else if (latestSelectedAccountMenuItem is AccountMenuItem selectedAccountMenuItem) + { + operationAccount = selectedAccountMenuItem.HoldingAccounts.ElementAt(0); + } + else + { + // User is at some other page. List all accounts. + Messenger.Send(new CreateNewMailWithMultipleAccountsRequested(accounts)); + } } } @@ -779,7 +788,7 @@ namespace Wino.Mail.ViewModels var (draftMailCopy, draftBase64MimeMessage) = await _mailService.CreateDraftAsync(account.Id, draftOptions).ConfigureAwait(false); - var draftPreparationRequest = new DraftPreparationRequest(account, draftMailCopy, draftBase64MimeMessage); + var draftPreparationRequest = new DraftPreparationRequest(account, draftMailCopy, draftBase64MimeMessage, draftOptions.Reason); await _winoRequestDelegator.ExecuteAsync(draftPreparationRequest); } @@ -795,7 +804,7 @@ namespace Wino.Mail.ViewModels } protected override void OnAccountRemoved(MailAccount removedAccount) - => Messenger.Send(new AccountsMenuRefreshRequested(true)); + => Messenger.Send(new AccountsMenuRefreshRequested(false)); protected override async void OnAccountCreated(MailAccount createdAccount) { @@ -877,12 +886,9 @@ namespace Wino.Mail.ViewModels { await RecreateMenuItemsAsync(); - if (message.AutomaticallyNavigateFirstItem) + if (MenuItems.FirstOrDefault(a => a is IAccountMenuItem) is IAccountMenuItem firstAccount) { - if (MenuItems.FirstOrDefault(a => a is IAccountMenuItem) is IAccountMenuItem firstAccount) - { - await ChangeLoadedAccountAsync(firstAccount); - } + await ChangeLoadedAccountAsync(firstAccount, message.AutomaticallyNavigateFirstItem); } } diff --git a/Wino.Mail.ViewModels/ComposePageViewModel.cs b/Wino.Mail.ViewModels/ComposePageViewModel.cs index d3424bf5..f91ca00a 100644 --- a/Wino.Mail.ViewModels/ComposePageViewModel.cs +++ b/Wino.Mail.ViewModels/ComposePageViewModel.cs @@ -8,7 +8,6 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; using MimeKit; -using MimeKit.Utils; using Wino.Core.Domain; using Wino.Core.Domain.Entities; using Wino.Core.Domain.Enums; @@ -21,6 +20,7 @@ using Wino.Core.Extensions; using Wino.Core.Services; using Wino.Mail.ViewModels.Data; using Wino.Messaging.Client.Mails; +using Wino.Messaging.Server; namespace Wino.Mail.ViewModels { @@ -68,6 +68,12 @@ namespace Wino.Mail.ViewModels [NotifyCanExecuteChangedFor(nameof(SendCommand))] private MailAccount composingAccount; + [ObservableProperty] + private List availableAliases; + + [ObservableProperty] + private MailAccountAlias selectedAlias; + [ObservableProperty] private bool isDraggingOverComposerGrid; @@ -112,6 +118,7 @@ namespace Wino.Mail.ViewModels private readonly IWinoRequestDelegator _worker; public readonly IFontService FontService; public readonly IPreferencesService PreferencesService; + private readonly IWinoServerConnectionManager _winoServerConnectionManager; public readonly IContactService ContactService; public ComposePageViewModel(IDialogService dialogService, @@ -124,21 +131,23 @@ namespace Wino.Mail.ViewModels IWinoRequestDelegator worker, IContactService contactService, IFontService fontService, - IPreferencesService preferencesService) : base(dialogService) + IPreferencesService preferencesService, + IWinoServerConnectionManager winoServerConnectionManager) : base(dialogService) { NativeAppService = nativeAppService; - _folderService = folderService; ContactService = contactService; FontService = fontService; + PreferencesService = preferencesService; + _folderService = folderService; _mailService = mailService; _launchProtocolService = launchProtocolService; _mimeFileService = mimeFileService; _accountService = accountService; _worker = worker; + _winoServerConnectionManager = winoServerConnectionManager; SelectedToolbarSection = ToolbarSections[0]; - PreferencesService = preferencesService; } [RelayCommand] @@ -163,6 +172,12 @@ namespace Wino.Mail.ViewModels if (!isConfirmed) return; } + if (SelectedAlias == null) + { + DialogService.InfoBarMessage(Translator.DialogMessage_AliasNotSelectedTitle, Translator.DialogMessage_AliasNotSelectedMessage, InfoBarMessageType.Error); + return; + } + // Save mime changes before sending. await UpdateMimeChangesAsync().ConfigureAwait(false); @@ -177,11 +192,26 @@ namespace Wino.Mail.ViewModels int count = (int)memoryStream.Length; var base64EncodedMessage = Convert.ToBase64String(buffer); - var draftSendPreparationRequest = new SendDraftPreparationRequest(CurrentMailDraftItem.MailCopy, sentFolder, CurrentMailDraftItem.AssignedFolder, CurrentMailDraftItem.AssignedAccount.Preferences, base64EncodedMessage); + var draftSendPreparationRequest = new SendDraftPreparationRequest(CurrentMailDraftItem.MailCopy, + SelectedAlias, + sentFolder, + CurrentMailDraftItem.AssignedFolder, + CurrentMailDraftItem.AssignedAccount.Preferences, + base64EncodedMessage); await _worker.ExecuteAsync(draftSendPreparationRequest); } + public async Task IncludeAttachmentAsync(MailAttachmentViewModel viewModel) + { + //if (bodyBuilder == null) return; + + //bodyBuilder.Attachments.Add(viewModel.FileName, new MemoryStream(viewModel.Content)); + + //LoadAttachments(); + IncludedAttachments.Add(viewModel); + } + private async Task UpdateMimeChangesAsync() { if (isUpdatingMimeBlocked || CurrentMimeMessage == null || ComposingAccount == null || CurrentMailDraftItem == null) return; @@ -194,6 +224,8 @@ namespace Wino.Mail.ViewModels SaveImportance(); SaveSubject(); + SaveFromAddress(); + SaveReplyToAddress(); await SaveAttachmentsAsync(); await SaveBodyAsync(); @@ -207,6 +239,8 @@ namespace Wino.Mail.ViewModels { CurrentMailDraftItem.Subject = CurrentMimeMessage.Subject; CurrentMailDraftItem.PreviewText = CurrentMimeMessage.TextBody; + CurrentMailDraftItem.FromAddress = SelectedAlias.AliasAddress; + CurrentMailDraftItem.HasAttachments = CurrentMimeMessage.Attachments.Any(); // Update database. await _mailService.UpdateMailAsync(CurrentMailDraftItem.MailCopy); @@ -224,7 +258,10 @@ namespace Wino.Mail.ViewModels } } - private void SaveImportance() { CurrentMimeMessage.Importance = IsImportanceSelected ? SelectedMessageImportance : MessageImportance.Normal; } + private void SaveImportance() + { + CurrentMimeMessage.Importance = IsImportanceSelected ? SelectedMessageImportance : MessageImportance.Normal; + } private void SaveSubject() { @@ -234,6 +271,31 @@ namespace Wino.Mail.ViewModels } } + private void ClearCurrentMimeAttachments() + { + var attachments = new List(); + var multiparts = new List(); + var iter = new MimeIterator(CurrentMimeMessage); + + // collect our list of attachments and their parent multiparts + while (iter.MoveNext()) + { + var multipart = iter.Parent as Multipart; + var part = iter.Current as MimePart; + + if (multipart != null && part != null && part.IsAttachment) + { + // keep track of each attachment's parent multipart + multiparts.Add(multipart); + attachments.Add(part); + } + } + + // now remove each attachment from its parent multipart... + for (int i = 0; i < attachments.Count; i++) + multiparts[i].Remove(attachments[i]); + } + private async Task SaveBodyAsync() { if (GetHTMLBodyFunction != null) @@ -241,8 +303,7 @@ namespace Wino.Mail.ViewModels bodyBuilder.SetHtmlBody(await GetHTMLBodyFunction()); } - if (bodyBuilder.HtmlBody != null && bodyBuilder.TextBody != null) - CurrentMimeMessage.Body = bodyBuilder.ToMessageBody(); + CurrentMimeMessage.Body = bodyBuilder.ToMessageBody(); } [RelayCommand(CanExecute = nameof(canSendMail))] @@ -280,6 +341,8 @@ namespace Wino.Mail.ViewModels base.OnNavigatedFrom(mode, parameters); await UpdateMimeChangesAsync().ConfigureAwait(false); + + Messenger.Send(new KillChromiumRequested()); } public override async void OnNavigatedTo(NavigationMode mode, object parameters) @@ -288,92 +351,58 @@ namespace Wino.Mail.ViewModels if (parameters != null && parameters is MailItemViewModel mailItem) { - await LoadAccountsAsync(); - CurrentMailDraftItem = mailItem; - _ = TryPrepareComposeAsync(true); - } - - ToItems.CollectionChanged -= ContactListCollectionChanged; - ToItems.CollectionChanged += ContactListCollectionChanged; - - // Check if there is any delivering mail address from protocol launch. - - if (_launchProtocolService.MailToUri != null) - { - // TODO - //var requestedMailContact = await GetAddressInformationAsync(_launchProtocolService.MailtoParameters, ToItems); - - //if (requestedMailContact != null) - //{ - // ToItems.Add(requestedMailContact); - //} - //else - // DialogService.InfoBarMessage("Invalid Address", "Address is not a valid e-mail address.", InfoBarMessageType.Warning); - - // Clear the address. - _launchProtocolService.MailToUri = null; - } - } - - private void ContactListCollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) - { - if (e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Add) - { - // Prevent duplicates. - if (!(sender is ObservableCollection list)) - return; - - foreach (var item in e.NewItems) - { - if (item is AddressInformation addedInfo && list.Count(a => a == addedInfo) > 1) - { - var addedIndex = list.IndexOf(addedInfo); - list.RemoveAt(addedIndex); - } - } - } - } - - private async Task LoadAccountsAsync() - { - // Load accounts - - var accounts = await _accountService.GetAccountsAsync(); - - foreach (var account in accounts) - { - Accounts.Add(account); + await TryPrepareComposeAsync(true); } } private async Task InitializeComposerAccountAsync() { + if (CurrentMailDraftItem == null) return false; + if (ComposingAccount != null) return true; - if (CurrentMailDraftItem == null) - return false; + var composingAccount = await _accountService.GetAccountAsync(CurrentMailDraftItem.AssignedAccount.Id).ConfigureAwait(false); + if (composingAccount == null) return false; + + var aliases = await _accountService.GetAccountAliasesAsync(composingAccount.Id).ConfigureAwait(false); + + if (aliases == null || !aliases.Any()) return false; + + // MailAccountAlias primaryAlias = aliases.Find(a => a.IsPrimary) ?? aliases.First(); + + // Auto-select the correct alias from the message itself. + // If can't, fallback to primary alias. + + MailAccountAlias primaryAlias = null; + + if (!string.IsNullOrEmpty(CurrentMailDraftItem.FromAddress)) + { + primaryAlias = aliases.Find(a => a.AliasAddress == CurrentMailDraftItem.FromAddress); + } + + primaryAlias ??= await _accountService.GetPrimaryAccountAliasAsync(ComposingAccount.Id).ConfigureAwait(false); await ExecuteUIThread(() => { - ComposingAccount = Accounts.FirstOrDefault(a => a.Id == CurrentMailDraftItem.AssignedAccount.Id); + ComposingAccount = composingAccount; + AvailableAliases = aliases; + SelectedAlias = primaryAlias; }); - return ComposingAccount != null; + return true; } private async Task TryPrepareComposeAsync(bool downloadIfNeeded) { - if (CurrentMailDraftItem == null) - return; + if (CurrentMailDraftItem == null) return; bool isComposerInitialized = await InitializeComposerAccountAsync(); - if (!isComposerInitialized) - { - return; - } + if (!isComposerInitialized) return; + + retry: // Replying existing message. MimeMessageInformation mimeMessageInformation = null; @@ -386,18 +415,24 @@ namespace Wino.Mail.ViewModels { if (downloadIfNeeded) { - // TODO: Folder id needs to be passed. - // TODO: Send mail retrieve request. - // _worker.Queue(new FetchSingleItemRequest(ComposingAccount.Id, CurrentMailDraftItem.Id, string.Empty)); + downloadIfNeeded = false; + + var package = new DownloadMissingMessageRequested(CurrentMailDraftItem.AssignedAccount.Id, CurrentMailDraftItem.MailCopy); + var downloadResponse = await _winoServerConnectionManager.GetResponseAsync(package); + + if (downloadResponse.IsSuccess) + { + goto retry; + } } - //else - // DialogService.ShowMIMENotFoundMessage(); + else + DialogService.InfoBarMessage(Translator.Info_ComposerMissingMIMETitle, Translator.Info_ComposerMissingMIMEMessage, InfoBarMessageType.Error); return; } catch (IOException) { - DialogService.InfoBarMessage("Busy", "Mail is being processed. Please wait a moment and try again.", InfoBarMessageType.Warning); + DialogService.InfoBarMessage(Translator.Busy, Translator.Exception_MailProcessing, InfoBarMessageType.Warning); } catch (ComposerMimeNotFoundException) { @@ -416,6 +451,8 @@ namespace Wino.Mail.ViewModels { // Extract information + CurrentMimeMessage = replyingMime; + ToItems.Clear(); CCItems.Clear(); BCCItems.Clear(); @@ -424,22 +461,22 @@ namespace Wino.Mail.ViewModels LoadAddressInfo(replyingMime.Cc, CCItems); LoadAddressInfo(replyingMime.Bcc, BCCItems); - LoadAttachments(replyingMime.Attachments); + LoadAttachments(); if (replyingMime.Cc.Any() || replyingMime.Bcc.Any()) IsCCBCCVisible = true; Subject = replyingMime.Subject; - CurrentMimeMessage = replyingMime; - Messenger.Send(new CreateNewComposeMailRequested(renderModel)); }); } - private void LoadAttachments(IEnumerable mimeEntities) + private void LoadAttachments() { - foreach (var attachment in mimeEntities) + if (CurrentMimeMessage == null) return; + + foreach (var attachment in CurrentMimeMessage.Attachments) { if (attachment.IsAttachment && attachment is MimePart attachmentPart) { @@ -459,6 +496,28 @@ namespace Wino.Mail.ViewModels } } + private void SaveFromAddress() + { + if (SelectedAlias == null) return; + + CurrentMimeMessage.From.Clear(); + CurrentMimeMessage.From.Add(new MailboxAddress(ComposingAccount.SenderName, SelectedAlias.AliasAddress)); + } + + private void SaveReplyToAddress() + { + if (SelectedAlias == null) return; + + if (!string.IsNullOrEmpty(SelectedAlias.ReplyToAddress)) + { + if (!CurrentMimeMessage.ReplyTo.Any(a => a is MailboxAddress mailboxAddress && mailboxAddress.Address == SelectedAlias.ReplyToAddress)) + { + CurrentMimeMessage.ReplyTo.Clear(); + CurrentMimeMessage.ReplyTo.Add(new MailboxAddress(SelectedAlias.ReplyToAddress, SelectedAlias.ReplyToAddress)); + } + } + } + private void SaveAddressInfo(IEnumerable addresses, InternetAddressList list) { list.Clear(); diff --git a/Wino.Mail.ViewModels/Data/AccountProviderDetailViewModel.cs b/Wino.Mail.ViewModels/Data/AccountProviderDetailViewModel.cs index 8cdacde7..3d1c5e3a 100644 --- a/Wino.Mail.ViewModels/Data/AccountProviderDetailViewModel.cs +++ b/Wino.Mail.ViewModels/Data/AccountProviderDetailViewModel.cs @@ -23,6 +23,8 @@ namespace Wino.Mail.ViewModels.Data public int HoldingAccountCount => 1; + public bool HasProfilePicture => !string.IsNullOrEmpty(Account.Base64ProfilePictureData); + public AccountProviderDetailViewModel(IProviderDetail providerDetail, MailAccount account) { ProviderDetail = providerDetail; diff --git a/Wino.Mail.ViewModels/Data/BreadcrumbNavigationItemViewModel.cs b/Wino.Mail.ViewModels/Data/BreadcrumbNavigationItemViewModel.cs index c90b81aa..9ba7f782 100644 --- a/Wino.Mail.ViewModels/Data/BreadcrumbNavigationItemViewModel.cs +++ b/Wino.Mail.ViewModels/Data/BreadcrumbNavigationItemViewModel.cs @@ -3,31 +3,21 @@ using Wino.Messaging.Client.Navigation; namespace Wino.Mail.ViewModels.Data { - public class BreadcrumbNavigationItemViewModel : ObservableObject + public partial class BreadcrumbNavigationItemViewModel : ObservableObject { + [ObservableProperty] + private string title; + + [ObservableProperty] + private bool isActive; + public BreadcrumbNavigationRequested Request { get; set; } public BreadcrumbNavigationItemViewModel(BreadcrumbNavigationRequested request, bool isActive) { Request = request; Title = request.PageTitle; - - this.isActive = isActive; - } - - private string title; - public string Title - { - get => title; - set => SetProperty(ref title, value); - } - - private bool isActive; - - public bool IsActive - { - get => isActive; - set => SetProperty(ref isActive, value); + IsActive = isActive; } } } diff --git a/Wino.Mail.ViewModels/Data/MailAttachmentViewModel.cs b/Wino.Mail.ViewModels/Data/MailAttachmentViewModel.cs index 90387ce4..469b55ba 100644 --- a/Wino.Mail.ViewModels/Data/MailAttachmentViewModel.cs +++ b/Wino.Mail.ViewModels/Data/MailAttachmentViewModel.cs @@ -6,9 +6,8 @@ using Wino.Core.Extensions; namespace Wino.Mail.ViewModels.Data { - public class MailAttachmentViewModel : ObservableObject + public partial class MailAttachmentViewModel : ObservableObject { - private bool isBusy; private readonly MimePart _mimePart; public MailAttachmentType AttachmentType { get; } @@ -22,23 +21,21 @@ namespace Wino.Mail.ViewModels.Data /// /// Gets or sets whether attachment is busy with opening or saving etc. /// - public bool IsBusy - { - get => isBusy; - set => SetProperty(ref isBusy, value); - } + [ObservableProperty] + private bool isBusy; public MailAttachmentViewModel(MimePart mimePart) { _mimePart = mimePart; - var array = new byte[_mimePart.Content.Stream.Length]; - _mimePart.Content.Stream.Read(array, 0, (int)_mimePart.Content.Stream.Length); + var memoryStream = new MemoryStream(); - Content = array; + using (memoryStream) mimePart.Content.DecodeTo(memoryStream); + + Content = memoryStream.ToArray(); FileName = mimePart.FileName; - ReadableSize = mimePart.Content.Stream.Length.GetBytesReadable(); + ReadableSize = ((long)Content.Length).GetBytesReadable(); var extension = Path.GetExtension(FileName); AttachmentType = GetAttachmentType(extension); diff --git a/Wino.Mail.ViewModels/Data/MailItemViewModel.cs b/Wino.Mail.ViewModels/Data/MailItemViewModel.cs index aa77ead0..d96e486e 100644 --- a/Wino.Mail.ViewModels/Data/MailItemViewModel.cs +++ b/Wino.Mail.ViewModels/Data/MailItemViewModel.cs @@ -18,8 +18,6 @@ namespace Wino.Mail.ViewModels.Data public string MessageId => ((IMailItem)MailCopy).MessageId; public string FromName => ((IMailItem)MailCopy).FromName ?? FromAddress; public DateTime CreationDate => ((IMailItem)MailCopy).CreationDate; - public string FromAddress => ((IMailItem)MailCopy).FromAddress; - public bool HasAttachments => ((IMailItem)MailCopy).HasAttachments; public string References => ((IMailItem)MailCopy).References; public string InReplyTo => ((IMailItem)MailCopy).InReplyTo; @@ -77,6 +75,18 @@ namespace Wino.Mail.ViewModels.Data set => SetProperty(MailCopy.PreviewText, value, MailCopy, (u, n) => u.PreviewText = n); } + public string FromAddress + { + get => MailCopy.FromAddress; + set => SetProperty(MailCopy.FromAddress, value, MailCopy, (u, n) => u.FromAddress = n); + } + + public bool HasAttachments + { + get => MailCopy.HasAttachments; + set => SetProperty(MailCopy.HasAttachments, value, MailCopy, (u, n) => u.HasAttachments = n); + } + public MailItemFolder AssignedFolder => ((IMailItem)MailCopy).AssignedFolder; public MailAccount AssignedAccount => ((IMailItem)MailCopy).AssignedAccount; @@ -94,6 +104,8 @@ namespace Wino.Mail.ViewModels.Data OnPropertyChanged(nameof(DraftId)); OnPropertyChanged(nameof(Subject)); OnPropertyChanged(nameof(PreviewText)); + OnPropertyChanged(nameof(FromAddress)); + OnPropertyChanged(nameof(HasAttachments)); } public IEnumerable GetContainingIds() => new[] { UniqueId }; diff --git a/Wino.Mail.ViewModels/MailRenderingPageViewModel.cs b/Wino.Mail.ViewModels/MailRenderingPageViewModel.cs index 305a7ece..7fe96b2e 100644 --- a/Wino.Mail.ViewModels/MailRenderingPageViewModel.cs +++ b/Wino.Mail.ViewModels/MailRenderingPageViewModel.cs @@ -273,7 +273,7 @@ namespace Wino.Mail.ViewModels var (draftMailCopy, draftBase64MimeMessage) = await _mailService.CreateDraftAsync(initializedMailItemViewModel.AssignedAccount.Id, draftOptions).ConfigureAwait(false); - var draftPreparationRequest = new DraftPreparationRequest(initializedMailItemViewModel.AssignedAccount, draftMailCopy, draftBase64MimeMessage, initializedMailItemViewModel.MailCopy); + var draftPreparationRequest = new DraftPreparationRequest(initializedMailItemViewModel.AssignedAccount, draftMailCopy, draftBase64MimeMessage, draftOptions.Reason, initializedMailItemViewModel.MailCopy); await _requestDelegator.ExecuteAsync(draftPreparationRequest); diff --git a/Wino.Mail.ViewModels/Wino.Mail.ViewModels.csproj b/Wino.Mail.ViewModels/Wino.Mail.ViewModels.csproj index 946a3bf8..82c61360 100644 --- a/Wino.Mail.ViewModels/Wino.Mail.ViewModels.csproj +++ b/Wino.Mail.ViewModels/Wino.Mail.ViewModels.csproj @@ -7,6 +7,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Wino.Mail/App.xaml b/Wino.Mail/App.xaml index 64b6c59e..57cb3f20 100644 --- a/Wino.Mail/App.xaml +++ b/Wino.Mail/App.xaml @@ -1,9 +1,10 @@ - + @@ -48,6 +49,14 @@ + + + -