Improve alias capability model and Outlook alias sync

This commit is contained in:
Burak Kaan Köse
2026-04-13 01:09:40 +02:00
parent 6fd66810e9
commit 40b15b4f08
18 changed files with 444 additions and 45 deletions
@@ -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.
/// </summary>
public string AliasSenderName { get; set; }
/// <summary>
/// Whether the alias was entered by the user or discovered from the provider.
/// </summary>
public AliasSource Source { get; set; } = AliasSource.Manual;
/// <summary>
/// Represents Wino's confidence that the alias can be used for sending.
/// </summary>
public AliasSendCapability SendCapability { get; set; } = AliasSendCapability.Unknown;
}
public class MailAccountAlias : RemoteAccountAlias
@@ -70,4 +82,28 @@ public class MailAccountAlias : RemoteAccountAlias
[Ignore]
public ObservableCollection<X509Certificate2> 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
};
}
@@ -120,7 +120,7 @@ public class MailAccount
/// <summary>
/// Gets whether the account can perform AliasInformation sync type.
/// </summary>
public bool IsAliasSyncSupported => ProviderType == MailProviderType.Gmail;
public bool IsAliasSyncSupported => ProviderType == MailProviderType.Gmail || ProviderType == MailProviderType.Outlook;
public override string ToString() => Name;
}
@@ -0,0 +1,8 @@
namespace Wino.Core.Domain.Enums;
public enum AliasSendCapability
{
Unknown = 0,
Confirmed = 1,
Denied = 2
}
+7
View File
@@ -0,0 +1,7 @@
namespace Wino.Core.Domain.Enums;
public enum AliasSource
{
Manual = 0,
ProviderDiscovered = 1
}
@@ -147,6 +147,7 @@ public interface IAccountService
/// <param name="accountId">Account id.</param>
/// <returns>Primary alias for the account.</returns>
Task<MailAccountAlias> GetPrimaryAccountAliasAsync(Guid accountId);
Task UpdateAliasSendCapabilityAsync(Guid accountId, string aliasAddress, AliasSendCapability capability);
Task<bool> IsAccountFocusedEnabledAsync(Guid accountId);
/// <summary>
@@ -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}",
@@ -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<IPreferencesService>();
var signatureService = new Mock<ISignatureService>();
var mimeFileService = new Mock<IMimeFileService>();
var contactPictureFileService = new Mock<IContactPictureFileService>();
return new AccountService(
databaseService,
signatureService.Object,
Mock.Of<IAuthenticationProvider>(),
mimeFileService.Object,
preferencesService.Object,
contactPictureFileService.Object);
}
}
+42 -1
View File
@@ -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", "<support@test.local>");
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();
@@ -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();
}
@@ -41,6 +41,7 @@ public interface IDefaultChangeProcessor
Task<bool> MapLocalDraftAsync(Guid accountId, Guid localDraftCopyUniqueId, string newMailCopyId, string newDraftId, string newThreadId);
Task UpdateFolderLastSyncDateAsync(Guid folderId);
Task UpdateRemoteAliasInformationAsync(MailAccount account, List<RemoteAccountAlias> remoteAccountAliases);
Task UpdateAliasSendCapabilityAsync(Guid accountId, string aliasAddress, AliasSendCapability capability);
// Calendar
Task<List<AccountCalendar>> GetAccountCalendarsAsync(Guid accountId);
@@ -200,6 +201,9 @@ public class DefaultChangeProcessor(IDatabaseService databaseService,
public Task UpdateRemoteAliasInformationAsync(MailAccount account, List<RemoteAccountAlias> remoteAccountAliases)
=> AccountService.UpdateRemoteAliasInformationAsync(account, remoteAccountAliases);
public Task UpdateAliasSendCapabilityAsync(Guid accountId, string aliasAddress, AliasSendCapability capability)
=> AccountService.UpdateAliasSendCapabilityAsync(accountId, aliasAddress, capability);
public Task<List<AccountCalendar>> GetAccountCalendarsAsync(Guid accountId)
=> CalendarService.GetAccountCalendarsAsync(accountId);
@@ -1405,6 +1405,69 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
return new ProfileInformation(displayNameAndAddress.Item1, profilePictureData, displayNameAndAddress.Item2);
}
protected override async Task SynchronizeAliasesAsync()
{
var userInfo = await _graphClient.Me.GetAsync((config) =>
{
config.QueryParameters.Select = ["mail", "proxyAddresses"];
}).ConfigureAwait(false);
var remoteAliases = GetRemoteAliases(userInfo);
await _outlookChangeProcessor.UpdateRemoteAliasInformationAsync(Account, remoteAliases).ConfigureAwait(false);
}
private List<RemoteAccountAlias> GetRemoteAliases(User userInfo)
{
var aliases = new Dictionary<string, RemoteAccountAlias>(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();
}
/// <summary>
/// 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<RequestInformation, Message,
var errorJson = JsonNode.Parse(content);
var errorCode = errorJson["error"]["code"].GetValue<string>();
var errorMessage = errorJson["error"]["message"].GetValue<string>();
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
@@ -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]
+3 -5
View File
@@ -813,17 +813,15 @@ 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));
}
}
}
private void SaveReadReceiptRequest()
{
@@ -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();
+1
View File
@@ -12,6 +12,7 @@
<SolidColorBrush x:Key="DiagnosticIdCopyBrush">#ff7675</SolidColorBrush>
<SolidColorBrush x:Key="AliasUnverifiedBrush">#ff7675</SolidColorBrush>
<SolidColorBrush x:Key="AliasVerifiedBrush">#1abc9c</SolidColorBrush>
<SolidColorBrush x:Key="AliasUnknownBrush">#f39c12</SolidColorBrush>
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Name="Light">
@@ -34,33 +34,42 @@
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock FontWeight="SemiBold" Text="{x:Bind AliasAddress}" />
<TextBlock Grid.Row="1" Style="{StaticResource CaptionTextBlockStyle}">
<Run Text="{x:Bind SourceDisplayName}" />
<Run Text=" • " />
<Run Text="{x:Bind CapabilityDisplayName}" />
</TextBlock>
<TextBlock Grid.Row="2" Style="{StaticResource CaptionTextBlockStyle}">
<Run Text="Reply-To:" /> <Run Text="{x:Bind ReplyToAddress}" />
</TextBlock>
</Grid>
<controls1:SwitchPresenter
<Grid
Grid.Column="1"
HorizontalAlignment="Stretch"
TargetType="x:Boolean"
Value="{x:Bind IsVerified}">
<controls1:Case Value="True">
HorizontalAlignment="Center"
VerticalAlignment="Center">
<Ellipse
Width="12"
Height="12"
Margin="0,0,12,0"
Fill="{StaticResource AliasVerifiedBrush}" />
</controls1:Case>
<controls1:Case Value="False">
Fill="{StaticResource AliasVerifiedBrush}"
Visibility="{x:Bind IsCapabilityConfirmed, Mode=OneWay}" />
<Ellipse
Width="12"
Height="12"
Margin="0,0,12,0"
Fill="{StaticResource AliasUnverifiedBrush}" />
</controls1:Case>
</controls1:SwitchPresenter>
Fill="{StaticResource AliasUnknownBrush}"
Visibility="{x:Bind IsCapabilityUnknown, Mode=OneWay}" />
<Ellipse
Width="12"
Height="12"
Margin="0,0,12,0"
Fill="{StaticResource AliasUnverifiedBrush}"
Visibility="{x:Bind IsCapabilityDenied, Mode=OneWay}" />
</Grid>
<RadioButton
Grid.Column="2"
@@ -195,7 +204,7 @@
Grid.Row="1"
Grid.Column="1"
HorizontalAlignment="Right"
Text="{x:Bind domain:Translator.AccountAlias_Column_Verified}" />
Text="{x:Bind domain:Translator.AccountAlias_Column_Status}" />
<TextBlock
Grid.Row="2"
+32 -6
View File
@@ -250,7 +250,9 @@ public class AccountService : BaseDatabaseService, IAccountService
IsRootAlias = true,
IsVerified = true,
ReplyToAddress = address,
Id = Guid.NewGuid()
Id = Guid.NewGuid(),
Source = AliasSource.Manual,
SendCapability = AliasSendCapability.Confirmed
};
await Connection.InsertAsync(rootAlias, typeof(MailAccountAlias)).ConfigureAwait(false);
@@ -261,7 +263,7 @@ public class AccountService : BaseDatabaseService, IAccountService
public async Task<List<MailAccountAlias>> GetAccountAliasesAsync(Guid accountId)
{
return await Connection.QueryAsync<MailAccountAlias>(
"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<RemoteAccountAlias> 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.
+67 -6
View File
@@ -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<MimeMessage> CreateDraftMimeAsync(MailAccount account, DraftCreationOptions draftCreationOptions)
private async Task<MimeMessage> 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<MailAccountAlias> 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 = $"""<div style="font-family: '{_preferencesService.ComposerFont}', Arial, sans-serif; font-size: {_preferencesService.ComposerFontSize}px"><br></div>""";