diff --git a/Wino.Core.Domain/Entities/Mail/MailAccountAlias.cs b/Wino.Core.Domain/Entities/Mail/MailAccountAlias.cs index b23aaf4e..e58e5b01 100644 --- a/Wino.Core.Domain/Entities/Mail/MailAccountAlias.cs +++ b/Wino.Core.Domain/Entities/Mail/MailAccountAlias.cs @@ -2,6 +2,8 @@ using System.Collections.ObjectModel; using System.Security.Cryptography.X509Certificates; using SQLite; +using Wino.Core.Domain; +using Wino.Core.Domain.Enums; namespace Wino.Core.Domain.Entities.Mail; @@ -42,6 +44,16 @@ public class RemoteAccountAlias /// Used for Gmail only. /// public string AliasSenderName { get; set; } + + /// + /// Whether the alias was entered by the user or discovered from the provider. + /// + public AliasSource Source { get; set; } = AliasSource.Manual; + + /// + /// Represents Wino's confidence that the alias can be used for sending. + /// + public AliasSendCapability SendCapability { get; set; } = AliasSendCapability.Unknown; } public class MailAccountAlias : RemoteAccountAlias @@ -70,4 +82,28 @@ public class MailAccountAlias : RemoteAccountAlias [Ignore] public ObservableCollection Certificates { get; set; } = []; + + [Ignore] + public bool IsCapabilityConfirmed => SendCapability == AliasSendCapability.Confirmed; + + [Ignore] + public bool IsCapabilityUnknown => SendCapability == AliasSendCapability.Unknown; + + [Ignore] + public bool IsCapabilityDenied => SendCapability == AliasSendCapability.Denied; + + [Ignore] + public string CapabilityDisplayName => SendCapability switch + { + AliasSendCapability.Confirmed => Translator.AccountAlias_Status_Confirmed, + AliasSendCapability.Denied => Translator.AccountAlias_Status_Denied, + _ => Translator.AccountAlias_Status_Unknown + }; + + [Ignore] + public string SourceDisplayName => Source switch + { + AliasSource.ProviderDiscovered => Translator.AccountAlias_Source_ProviderDiscovered, + _ => Translator.AccountAlias_Source_Manual + }; } diff --git a/Wino.Core.Domain/Entities/Shared/MailAccount.cs b/Wino.Core.Domain/Entities/Shared/MailAccount.cs index c27008a4..5ad3e46d 100644 --- a/Wino.Core.Domain/Entities/Shared/MailAccount.cs +++ b/Wino.Core.Domain/Entities/Shared/MailAccount.cs @@ -120,7 +120,7 @@ public class MailAccount /// /// Gets whether the account can perform AliasInformation sync type. /// - public bool IsAliasSyncSupported => ProviderType == MailProviderType.Gmail; + public bool IsAliasSyncSupported => ProviderType == MailProviderType.Gmail || ProviderType == MailProviderType.Outlook; public override string ToString() => Name; } diff --git a/Wino.Core.Domain/Enums/AliasSendCapability.cs b/Wino.Core.Domain/Enums/AliasSendCapability.cs new file mode 100644 index 00000000..436caf35 --- /dev/null +++ b/Wino.Core.Domain/Enums/AliasSendCapability.cs @@ -0,0 +1,8 @@ +namespace Wino.Core.Domain.Enums; + +public enum AliasSendCapability +{ + Unknown = 0, + Confirmed = 1, + Denied = 2 +} diff --git a/Wino.Core.Domain/Enums/AliasSource.cs b/Wino.Core.Domain/Enums/AliasSource.cs new file mode 100644 index 00000000..90f1a18d --- /dev/null +++ b/Wino.Core.Domain/Enums/AliasSource.cs @@ -0,0 +1,7 @@ +namespace Wino.Core.Domain.Enums; + +public enum AliasSource +{ + Manual = 0, + ProviderDiscovered = 1 +} diff --git a/Wino.Core.Domain/Interfaces/IAccountService.cs b/Wino.Core.Domain/Interfaces/IAccountService.cs index cc31f0d9..d802c8d4 100644 --- a/Wino.Core.Domain/Interfaces/IAccountService.cs +++ b/Wino.Core.Domain/Interfaces/IAccountService.cs @@ -147,6 +147,7 @@ public interface IAccountService /// Account id. /// Primary alias for the account. Task GetPrimaryAccountAliasAsync(Guid accountId); + Task UpdateAliasSendCapabilityAsync(Guid accountId, string aliasAddress, AliasSendCapability capability); Task IsAccountFocusedEnabledAsync(Guid accountId); /// diff --git a/Wino.Core.Domain/Translations/en_US/resources.json b/Wino.Core.Domain/Translations/en_US/resources.json index 37c631ce..4844b0cc 100644 --- a/Wino.Core.Domain/Translations/en_US/resources.json +++ b/Wino.Core.Domain/Translations/en_US/resources.json @@ -1,9 +1,14 @@ { "AccountAlias_Column_Alias": "Alias", "AccountAlias_Column_IsPrimaryAlias": "Primary", - "AccountAlias_Column_Verified": "Verified", - "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.", + "AccountAlias_Column_Status": "Status", + "AccountAlias_Disclaimer_FirstLine": "Wino can import Gmail aliases directly and can best-effort discover Outlook aliases from Microsoft Graph.", + "AccountAlias_Disclaimer_SecondLine": "Manual aliases are still available for Outlook or IMAP when the provider can't confirm sending capability ahead of time.", + "AccountAlias_Source_Manual": "Manual", + "AccountAlias_Source_ProviderDiscovered": "Provider discovered", + "AccountAlias_Status_Confirmed": "Ready to send", + "AccountAlias_Status_Unknown": "Capability unknown", + "AccountAlias_Status_Denied": "Send denied", "AccountCacheReset_Title": "Account Cache Reset", "AccountCacheReset_Message": "This account requires full re-sychronization to continue working. Please wait while Wino re-synchronizes your messages...", "AccountContactNameYou": "You", @@ -344,6 +349,7 @@ "Exception_InvalidMultiAccountMoveTarget": "You can't move multiple items that belong to different accounts in linked account.", "Exception_MailProcessing": "This mail is still being processed. Please try again after few seconds.", "Exception_MissingAlias": "Primary alias does not exist for this account. Creating draft failed.", + "Exception_AliasSendDenied_Message": "You do not have permission to send from alias {0}. Pick another alias or update your mailbox permissions.", "Exception_NullAssignedAccount": "Assigned account is null", "Exception_NullAssignedFolder": "Assigned folder is null", "Exception_SynchronizerFailureHTTP": "Response handling failed with error HTTP code {0}", diff --git a/Wino.Core.Tests/Services/AccountAliasCapabilityTests.cs b/Wino.Core.Tests/Services/AccountAliasCapabilityTests.cs new file mode 100644 index 00000000..676134d3 --- /dev/null +++ b/Wino.Core.Tests/Services/AccountAliasCapabilityTests.cs @@ -0,0 +1,121 @@ +using FluentAssertions; +using Moq; +using Wino.Core.Domain.Entities.Mail; +using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Domain.Enums; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Tests.Helpers; +using Wino.Services; +using Xunit; + +namespace Wino.Core.Tests.Services; + +public class AccountAliasCapabilityTests : IAsyncLifetime +{ + private InMemoryDatabaseService _databaseService = null!; + private AccountService _accountService = null!; + + public async Task InitializeAsync() + { + _databaseService = new InMemoryDatabaseService(); + await _databaseService.InitializeAsync(); + _accountService = CreateAccountService(_databaseService); + } + + public async Task DisposeAsync() => await _databaseService.DisposeAsync(); + + [Fact] + public async Task CreateRootAliasAsync_SetsManualConfirmedDefaults() + { + var accountId = Guid.NewGuid(); + + await _accountService.CreateRootAliasAsync(accountId, "root@example.com"); + + var aliases = await _accountService.GetAccountAliasesAsync(accountId); + var alias = aliases.Should().ContainSingle().Subject; + + alias.Source.Should().Be(AliasSource.Manual); + alias.SendCapability.Should().Be(AliasSendCapability.Confirmed); + alias.IsPrimary.Should().BeTrue(); + alias.IsRootAlias.Should().BeTrue(); + } + + [Fact] + public async Task UpdateRemoteAliasInformationAsync_PreservesManualAliasesWhileAddingProviderAliases() + { + var account = new MailAccount + { + Id = Guid.NewGuid(), + Name = "Alias Test", + Address = "primary@example.com", + ProviderType = MailProviderType.Outlook + }; + + await _databaseService.Connection.InsertAsync(account, typeof(MailAccount)); + + await _accountService.UpdateAccountAliasesAsync(account.Id, + [ + new MailAccountAlias + { + Id = Guid.NewGuid(), + AccountId = account.Id, + AliasAddress = "primary@example.com", + ReplyToAddress = "primary@example.com", + IsPrimary = true, + IsRootAlias = true, + Source = AliasSource.Manual, + SendCapability = AliasSendCapability.Confirmed + }, + new MailAccountAlias + { + Id = Guid.NewGuid(), + AccountId = account.Id, + AliasAddress = "custom@example.com", + ReplyToAddress = "replies@example.com", + Source = AliasSource.Manual, + SendCapability = AliasSendCapability.Unknown + } + ]); + + await _accountService.UpdateRemoteAliasInformationAsync(account, + [ + new RemoteAccountAlias + { + AliasAddress = "primary@example.com", + ReplyToAddress = "primary@example.com", + IsPrimary = true, + IsRootAlias = true, + Source = AliasSource.ProviderDiscovered, + SendCapability = AliasSendCapability.Confirmed + }, + new RemoteAccountAlias + { + AliasAddress = "sales@example.com", + ReplyToAddress = "sales@example.com", + Source = AliasSource.ProviderDiscovered, + SendCapability = AliasSendCapability.Unknown + } + ]); + + var aliases = await _accountService.GetAccountAliasesAsync(account.Id); + + aliases.Should().Contain(a => a.AliasAddress == "custom@example.com" && a.Source == AliasSource.Manual); + aliases.Should().Contain(a => a.AliasAddress == "sales@example.com" && a.Source == AliasSource.ProviderDiscovered); + } + + private static AccountService CreateAccountService(InMemoryDatabaseService databaseService) + { + var preferencesService = new Mock(); + var signatureService = new Mock(); + var mimeFileService = new Mock(); + var contactPictureFileService = new Mock(); + + return new AccountService( + databaseService, + signatureService.Object, + Mock.Of(), + mimeFileService.Object, + preferencesService.Object, + contactPictureFileService.Object); + } +} diff --git a/Wino.Core.Tests/Services/MailThreadingTests.cs b/Wino.Core.Tests/Services/MailThreadingTests.cs index eccdcd48..1f0af6ab 100644 --- a/Wino.Core.Tests/Services/MailThreadingTests.cs +++ b/Wino.Core.Tests/Services/MailThreadingTests.cs @@ -60,7 +60,9 @@ public class MailThreadingTests : IAsyncLifetime ReplyToAddress = _account.Address, IsPrimary = true, IsRootAlias = true, - IsVerified = true + IsVerified = true, + Source = AliasSource.Manual, + SendCapability = AliasSendCapability.Confirmed }; await _databaseService.Connection.InsertAsync(_account, typeof(MailAccount)); @@ -188,6 +190,45 @@ public class MailThreadingTests : IAsyncLifetime draftMailCopy.References.Should().Be($"{rootMessageId};{parentMessageId}"); } + [Fact] + public async Task CreateDraftAsync_Reply_PicksAliasFromDeliveredToHeader() + { + var secondaryAlias = new MailAccountAlias + { + Id = Guid.NewGuid(), + AccountId = _account.Id, + AliasAddress = "support@test.local", + ReplyToAddress = "support@test.local", + IsPrimary = false, + IsRootAlias = false, + Source = AliasSource.Manual, + SendCapability = AliasSendCapability.Unknown + }; + + await _databaseService.Connection.InsertAsync(secondaryAlias, typeof(MailAccountAlias)); + + var referencedMimeMessage = CreateReferencedMimeMessage("Alias reply"); + referencedMimeMessage.Headers.Add("Delivered-To", ""); + + var (draftMailCopy, draftBase64MimeMessage) = await _mailService.CreateDraftAsync( + _account.Id, + new DraftCreationOptions + { + Reason = DraftCreationReason.Reply, + ReferencedMessage = new ReferencedMessage + { + MimeMessage = referencedMimeMessage, + MailCopy = new MailCopy { UniqueId = Guid.NewGuid(), Id = Guid.NewGuid().ToString(), MessageId = "alias-parent@domain.com" } + } + }); + + var mimeMessage = draftBase64MimeMessage.GetMimeMessageFromBase64(); + + draftMailCopy.FromAddress.Should().Be("support@test.local"); + mimeMessage.From.Mailboxes.Should().ContainSingle(m => m.Address == "support@test.local"); + mimeMessage.ReplyTo.Mailboxes.Should().ContainSingle(m => m.Address == "support@test.local"); + } + private static MimeMessage CreateReferencedMimeMessage(string subject, string? messageId = null) { var message = new MimeMessage(); diff --git a/Wino.Core/Extensions/GoogleIntegratorExtensions.cs b/Wino.Core/Extensions/GoogleIntegratorExtensions.cs index 292fb187..c3ba55f5 100644 --- a/Wino.Core/Extensions/GoogleIntegratorExtensions.cs +++ b/Wino.Core/Extensions/GoogleIntegratorExtensions.cs @@ -128,6 +128,10 @@ public static class GoogleIntegratorExtensions ReplyToAddress = a.ReplyToAddress, AliasSenderName = a.DisplayName, IsVerified = a.VerificationStatus == "accepted" || a.IsDefault.GetValueOrDefault(), + Source = AliasSource.ProviderDiscovered, + SendCapability = a.VerificationStatus == "accepted" || a.IsDefault.GetValueOrDefault() + ? AliasSendCapability.Confirmed + : AliasSendCapability.Unknown, }).ToList(); } diff --git a/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs b/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs index 1db37931..6c271231 100644 --- a/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs +++ b/Wino.Core/Integration/Processors/DefaultChangeProcessor.cs @@ -41,6 +41,7 @@ public interface IDefaultChangeProcessor Task MapLocalDraftAsync(Guid accountId, Guid localDraftCopyUniqueId, string newMailCopyId, string newDraftId, string newThreadId); Task UpdateFolderLastSyncDateAsync(Guid folderId); Task UpdateRemoteAliasInformationAsync(MailAccount account, List remoteAccountAliases); + Task UpdateAliasSendCapabilityAsync(Guid accountId, string aliasAddress, AliasSendCapability capability); // Calendar Task> GetAccountCalendarsAsync(Guid accountId); @@ -200,6 +201,9 @@ public class DefaultChangeProcessor(IDatabaseService databaseService, public Task UpdateRemoteAliasInformationAsync(MailAccount account, List remoteAccountAliases) => AccountService.UpdateRemoteAliasInformationAsync(account, remoteAccountAliases); + public Task UpdateAliasSendCapabilityAsync(Guid accountId, string aliasAddress, AliasSendCapability capability) + => AccountService.UpdateAliasSendCapabilityAsync(accountId, aliasAddress, capability); + public Task> GetAccountCalendarsAsync(Guid accountId) => CalendarService.GetAccountCalendarsAsync(accountId); diff --git a/Wino.Core/Synchronizers/OutlookSynchronizer.cs b/Wino.Core/Synchronizers/OutlookSynchronizer.cs index f2668474..59f685c4 100644 --- a/Wino.Core/Synchronizers/OutlookSynchronizer.cs +++ b/Wino.Core/Synchronizers/OutlookSynchronizer.cs @@ -1405,6 +1405,69 @@ public class OutlookSynchronizer : WinoSynchronizer + { + config.QueryParameters.Select = ["mail", "proxyAddresses"]; + }).ConfigureAwait(false); + + var remoteAliases = GetRemoteAliases(userInfo); + await _outlookChangeProcessor.UpdateRemoteAliasInformationAsync(Account, remoteAliases).ConfigureAwait(false); + } + + private List GetRemoteAliases(User userInfo) + { + var aliases = new Dictionary(StringComparer.OrdinalIgnoreCase); + + void AddAlias(string address) + { + if (string.IsNullOrWhiteSpace(address)) + return; + + var normalizedAddress = address.Trim(); + var isAccountAddress = normalizedAddress.Equals(Account.Address, StringComparison.OrdinalIgnoreCase); + var capability = isAccountAddress ? AliasSendCapability.Confirmed : AliasSendCapability.Unknown; + + if (aliases.TryGetValue(normalizedAddress, out var existingAlias)) + { + existingAlias.IsPrimary |= isAccountAddress; + existingAlias.IsRootAlias |= isAccountAddress; + + if (capability == AliasSendCapability.Confirmed) + existingAlias.SendCapability = AliasSendCapability.Confirmed; + + return; + } + + aliases[normalizedAddress] = new RemoteAccountAlias + { + AliasAddress = normalizedAddress, + ReplyToAddress = normalizedAddress, + IsPrimary = isAccountAddress, + IsRootAlias = isAccountAddress, + IsVerified = capability == AliasSendCapability.Confirmed, + Source = AliasSource.ProviderDiscovered, + SendCapability = capability + }; + } + + AddAlias(userInfo?.Mail); + + foreach (var proxyAddress in userInfo?.ProxyAddresses ?? []) + { + if (string.IsNullOrWhiteSpace(proxyAddress) || + !proxyAddress.StartsWith("smtp:", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + AddAlias(proxyAddress["smtp:".Length..]); + } + + return aliases.Values.ToList(); + } + /// /// POST requests are handled differently in batches in Graph SDK. /// Batch basically ignores the step's coontent-type and body. @@ -1959,6 +2022,16 @@ public class OutlookSynchronizer : WinoSynchronizer(); var errorMessage = errorJson["error"]["message"].GetValue(); + + if (response.StatusCode == HttpStatusCode.Forbidden && + string.Equals(errorCode, "ErrorSendAsDenied", StringComparison.OrdinalIgnoreCase) && + bundle?.UIChangeRequest is SendDraftRequest sendDraftRequest) + { + var sendingAlias = sendDraftRequest.Request.SendingAlias?.AliasAddress ?? Account.Address; + await _outlookChangeProcessor.UpdateAliasSendCapabilityAsync(Account.Id, sendingAlias, AliasSendCapability.Denied).ConfigureAwait(false); + errorMessage = string.Format(Translator.Exception_AliasSendDenied_Message, sendingAlias); + } + var errorString = $"[{response.StatusCode}] {errorCode} - {errorMessage}\n"; // Create error context diff --git a/Wino.Mail.ViewModels/AliasManagementPageViewModel.cs b/Wino.Mail.ViewModels/AliasManagementPageViewModel.cs index 7b1a3071..ae119327 100644 --- a/Wino.Mail.ViewModels/AliasManagementPageViewModel.cs +++ b/Wino.Mail.ViewModels/AliasManagementPageViewModel.cs @@ -103,7 +103,7 @@ public partial class AliasManagementPageViewModel : MailBaseViewModel if (aliasSyncResult.CompletedState == SynchronizationCompletedState.Success) await LoadAliasesAsync(); else - _dialogService.InfoBarMessage(Translator.GeneralTitle_Error, "Failed to synchronize aliases", InfoBarMessageType.Error); + _dialogService.InfoBarMessage(Translator.GeneralTitle_Error, Translator.Exception_FailedToSynchronizeAliases, InfoBarMessageType.Error); } [RelayCommand] diff --git a/Wino.Mail.ViewModels/ComposePageViewModel.cs b/Wino.Mail.ViewModels/ComposePageViewModel.cs index 15906042..a1760045 100644 --- a/Wino.Mail.ViewModels/ComposePageViewModel.cs +++ b/Wino.Mail.ViewModels/ComposePageViewModel.cs @@ -813,15 +813,13 @@ public partial class ComposePageViewModel : MailBaseViewModel, private void SaveReplyToAddress() { - if (SelectedAlias == null) return; + if (SelectedAlias == null || CurrentMimeMessage == null) return; + + CurrentMimeMessage.ReplyTo.Clear(); 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)); - } + CurrentMimeMessage.ReplyTo.Add(new MailboxAddress(SelectedAlias.ReplyToAddress, SelectedAlias.ReplyToAddress)); } } diff --git a/Wino.Mail.WinUI/Dialogs/CreateAccountAliasDialog.xaml.cs b/Wino.Mail.WinUI/Dialogs/CreateAccountAliasDialog.xaml.cs index 16a7c26c..93e667dd 100644 --- a/Wino.Mail.WinUI/Dialogs/CreateAccountAliasDialog.xaml.cs +++ b/Wino.Mail.WinUI/Dialogs/CreateAccountAliasDialog.xaml.cs @@ -1,6 +1,7 @@ using System; using Microsoft.UI.Xaml.Controls; using Wino.Core.Domain.Entities.Mail; +using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; namespace Wino.Dialogs; @@ -21,7 +22,9 @@ public sealed partial class CreateAccountAliasDialog : ContentDialog, ICreateAcc ReplyToAddress = ReplyToTextBox.Text.Trim(), Id = Guid.NewGuid(), IsPrimary = false, - IsVerified = false + IsVerified = false, + Source = AliasSource.Manual, + SendCapability = AliasSendCapability.Unknown }; Hide(); diff --git a/Wino.Mail.WinUI/Styles/Colors.xaml b/Wino.Mail.WinUI/Styles/Colors.xaml index e9f33274..5a8c13df 100644 --- a/Wino.Mail.WinUI/Styles/Colors.xaml +++ b/Wino.Mail.WinUI/Styles/Colors.xaml @@ -12,6 +12,7 @@ #ff7675 #ff7675 #1abc9c + #f39c12 diff --git a/Wino.Mail.WinUI/Views/Settings/AliasManagementPage.xaml b/Wino.Mail.WinUI/Views/Settings/AliasManagementPage.xaml index b695ee78..16e750c7 100644 --- a/Wino.Mail.WinUI/Views/Settings/AliasManagementPage.xaml +++ b/Wino.Mail.WinUI/Views/Settings/AliasManagementPage.xaml @@ -34,33 +34,42 @@ + + + + + + - - - - - - - - + HorizontalAlignment="Center" + VerticalAlignment="Center"> + + + + + Text="{x:Bind domain:Translator.AccountAlias_Column_Status}" /> > GetAccountAliasesAsync(Guid accountId) { return await Connection.QueryAsync( - "SELECT * FROM MailAccountAlias WHERE AccountId = ? ORDER BY IsRootAlias DESC", + "SELECT * FROM MailAccountAlias WHERE AccountId = ? ORDER BY IsRootAlias DESC, IsPrimary DESC, AliasAddress ASC", accountId).ConfigureAwait(false); } @@ -491,11 +493,16 @@ public class AccountService : BaseDatabaseService, IAccountService public async Task UpdateRemoteAliasInformationAsync(MailAccount account, List remoteAccountAliases) { var localAliases = await GetAccountAliasesAsync(account.Id).ConfigureAwait(false); - var rootAlias = localAliases.Find(a => a.IsRootAlias); + var normalizedRemoteAliases = remoteAccountAliases ?? []; - foreach (var remoteAlias in remoteAccountAliases) + foreach (var remoteAlias in normalizedRemoteAliases) { - var existingAlias = localAliases.Find(a => a.AccountId == account.Id && a.AliasAddress == remoteAlias.AliasAddress); + if (string.IsNullOrWhiteSpace(remoteAlias?.AliasAddress)) + continue; + + var existingAlias = localAliases.Find(a => + a.AccountId == account.Id && + a.AliasAddress.Equals(remoteAlias.AliasAddress, StringComparison.OrdinalIgnoreCase)); if (existingAlias == null) { @@ -509,7 +516,9 @@ public class AccountService : BaseDatabaseService, IAccountService ReplyToAddress = remoteAlias.ReplyToAddress, Id = Guid.NewGuid(), IsRootAlias = remoteAlias.IsRootAlias, - AliasSenderName = remoteAlias.AliasSenderName + AliasSenderName = remoteAlias.AliasSenderName, + Source = remoteAlias.Source, + SendCapability = remoteAlias.SendCapability }; await Connection.InsertAsync(newAlias, typeof(MailAccountAlias)); @@ -522,6 +531,8 @@ public class AccountService : BaseDatabaseService, IAccountService existingAlias.IsVerified = remoteAlias.IsVerified; existingAlias.ReplyToAddress = remoteAlias.ReplyToAddress; existingAlias.AliasSenderName = remoteAlias.AliasSenderName; + existingAlias.Source = remoteAlias.Source; + existingAlias.SendCapability = remoteAlias.SendCapability; await Connection.UpdateAsync(existingAlias, typeof(MailAccountAlias)); } @@ -553,6 +564,21 @@ public class AccountService : BaseDatabaseService, IAccountService } } + public async Task UpdateAliasSendCapabilityAsync(Guid accountId, string aliasAddress, AliasSendCapability capability) + { + if (string.IsNullOrWhiteSpace(aliasAddress)) + return; + + var aliases = await GetAccountAliasesAsync(accountId).ConfigureAwait(false); + var alias = aliases.FirstOrDefault(a => a.AliasAddress.Equals(aliasAddress, StringComparison.OrdinalIgnoreCase)); + + if (alias == null) + return; + + alias.SendCapability = capability; + await Connection.UpdateAsync(alias, typeof(MailAccountAlias)).ConfigureAwait(false); + } + public async Task DeleteAccountAliasAsync(Guid aliasId) { // Create query to delete alias. diff --git a/Wino.Services/MailService.cs b/Wino.Services/MailService.cs index 85fb0c02..c8772ad9 100644 --- a/Wino.Services/MailService.cs +++ b/Wino.Services/MailService.cs @@ -56,7 +56,8 @@ public class MailService : BaseDatabaseService, IMailService public async Task<(MailCopy draftMailCopy, string draftBase64MimeMessage)> CreateDraftAsync(Guid accountId, DraftCreationOptions draftCreationOptions) { var composerAccount = await _accountService.GetAccountAsync(accountId).ConfigureAwait(false); - var createdDraftMimeMessage = await CreateDraftMimeAsync(composerAccount, draftCreationOptions); + var selectedAlias = await ResolveDraftAliasAsync(composerAccount, draftCreationOptions).ConfigureAwait(false); + var createdDraftMimeMessage = await CreateDraftMimeAsync(composerAccount, draftCreationOptions, selectedAlias).ConfigureAwait(false); var draftFolder = await _folderService.GetSpecialFolderByAccountIdAsync(composerAccount.Id, SpecialFolderType.Draft); @@ -74,8 +75,8 @@ public class MailService : BaseDatabaseService, IMailService UniqueId = Guid.Parse(mimeUniqueId), Id = Guid.NewGuid().ToString(), // This will be replaced after network call with the remote draft id. CreationDate = DateTime.UtcNow, - FromAddress = primaryAlias?.AliasAddress ?? composerAccount.Address, - FromName = composerAccount.SenderName, + FromAddress = selectedAlias?.AliasAddress ?? primaryAlias?.AliasAddress ?? composerAccount.Address, + FromName = selectedAlias?.AliasSenderName ?? composerAccount.SenderName, HasAttachments = false, Importance = MailImportance.Normal, Subject = createdDraftMimeMessage.Subject, @@ -1016,7 +1017,7 @@ public class MailService : BaseDatabaseService, IMailService await _contactService.SaveAddressInformationAsync(contacts).ConfigureAwait(false); } - private async Task CreateDraftMimeAsync(MailAccount account, DraftCreationOptions draftCreationOptions) + private async Task CreateDraftMimeAsync(MailAccount account, DraftCreationOptions draftCreationOptions, MailAccountAlias selectedAlias) { // 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. @@ -1028,10 +1029,15 @@ public class MailService : BaseDatabaseService, IMailService }; EnsureOutgoingMessageId(message); - var primaryAlias = await _accountService.GetPrimaryAccountAliasAsync(account.Id) ?? throw new MissingAliasException(); + selectedAlias ??= await _accountService.GetPrimaryAccountAliasAsync(account.Id) ?? throw new MissingAliasException(); // Set FromName and FromAddress by alias. - message.From.Add(new MailboxAddress(account.SenderName, primaryAlias.AliasAddress)); + message.From.Add(new MailboxAddress(selectedAlias.AliasSenderName ?? account.SenderName, selectedAlias.AliasAddress)); + + if (!string.IsNullOrWhiteSpace(selectedAlias.ReplyToAddress)) + { + message.ReplyTo.Add(new MailboxAddress(selectedAlias.ReplyToAddress, selectedAlias.ReplyToAddress)); + } var builder = new BodyBuilder(); @@ -1052,6 +1058,61 @@ public class MailService : BaseDatabaseService, IMailService return message; } + private async Task ResolveDraftAliasAsync(MailAccount account, DraftCreationOptions draftCreationOptions) + { + var aliases = await _accountService.GetAccountAliasesAsync(account.Id).ConfigureAwait(false); + var primaryAlias = aliases.FirstOrDefault(a => a.IsPrimary) ?? aliases.FirstOrDefault(); + + if (draftCreationOptions?.ReferencedMessage?.MimeMessage == null) + return primaryAlias; + + var referencedMessage = draftCreationOptions.ReferencedMessage.MimeMessage; + + MailAccountAlias FindAlias(string address) + { + if (string.IsNullOrWhiteSpace(address)) + return null; + + return aliases.FirstOrDefault(a => a.AliasAddress.Equals(address.Trim(), StringComparison.OrdinalIgnoreCase)); + } + + var deliveredToAlias = FindAlias(ExtractAddressFromHeader(referencedMessage.Headers["Delivered-To"])) + ?? FindAlias(ExtractAddressFromHeader(referencedMessage.Headers["X-Original-To"])); + if (deliveredToAlias != null) + return deliveredToAlias; + + foreach (var mailbox in referencedMessage.To.Mailboxes) + { + var matchedAlias = FindAlias(mailbox.Address); + if (matchedAlias != null) + return matchedAlias; + } + + foreach (var mailbox in referencedMessage.Cc.Mailboxes) + { + var matchedAlias = FindAlias(mailbox.Address); + if (matchedAlias != null) + return matchedAlias; + } + + return primaryAlias; + } + + private static string ExtractAddressFromHeader(string headerValue) + { + if (string.IsNullOrWhiteSpace(headerValue)) + return string.Empty; + + var trimmed = headerValue.Trim(); + var leftBracketIndex = trimmed.LastIndexOf('<'); + var rightBracketIndex = trimmed.LastIndexOf('>'); + + if (leftBracketIndex >= 0 && rightBracketIndex > leftBracketIndex) + return trimmed[(leftBracketIndex + 1)..rightBracketIndex].Trim(); + + return trimmed.Trim().Trim('<', '>', '"', '\''); + } + private string CreateHtmlGap() { var template = $"""

""";