Improve alias capability model and Outlook alias sync
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>""";
|
||||
|
||||
Reference in New Issue
Block a user