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 = $"""
""";