Merge pull request #324 from bkaankose/feature/Aliases

E-mail Aliases Support
This commit is contained in:
Burak Kaan Köse
2024-08-19 20:37:58 +02:00
committed by GitHub
74 changed files with 1973 additions and 637 deletions

View File

@@ -28,7 +28,7 @@ namespace Wino.Core.Authenticators
public string ClientId { get; } = "b19c2035-d740-49ff-b297-de6ec561b208";
private readonly string[] MailScope = ["email", "mail.readwrite", "offline_access", "mail.send"];
private readonly string[] MailScope = ["email", "mail.readwrite", "offline_access", "mail.send", "Mail.Send.Shared", "Mail.ReadWrite.Shared"];
public override MailProviderType ProviderType => MailProviderType.Outlook;

View File

@@ -1,31 +0,0 @@
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Folders;
using Wino.Core.MenuItems;
namespace Wino.Core.Extensions
{
public static class FolderTreeExtensions
{
private static MenuItemBase<IMailItemFolder, FolderMenuItem> GetMenuItemByFolderRecursive(IMailItemFolder structure, AccountMenuItem parentAccountMenuItem, IMenuItem parentFolderItem)
{
MenuItemBase<IMailItemFolder, FolderMenuItem> parentMenuItem = new FolderMenuItem(structure, parentAccountMenuItem.Parameter, parentFolderItem);
var childStructures = structure.ChildFolders;
foreach (var childFolder in childStructures)
{
if (childFolder == null) continue;
// Folder menu item.
var subChildrenFolderTree = GetMenuItemByFolderRecursive(childFolder, parentAccountMenuItem, parentMenuItem);
if (subChildrenFolderTree is FolderMenuItem folderItem)
{
parentMenuItem.SubMenuItems.Add(folderItem);
}
}
return parentMenuItem;
}
}
}

View File

@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Web;
using Google.Apis.Gmail.v1.Data;
@@ -205,44 +204,16 @@ namespace Wino.Core.Extensions
};
}
public static Tuple<MailCopy, MimeMessage, IEnumerable<string>> GetMailDetails(this Message message)
public static List<RemoteAccountAlias> GetRemoteAliases(this ListSendAsResponse response)
{
MimeMessage mimeMessage = message.GetGmailMimeMessage();
if (mimeMessage == null)
return response?.SendAs?.Select(a => new RemoteAccountAlias()
{
// This should never happen.
Debugger.Break();
return default;
}
bool isUnread = message.GetIsUnread();
bool isFocused = message.GetIsFocused();
bool isFlagged = message.GetIsFlagged();
bool isDraft = message.GetIsDraft();
var mailCopy = new MailCopy()
{
CreationDate = mimeMessage.Date.UtcDateTime,
Subject = HttpUtility.HtmlDecode(mimeMessage.Subject),
FromName = MailkitClientExtensions.GetActualSenderName(mimeMessage),
FromAddress = MailkitClientExtensions.GetActualSenderAddress(mimeMessage),
PreviewText = HttpUtility.HtmlDecode(message.Snippet),
ThreadId = message.ThreadId,
Importance = (MailImportance)mimeMessage.Importance,
Id = message.Id,
IsDraft = isDraft,
HasAttachments = mimeMessage.Attachments.Any(),
IsRead = !isUnread,
IsFlagged = isFlagged,
IsFocused = isFocused,
InReplyTo = mimeMessage.InReplyTo,
MessageId = mimeMessage.MessageId,
References = mimeMessage.References.GetReferences()
};
return new Tuple<MailCopy, MimeMessage, IEnumerable<string>>(mailCopy, mimeMessage, message.LabelIds);
AliasAddress = a.SendAsEmail,
IsRootAlias = a.IsDefault.GetValueOrDefault(),
IsPrimary = a.IsPrimary.GetValueOrDefault(),
ReplyToAddress = a.ReplyToAddress,
IsVerified = a.VerificationStatus == "accepted" || a.IsDefault.GetValueOrDefault(),
}).ToList();
}
}
}

View File

@@ -1,5 +1,9 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Microsoft.Graph.Models;
using MimeKit;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
@@ -61,5 +65,160 @@ namespace Wino.Core.Extensions
return mailCopy;
}
public static Message AsOutlookMessage(this MimeMessage mime, bool includeInternetHeaders)
{
var fromAddress = GetRecipients(mime.From).ElementAt(0);
var toAddresses = GetRecipients(mime.To).ToList();
var ccAddresses = GetRecipients(mime.Cc).ToList();
var bccAddresses = GetRecipients(mime.Bcc).ToList();
var replyToAddresses = GetRecipients(mime.ReplyTo).ToList();
var message = new Message()
{
Subject = mime.Subject,
Importance = GetImportance(mime.Importance),
Body = new ItemBody() { ContentType = BodyType.Html, Content = mime.HtmlBody },
IsDraft = false,
IsRead = true, // Sent messages are always read.
ToRecipients = toAddresses,
CcRecipients = ccAddresses,
BccRecipients = bccAddresses,
From = fromAddress,
InternetMessageId = GetProperId(mime.MessageId),
ReplyTo = replyToAddresses,
Attachments = []
};
// Headers are only included when creating the draft.
// When sending, they are not included. Graph will throw an error.
if (includeInternetHeaders)
{
message.InternetMessageHeaders = GetHeaderList(mime);
}
foreach (var part in mime.BodyParts)
{
if (part.IsAttachment)
{
// File attachment.
using var memory = new MemoryStream();
((MimePart)part).Content.DecodeTo(memory);
var bytes = memory.ToArray();
var fileAttachment = new FileAttachment()
{
ContentId = part.ContentId,
Name = part.ContentDisposition?.FileName ?? part.ContentType.Name,
ContentBytes = bytes,
};
message.Attachments.Add(fileAttachment);
}
else if (part.ContentDisposition != null && part.ContentDisposition.Disposition == "inline")
{
// Inline attachment.
using var memory = new MemoryStream();
((MimePart)part).Content.DecodeTo(memory);
var bytes = memory.ToArray();
var inlineAttachment = new FileAttachment()
{
IsInline = true,
ContentId = part.ContentId,
Name = part.ContentDisposition?.FileName ?? part.ContentType.Name,
ContentBytes = bytes
};
message.Attachments.Add(inlineAttachment);
}
}
return message;
}
#region Mime to Outlook Message Helpers
private static IEnumerable<Recipient> GetRecipients(this InternetAddressList internetAddresses)
{
foreach (var address in internetAddresses)
{
if (address is MailboxAddress mailboxAddress)
yield return new Recipient() { EmailAddress = new EmailAddress() { Address = mailboxAddress.Address, Name = mailboxAddress.Name } };
else if (address is GroupAddress groupAddress)
{
// TODO: Group addresses are not directly supported.
// It'll be individually added.
foreach (var mailbox in groupAddress.Members)
if (mailbox is MailboxAddress groupMemberMailAddress)
yield return new Recipient() { EmailAddress = new EmailAddress() { Address = groupMemberMailAddress.Address, Name = groupMemberMailAddress.Name } };
}
}
}
private static Importance? GetImportance(MessageImportance importance)
{
return importance switch
{
MessageImportance.Low => Importance.Low,
MessageImportance.Normal => Importance.Normal,
MessageImportance.High => Importance.High,
_ => null
};
}
private static List<InternetMessageHeader> GetHeaderList(this MimeMessage mime)
{
// Graph API only allows max of 5 headers.
// Here we'll try to ignore some headers that are not neccessary.
// Outlook API will generate them automatically.
// Some headers also require to start with X- or x-.
string[] headersToIgnore = ["Date", "To", "MIME-Version", "From", "Subject", "Message-Id"];
string[] headersToModify = ["In-Reply-To", "Reply-To", "References", "Thread-Topic"];
var headers = new List<InternetMessageHeader>();
int includedHeaderCount = 0;
foreach (var header in mime.Headers)
{
if (!headersToIgnore.Contains(header.Field))
{
if (headersToModify.Contains(header.Field))
{
headers.Add(new InternetMessageHeader() { Name = $"X-{header.Field}", Value = header.Value });
}
else
{
headers.Add(new InternetMessageHeader() { Name = header.Field, Value = header.Value });
}
includedHeaderCount++;
}
if (includedHeaderCount >= 5) break;
}
return headers;
}
private static string GetProperId(string id)
{
// Outlook requires some identifiers to start with "X-" or "x-".
if (string.IsNullOrEmpty(id)) return string.Empty;
if (!id.StartsWith("x-") || !id.StartsWith("X-"))
return $"X-{id}";
return id;
}
#endregion
}
}

View File

@@ -33,11 +33,13 @@ namespace Wino.Core.Integration
// Later on maybe we can make it configurable and leave it to the user with passing
// real implementation details.
private readonly ImapImplementation _implementation = new ImapImplementation()
private readonly ImapImplementation _implementation = new()
{
Version = "1.0",
Version = "1.8.0",
OS = "Windows",
Vendor = "Wino"
Vendor = "Wino",
SupportUrl = "https://www.winomail.app",
Name = "Wino Mail User",
};
private readonly int MinimumPoolSize = 5;

View File

@@ -19,6 +19,7 @@ namespace Wino.Core.Integration.Processors
/// </summary>
public interface IDefaultChangeProcessor
{
Task UpdateAccountAsync(MailAccount account);
Task<string> UpdateAccountDeltaSynchronizationIdentifierAsync(Guid accountId, string deltaSynchronizationIdentifier);
Task CreateAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId);
Task DeleteAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId);
@@ -39,12 +40,14 @@ namespace Wino.Core.Integration.Processors
/// <returns>All folders.</returns>
Task<List<MailItemFolder>> GetLocalFoldersAsync(Guid accountId);
Task<List<MailItemFolder>> GetSynchronizationFoldersAsync(SynchronizationOptions options);
Task<bool> MapLocalDraftAsync(Guid accountId, Guid localDraftCopyUniqueId, string newMailCopyId, string newDraftId, string newThreadId);
Task UpdateFolderLastSyncDateAsync(Guid folderId);
Task<List<MailItemFolder>> GetExistingFoldersAsync(Guid accountId);
Task UpdateRemoteAliasInformationAsync(MailAccount account, List<RemoteAccountAlias> remoteAccountAliases);
}
public interface IGmailChangeProcessor : IDefaultChangeProcessor
@@ -172,5 +175,11 @@ namespace Wino.Core.Integration.Processors
public Task UpdateFolderLastSyncDateAsync(Guid folderId)
=> FolderService.UpdateFolderLastSyncDateAsync(folderId);
public Task UpdateAccountAsync(MailAccount account)
=> AccountService.UpdateAccountAsync(account);
public Task UpdateRemoteAliasInformationAsync(MailAccount account, List<RemoteAccountAlias> remoteAccountAliases)
=> AccountService.UpdateRemoteAliasInformationAsync(account, remoteAccountAliases);
}
}

View File

@@ -47,6 +47,12 @@ namespace Wino.Core.MenuItems
set => SetProperty(Parameter.Name, value, Parameter, (u, n) => u.Name = n);
}
public string Base64ProfilePicture
{
get => Parameter.Name;
set => SetProperty(Parameter.Base64ProfilePictureData, value, Parameter, (u, n) => u.Base64ProfilePictureData = n);
}
public IEnumerable<MailAccount> HoldingAccounts => new List<MailAccount> { Parameter };
public AccountMenuItem(MailAccount account, IMenuItem parent = null) : base(account, account.Id, parent)
@@ -59,6 +65,7 @@ namespace Wino.Core.MenuItems
Parameter = account;
AccountName = account.Name;
AttentionReason = account.AttentionReason;
Base64ProfilePicture = account.Base64ProfilePictureData;
if (SubMenuItems == null) return;

View File

@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Diagnostics;
@@ -10,6 +9,7 @@ using SqlKata;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts;
using Wino.Core.Extensions;
using Wino.Messaging.Client.Accounts;
using Wino.Messaging.UI;
@@ -233,6 +233,33 @@ namespace Wino.Core.Services
return accounts;
}
public async Task CreateRootAliasAsync(Guid accountId, string address)
{
var rootAlias = new MailAccountAlias()
{
AccountId = accountId,
AliasAddress = address,
IsPrimary = true,
IsRootAlias = true,
IsVerified = true,
ReplyToAddress = address,
Id = Guid.NewGuid()
};
await Connection.InsertAsync(rootAlias).ConfigureAwait(false);
Log.Information("Created root alias for the account {AccountId}", accountId);
}
public async Task<List<MailAccountAlias>> GetAccountAliasesAsync(Guid accountId)
{
var query = new Query(nameof(MailAccountAlias))
.Where(nameof(MailAccountAlias.AccountId), accountId)
.OrderByDesc(nameof(MailAccountAlias.IsRootAlias));
return await Connection.QueryAsync<MailAccountAlias>(query.GetRawQuery()).ConfigureAwait(false);
}
private Task<MergedInbox> GetMergedInboxInformationAsync(Guid mergedInboxId)
=> Connection.Table<MergedInbox>().FirstOrDefaultAsync(a => a.Id == mergedInboxId);
@@ -245,6 +272,7 @@ namespace Wino.Core.Services
await Connection.Table<TokenInformation>().Where(a => a.AccountId == account.Id).DeleteAsync();
await Connection.Table<MailItemFolder>().DeleteAsync(a => a.MailAccountId == account.Id);
await Connection.Table<AccountSignature>().DeleteAsync(a => a.MailAccountId == account.Id);
await Connection.Table<MailAccountAlias>().DeleteAsync(a => a.AccountId == account.Id);
// Account belongs to a merged inbox.
// In case of there'll be a single account in the merged inbox, remove the merged inbox as well.
@@ -295,6 +323,19 @@ namespace Wino.Core.Services
ReportUIChange(new AccountRemovedMessage(account));
}
public async Task UpdateProfileInformationAsync(Guid accountId, ProfileInformation profileInformation)
{
var account = await GetAccountAsync(accountId).ConfigureAwait(false);
if (account != null)
{
account.SenderName = profileInformation.SenderName;
account.Base64ProfilePictureData = profileInformation.Base64ProfilePictureData;
await UpdateAccountAsync(account).ConfigureAwait(false);
}
}
public async Task<MailAccount> GetAccountAsync(Guid accountId)
{
var account = await Connection.Table<MailAccount>().FirstOrDefaultAsync(a => a.Id == accountId);
@@ -321,17 +362,98 @@ namespace Wino.Core.Services
public async Task UpdateAccountAsync(MailAccount account)
{
if (account.Preferences == null)
{
Debugger.Break();
}
await Connection.UpdateAsync(account.Preferences);
await Connection.UpdateAsync(account);
await Connection.UpdateAsync(account.Preferences).ConfigureAwait(false);
await Connection.UpdateAsync(account).ConfigureAwait(false);
ReportUIChange(new AccountUpdatedMessage(account));
}
public async Task UpdateAccountAliasesAsync(Guid accountId, List<MailAccountAlias> aliases)
{
// Delete existing ones.
await Connection.Table<MailAccountAlias>().DeleteAsync(a => a.AccountId == accountId).ConfigureAwait(false);
// Insert new ones.
foreach (var alias in aliases)
{
await Connection.InsertAsync(alias).ConfigureAwait(false);
}
}
public async Task UpdateRemoteAliasInformationAsync(MailAccount account, List<RemoteAccountAlias> remoteAccountAliases)
{
var localAliases = await GetAccountAliasesAsync(account.Id).ConfigureAwait(false);
var rootAlias = localAliases.Find(a => a.IsRootAlias);
foreach (var remoteAlias in remoteAccountAliases)
{
var existingAlias = localAliases.Find(a => a.AccountId == account.Id && a.AliasAddress == remoteAlias.AliasAddress);
if (existingAlias == null)
{
// Create new alias.
var newAlias = new MailAccountAlias()
{
AccountId = account.Id,
AliasAddress = remoteAlias.AliasAddress,
IsPrimary = remoteAlias.IsPrimary,
IsVerified = remoteAlias.IsVerified,
ReplyToAddress = remoteAlias.ReplyToAddress,
Id = Guid.NewGuid(),
IsRootAlias = remoteAlias.IsRootAlias
};
await Connection.InsertAsync(newAlias);
localAliases.Add(newAlias);
}
else
{
// Update existing alias.
existingAlias.IsPrimary = remoteAlias.IsPrimary;
existingAlias.IsVerified = remoteAlias.IsVerified;
existingAlias.ReplyToAddress = remoteAlias.ReplyToAddress;
await Connection.UpdateAsync(existingAlias);
}
}
// Make sure there is only 1 root alias and 1 primary alias selected.
bool shouldUpdatePrimary = localAliases.Count(a => a.IsPrimary) != 1;
bool shouldUpdateRoot = localAliases.Count(a => a.IsRootAlias) != 1;
if (shouldUpdatePrimary)
{
localAliases.ForEach(a => a.IsPrimary = false);
var idealPrimaryAlias = localAliases.Find(a => a.AliasAddress == account.Address) ?? localAliases.First();
idealPrimaryAlias.IsPrimary = true;
await Connection.UpdateAsync(idealPrimaryAlias).ConfigureAwait(false);
}
if (shouldUpdateRoot)
{
localAliases.ForEach(a => a.IsRootAlias = false);
var idealRootAlias = localAliases.Find(a => a.AliasAddress == account.Address) ?? localAliases.First();
idealRootAlias.IsRootAlias = true;
await Connection.UpdateAsync(idealRootAlias).ConfigureAwait(false);
}
}
public async Task DeleteAccountAliasAsync(Guid aliasId)
{
// Create query to delete alias.
var query = new Query("MailAccountAlias")
.Where("Id", aliasId)
.AsDelete();
await Connection.ExecuteAsync(query.GetRawQuery()).ConfigureAwait(false);
}
public async Task CreateAccountAsync(MailAccount account, TokenInformation tokenInformation, CustomServerInformation customServerInformation)
{
Guard.IsNotNull(account);
@@ -385,7 +507,7 @@ namespace Wino.Core.Services
// Outlook token cache is managed by MSAL.
// Don't save it to database.
if (tokenInformation != null && account.ProviderType != MailProviderType.Outlook)
if (tokenInformation != null && (account.ProviderType != MailProviderType.Outlook || account.ProviderType == MailProviderType.Office365))
await Connection.InsertAsync(tokenInformation);
}
@@ -437,5 +559,14 @@ namespace Wino.Core.Services
Messenger.Send(new AccountMenuItemsReordered(accountIdOrderPair));
}
public async Task<MailAccountAlias> GetPrimaryAccountAliasAsync(Guid accountId)
{
var aliases = await GetAccountAliasesAsync(accountId);
if (aliases == null || aliases.Count == 0) return null;
return aliases.FirstOrDefault(a => a.IsPrimary) ?? aliases.First();
}
}
}

View File

@@ -61,7 +61,8 @@ namespace Wino.Core.Services
typeof(CustomServerInformation),
typeof(AccountSignature),
typeof(MergedInbox),
typeof(MailAccountPreferences)
typeof(MailAccountPreferences),
typeof(MailAccountAlias)
);
}
}

View File

@@ -10,6 +10,7 @@ using SqlKata;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Exceptions;
using Wino.Core.Domain.Extensions;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Comparers;
@@ -62,12 +63,14 @@ namespace Wino.Core.Services
// This header will be used to map the local draft copy with the remote draft copy.
var mimeUniqueId = createdDraftMimeMessage.Headers[Constants.WinoLocalDraftHeader];
var primaryAlias = await _accountService.GetPrimaryAccountAliasAsync(accountId).ConfigureAwait(false);
var copy = new MailCopy
{
UniqueId = Guid.Parse(mimeUniqueId),
Id = Guid.NewGuid().ToString(), // This will be replaced after network call with the remote draft id.
CreationDate = DateTime.UtcNow,
FromAddress = composerAccount.Address,
FromAddress = primaryAlias?.AliasAddress ?? composerAccount.Address,
FromName = composerAccount.SenderName,
HasAttachments = false,
Importance = MailImportance.Normal,
@@ -621,12 +624,17 @@ namespace Wino.Core.Services
// This unique id is stored in mime headers for Wino to identify remote message with local copy.
// Same unique id will be used for the local copy as well.
// Synchronizer will map this unique id to the local draft copy after synchronization.
var message = new MimeMessage()
{
Headers = { { Constants.WinoLocalDraftHeader, Guid.NewGuid().ToString() } },
From = { new MailboxAddress(account.SenderName, account.Address) }
};
var primaryAlias = await _accountService.GetPrimaryAccountAliasAsync(account.Id) ?? throw new MissingAliasException();
// Set FromName and FromAddress by alias.
message.From.Add(new MailboxAddress(account.SenderName, primaryAlias.AliasAddress));
var builder = new BodyBuilder();
var signature = await GetSignature(account, draftCreationOptions.Reason);

View File

@@ -109,12 +109,9 @@ namespace Wino.Core.Services
var resourcePath = await GetMimeResourcePathAsync(accountId, fileId).ConfigureAwait(false);
var completeFilePath = GetEMLPath(resourcePath);
var fileStream = File.Create(completeFilePath);
using var fileStream = File.Open(completeFilePath, FileMode.OpenOrCreate);
using (fileStream)
{
await mimeMessage.WriteToAsync(fileStream).ConfigureAwait(false);
}
await mimeMessage.WriteToAsync(fileStream).ConfigureAwait(false);
return true;
}

View File

@@ -3,6 +3,7 @@ using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
@@ -12,6 +13,7 @@ using Wino.Core.Domain;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts;
using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Models.Synchronization;
using Wino.Core.Integration;
@@ -69,8 +71,65 @@ namespace Wino.Core.Synchronizers
/// <param name="cancellationToken">Cancellation token</param>
public abstract Task ExecuteNativeRequestsAsync(IEnumerable<IRequestBundle<TBaseRequest>> batchedRequests, CancellationToken cancellationToken = default);
public abstract Task<SynchronizationResult> SynchronizeInternalAsync(SynchronizationOptions options, CancellationToken cancellationToken = default);
/// <summary>
/// Refreshes remote mail account profile if possible.
/// Profile picture, sender name and mailbox settings (todo) will be handled in this step.
/// </summary>
public virtual Task<ProfileInformation> GetProfileInformationAsync() => default;
/// <summary>
/// Refreshes the aliases of the account.
/// Only available for Gmail right now.
/// </summary>
protected virtual Task SynchronizeAliasesAsync() => Task.CompletedTask;
/// <summary>
/// Returns the base64 encoded profile picture of the account from the given URL.
/// </summary>
/// <param name="url">URL to retrieve picture from.</param>
/// <returns>base64 encoded profile picture</returns>
protected async Task<string> GetProfilePictureBase64EncodedAsync(string url)
{
using var client = new HttpClient();
var response = await client.GetAsync(url).ConfigureAwait(false);
var byteContent = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
return Convert.ToBase64String(byteContent);
}
/// <summary>
/// Internally synchronizes the account with the given options.
/// Not exposed and overriden for each synchronizer.
/// </summary>
/// <param name="options">Synchronization options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Synchronization result that contains summary of the sync.</returns>
protected abstract Task<SynchronizationResult> SynchronizeInternalAsync(SynchronizationOptions options, CancellationToken cancellationToken = default);
/// <summary>
/// Safely updates account's profile information.
/// Database changes are reflected after this call.
/// </summary>
private async Task<ProfileInformation> SynchronizeProfileInformationInternalAsync()
{
var profileInformation = await GetProfileInformationAsync();
if (profileInformation != null)
{
Account.SenderName = profileInformation.SenderName;
Account.Base64ProfilePictureData = profileInformation.Base64ProfilePictureData;
}
return profileInformation;
}
/// <summary>
/// Batches network requests, executes them, and does the needed synchronization after the batch request execution.
/// </summary>
/// <param name="options">Synchronization options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Synchronization result that contains summary of the sync.</returns>
public async Task<SynchronizationResult> SynchronizeAsync(SynchronizationOptions options, CancellationToken cancellationToken = default)
{
try
@@ -104,6 +163,48 @@ namespace Wino.Core.Synchronizers
await synchronizationSemaphore.WaitAsync(activeSynchronizationCancellationToken);
// Handle special synchronization types.
// Profile information sync.
if (options.Type == SynchronizationType.UpdateProfile)
{
if (!Account.IsProfileInfoSyncSupported) return SynchronizationResult.Empty;
ProfileInformation newProfileInformation = null;
try
{
newProfileInformation = await SynchronizeProfileInformationInternalAsync();
}
catch (Exception ex)
{
Log.Error(ex, "Failed to update profile information for {Name}", Account.Name);
return SynchronizationResult.Failed;
}
return SynchronizationResult.Completed(null, newProfileInformation);
}
// Alias sync.
if (options.Type == SynchronizationType.Alias)
{
if (!Account.IsAliasSyncSupported) return SynchronizationResult.Empty;
try
{
await SynchronizeAliasesAsync();
return SynchronizationResult.Empty;
}
catch (Exception ex)
{
Log.Error(ex, "Failed to update aliases for {Name}", Account.Name);
return SynchronizationResult.Failed;
}
}
// Let servers to finish their job. Sometimes the servers doesn't respond immediately.
bool shouldDelayExecution = batches.Any(a => a.DelayExecution);
@@ -150,6 +251,10 @@ namespace Wino.Core.Synchronizers
private void PublishUnreadItemChanges()
=> WeakReferenceMessenger.Default.Send(new RefreshUnreadCountsMessage(Account.Id));
/// <summary>
/// Sends a message to the shell to update the synchronization progress.
/// </summary>
/// <param name="progress">Percentage of the progress.</param>
public void PublishSynchronizationProgress(double progress)
=> WeakReferenceMessenger.Default.Send(new AccountSynchronizationProgressUpdatedMessage(Account.Id, progress));

View File

@@ -7,6 +7,7 @@ using System.Threading.Tasks;
using Google.Apis.Gmail.v1;
using Google.Apis.Gmail.v1.Data;
using Google.Apis.Http;
using Google.Apis.PeopleService.v1;
using Google.Apis.Requests;
using Google.Apis.Services;
using MailKit;
@@ -18,6 +19,7 @@ using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Exceptions;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts;
using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Models.Requests;
using Wino.Core.Domain.Models.Synchronization;
@@ -37,8 +39,10 @@ namespace Wino.Core.Synchronizers
// https://github.com/googleapis/google-api-dotnet-client/issues/2603
private const uint MaximumAllowedBatchRequestSize = 10;
private readonly ConfigurableHttpClient _gmailHttpClient;
private readonly ConfigurableHttpClient _googleHttpClient;
private readonly GmailService _gmailService;
private readonly PeopleServiceService _peopleService;
private readonly IAuthenticator _authenticator;
private readonly IGmailChangeProcessor _gmailChangeProcessor;
private readonly ILogger _logger = Log.ForContext<GmailSynchronizer>();
@@ -54,15 +58,48 @@ namespace Wino.Core.Synchronizers
HttpClientFactory = this
};
_gmailHttpClient = new ConfigurableHttpClient(messageHandler);
_googleHttpClient = new ConfigurableHttpClient(messageHandler);
_gmailService = new GmailService(initializer);
_peopleService = new PeopleServiceService(initializer);
_authenticator = authenticator;
_gmailChangeProcessor = gmailChangeProcessor;
}
public ConfigurableHttpClient CreateHttpClient(CreateHttpClientArgs args) => _gmailHttpClient;
public ConfigurableHttpClient CreateHttpClient(CreateHttpClientArgs args) => _googleHttpClient;
public override async Task<SynchronizationResult> SynchronizeInternalAsync(SynchronizationOptions options, CancellationToken cancellationToken = default)
public override async Task<ProfileInformation> GetProfileInformationAsync()
{
var profileRequest = _peopleService.People.Get("people/me");
profileRequest.PersonFields = "names,photos";
string senderName = string.Empty, base64ProfilePicture = string.Empty;
var userProfile = await profileRequest.ExecuteAsync();
senderName = userProfile.Names?.FirstOrDefault()?.DisplayName ?? Account.SenderName;
var profilePicture = userProfile.Photos?.FirstOrDefault()?.Url ?? string.Empty;
if (!string.IsNullOrEmpty(profilePicture))
{
base64ProfilePicture = await GetProfilePictureBase64EncodedAsync(profilePicture).ConfigureAwait(false);
}
return new ProfileInformation(senderName, base64ProfilePicture);
}
protected override async Task SynchronizeAliasesAsync()
{
var sendAsListRequest = _gmailService.Users.Settings.SendAs.List("me");
var sendAsListResponse = await sendAsListRequest.ExecuteAsync();
var remoteAliases = sendAsListResponse.GetRemoteAliases();
await _gmailChangeProcessor.UpdateRemoteAliasInformationAsync(Account, remoteAliases).ConfigureAwait(false);
}
protected override async Task<SynchronizationResult> SynchronizeInternalAsync(SynchronizationOptions options, CancellationToken cancellationToken = default)
{
_logger.Information("Internal synchronization started for {Name}", Account.Name);

View File

@@ -405,7 +405,7 @@ namespace Wino.Core.Synchronizers
];
}
public override async Task<SynchronizationResult> SynchronizeInternalAsync(SynchronizationOptions options, CancellationToken cancellationToken = default)
protected override async Task<SynchronizationResult> SynchronizeInternalAsync(SynchronizationOptions options, CancellationToken cancellationToken = default)
{
var downloadedMessageIds = new List<string>();

View File

@@ -4,8 +4,8 @@ using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
@@ -18,11 +18,11 @@ using Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options;
using MimeKit;
using MoreLinq.Extensions;
using Serilog;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Exceptions;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts;
using Wino.Core.Domain.Models.MailItem;
using Wino.Core.Domain.Models.Requests;
using Wino.Core.Domain.Models.Synchronization;
@@ -128,7 +128,7 @@ namespace Wino.Core.Synchronizers
#endregion
public override async Task<SynchronizationResult> SynchronizeInternalAsync(SynchronizationOptions options, CancellationToken cancellationToken = default)
protected override async Task<SynchronizationResult> SynchronizeInternalAsync(SynchronizationOptions options, CancellationToken cancellationToken = default)
{
var downloadedMessageIds = new List<string>();
@@ -473,6 +473,36 @@ namespace Wino.Core.Synchronizers
}
}
/// <summary>
/// Get the user's profile picture
/// </summary>
/// <returns>Base64 encoded profile picture.</returns>
private async Task<string> GetUserProfilePictureAsync()
{
var photoStream = await _graphClient.Me.Photos["48x48"].Content.GetAsync();
using var memoryStream = new MemoryStream();
await photoStream.CopyToAsync(memoryStream);
var byteArray = memoryStream.ToArray();
return Convert.ToBase64String(byteArray);
}
private async Task<string> GetSenderNameAsync()
{
var userInfo = await _graphClient.Users["me"].GetAsync();
return userInfo.DisplayName;
}
public override async Task<ProfileInformation> GetProfileInformationAsync()
{
var profilePictureData = await GetUserProfilePictureAsync().ConfigureAwait(false);
var senderName = await GetSenderNameAsync().ConfigureAwait(false);
return new ProfileInformation(senderName, profilePictureData);
}
#region Mail Integration
public override bool DelaySendOperationSynchronization() => true;
@@ -572,22 +602,50 @@ namespace Wino.Core.Synchronizers
{
if (item is CreateDraftRequest createDraftRequest)
{
createDraftRequest.DraftPreperationRequest.CreatedLocalDraftMimeMessage.Prepare(EncodingConstraint.None);
var reason = createDraftRequest.DraftPreperationRequest.Reason;
var message = createDraftRequest.DraftPreperationRequest.CreatedLocalDraftMimeMessage.AsOutlookMessage(true);
var plainTextBytes = Encoding.UTF8.GetBytes(createDraftRequest.DraftPreperationRequest.CreatedLocalDraftMimeMessage.ToString());
var base64Encoded = Convert.ToBase64String(plainTextBytes);
if (reason == DraftCreationReason.Empty)
{
return _graphClient.Me.Messages.ToPostRequestInformation(message);
}
else if (reason == DraftCreationReason.Reply)
{
return _graphClient.Me.Messages[createDraftRequest.DraftPreperationRequest.ReferenceMailCopy.Id].CreateReply.ToPostRequestInformation(new Microsoft.Graph.Me.Messages.Item.CreateReply.CreateReplyPostRequestBody()
{
Message = message
});
}
else if (reason == DraftCreationReason.ReplyAll)
{
return _graphClient.Me.Messages[createDraftRequest.DraftPreperationRequest.ReferenceMailCopy.Id].CreateReplyAll.ToPostRequestInformation(new Microsoft.Graph.Me.Messages.Item.CreateReplyAll.CreateReplyAllPostRequestBody()
{
Message = message
});
}
else if (reason == DraftCreationReason.Forward)
{
return _graphClient.Me.Messages[createDraftRequest.DraftPreperationRequest.ReferenceMailCopy.Id].CreateForward.ToPostRequestInformation(new Microsoft.Graph.Me.Messages.Item.CreateForward.CreateForwardPostRequestBody()
{
Message = message
});
//createDraftRequest.DraftPreperationRequest.CreatedLocalDraftMimeMessage.Prepare(EncodingConstraint.None);
var requestInformation = _graphClient.Me.Messages.ToPostRequestInformation(new Message());
//var plainTextBytes = Encoding.UTF8.GetBytes(createDraftRequest.DraftPreperationRequest.CreatedLocalDraftMimeMessage.ToString());
//var base64Encoded = Convert.ToBase64String(plainTextBytes);
requestInformation.Headers.Clear();// replace the json content header
requestInformation.Headers.Add("Content-Type", "text/plain");
//var requestInformation = _graphClient.Me.Messages.ToPostRequestInformation(new Message());
requestInformation.SetStreamContent(new MemoryStream(Encoding.UTF8.GetBytes(base64Encoded)), "text/plain");
//requestInformation.Headers.Clear();// replace the json content header
//requestInformation.Headers.Add("Content-Type", "text/plain");
return requestInformation;
//requestInformation.SetStreamContent(new MemoryStream(Encoding.UTF8.GetBytes(base64Encoded)), "text/plain");
//return requestInformation;
}
}
return default;
throw new Exception("Invalid create draft request type.");
});
}
@@ -602,50 +660,43 @@ namespace Wino.Core.Synchronizers
var mailCopyId = sendDraftPreparationRequest.MailItem.Id;
var mimeMessage = sendDraftPreparationRequest.Mime;
var batchDeleteRequest = new BatchDeleteRequest(new List<IRequest>()
// Convert mime message to Outlook message.
// Outlook synchronizer does not send MIME messages directly anymore.
// Alias support is lacking with direct MIMEs.
// Therefore we convert the MIME message to Outlook message and use proper APIs.
var outlookMessage = mimeMessage.AsOutlookMessage(false);
// Update draft.
var patchDraftRequest = _graphClient.Me.Messages[mailCopyId].ToPatchRequestInformation(outlookMessage);
var patchDraftRequestBundle = new HttpRequestBundle<RequestInformation>(patchDraftRequest, request);
// Send draft.
// POST requests are handled differently in batches in Graph SDK.
// Batch basically ignores the step's coontent-type and body.
// Manually create a POST request with empty body and send it.
var sendDraftRequest = _graphClient.Me.Messages[mailCopyId].Send.ToPostRequestInformation((config) =>
{
new DeleteRequest(sendDraftPreparationRequest.MailItem)
config.Headers.Add("Content-Type", "application/json");
});
var deleteBundle = Delete(batchDeleteRequest).ElementAt(0);
sendDraftRequest.Headers.Clear();
mimeMessage.Prepare(EncodingConstraint.None);
sendDraftRequest.Content = new MemoryStream(Encoding.UTF8.GetBytes("{}"));
sendDraftRequest.HttpMethod = Method.POST;
sendDraftRequest.Headers.Add("Content-Type", "application/json");
var plainTextBytes = Encoding.UTF8.GetBytes(mimeMessage.ToString());
var base64Encoded = Convert.ToBase64String(plainTextBytes);
var sendDraftRequestBundle = new HttpRequestBundle<RequestInformation>(sendDraftRequest, request);
var outlookMessage = new Message()
{
ConversationId = sendDraftPreparationRequest.MailItem.ThreadId
};
// Apply importance here as well just in case.
if (mimeMessage.Importance != MessageImportance.Normal)
outlookMessage.Importance = mimeMessage.Importance == MessageImportance.High ? Importance.High : Importance.Low;
var body = new Microsoft.Graph.Me.SendMail.SendMailPostRequestBody()
{
Message = outlookMessage
};
var sendRequest = _graphClient.Me.SendMail.ToPostRequestInformation(body);
sendRequest.Headers.Clear();
sendRequest.Headers.Add("Content-Type", "text/plain");
var stream = new MemoryStream(Encoding.UTF8.GetBytes(base64Encoded));
sendRequest.SetStreamContent(stream, "text/plain");
var sendMailRequest = new HttpRequestBundle<RequestInformation>(sendRequest, request);
return [deleteBundle, sendMailRequest];
return [patchDraftRequestBundle, sendDraftRequestBundle];
}
public override IEnumerable<IRequestBundle<RequestInformation>> Archive(BatchArchiveRequest request)
=> Move(new BatchMoveRequest(request.Items, request.FromFolder, request.ToFolder));
public override async Task DownloadMissingMimeMessageAsync(IMailItem mailItem,
MailKit.ITransferProgress transferProgress = null,
CancellationToken cancellationToken = default)
@@ -697,26 +748,41 @@ namespace Wino.Core.Synchronizers
request.ApplyUIChanges();
await batchContent.AddBatchRequestStepAsync(nativeRequest).ConfigureAwait(false);
var batchRequestId = await batchContent.AddBatchRequestStepAsync(nativeRequest).ConfigureAwait(false);
// Map BundleId to batch request step's key.
// This is how we can identify which step succeeded or failed in the bundle.
bundle.BundleId = batchContent.BatchRequestSteps.ElementAt(i).Key;
bundle.BundleId = batchRequestId;
}
if (!batchContent.BatchRequestSteps.Any())
continue;
// Set execution type to serial instead of parallel if needed.
// Each step will depend on the previous one.
if (itemCount > 1)
{
for (int i = 1; i < itemCount; i++)
{
var currentStep = batchContent.BatchRequestSteps.ElementAt(i);
var previousStep = batchContent.BatchRequestSteps.ElementAt(i - 1);
currentStep.Value.DependsOn = [previousStep.Key];
}
}
// Execute batch. This will collect responses from network call for each batch step.
var batchRequestResponse = await _graphClient.Batch.PostAsync(batchContent).ConfigureAwait(false);
var batchRequestResponse = await _graphClient.Batch.PostAsync(batchContent, cancellationToken).ConfigureAwait(false);
// Check responses for each bundle id.
// Each bundle id must return some HttpResponseMessage ideally.
var bundleIds = batchContent.BatchRequestSteps.Select(a => a.Key);
// TODO: Handling responses. They used to work in v1 core, but not in v2.
var exceptionBag = new List<string>();
foreach (var bundleId in bundleIds)
{
@@ -727,45 +793,31 @@ namespace Wino.Core.Synchronizers
var httpResponseMessage = await batchRequestResponse.GetResponseByIdAsync(bundleId);
if (httpResponseMessage == null)
continue;
using (httpResponseMessage)
{
await ProcessSingleNativeRequestResponseAsync(bundle, httpResponseMessage, cancellationToken).ConfigureAwait(false);
if (!httpResponseMessage.IsSuccessStatusCode)
{
var content = await httpResponseMessage.Content.ReadAsStringAsync();
var errorJson = JsonObject.Parse(content);
var errorString = $"({httpResponseMessage.StatusCode}) {errorJson["error"]["code"]} - {errorJson["error"]["message"]}";
exceptionBag.Add(errorString);
}
}
}
if (exceptionBag.Any())
{
var formattedErrorString = string.Join("\n", exceptionBag.Select((item, index) => $"{index + 1}. {item}"));
throw new SynchronizerException(formattedErrorString);
}
}
}
private async Task ProcessSingleNativeRequestResponseAsync(IRequestBundle<RequestInformation> bundle,
HttpResponseMessage httpResponseMessage,
CancellationToken cancellationToken = default)
{
if (httpResponseMessage == null) return;
if (!httpResponseMessage.IsSuccessStatusCode)
{
throw new SynchronizerException(string.Format(Translator.Exception_SynchronizerFailureHTTP, httpResponseMessage.StatusCode));
}
else if (bundle is HttpRequestBundle<RequestInformation, Message> messageBundle)
{
var outlookMessage = await messageBundle.DeserializeBundleAsync(httpResponseMessage, cancellationToken);
if (outlookMessage == null) return;
// TODO: Handle new message added or updated.
}
else if (bundle is HttpRequestBundle<RequestInformation, Microsoft.Graph.Models.MailFolder> folderBundle)
{
var outlookFolder = await folderBundle.DeserializeBundleAsync(httpResponseMessage, cancellationToken);
if (outlookFolder == null) return;
// TODO: Handle new folder added or updated.
}
else if (bundle is HttpRequestBundle<RequestInformation, MimeMessage> mimeBundle)
{
// TODO: Handle mime retrieve message.
}
}
private async Task<MimeMessage> DownloadMimeMessageAsync(string messageId, CancellationToken cancellationToken = default)
{

View File

@@ -17,7 +17,8 @@
<PackageReference Include="CommunityToolkit.Diagnostics" Version="8.2.2" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
<PackageReference Include="Google.Apis.Gmail.v1" Version="1.68.0.3427" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.59" />
<PackageReference Include="Google.Apis.PeopleService.v1" Version="1.68.0.3359" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.63" />
<PackageReference Include="HtmlKit" Version="1.1.0" />
<PackageReference Include="IsExternalInit" Version="1.0.3">
<PrivateAssets>all</PrivateAssets>
@@ -26,9 +27,9 @@
<PackageReference Include="MailKit" Version="4.7.1.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.Graph" Version="5.56.0" />
<PackageReference Include="Microsoft.Identity.Client" Version="4.62.0" />
<PackageReference Include="Microsoft.Identity.Client.Broker" Version="4.62.0" />
<PackageReference Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.62.0" />
<PackageReference Include="Microsoft.Identity.Client" Version="4.63.0" />
<PackageReference Include="Microsoft.Identity.Client.Broker" Version="4.63.0" />
<PackageReference Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.63.0" />
<PackageReference Include="MimeKit" Version="4.7.1" />
<PackageReference Include="morelinq" Version="4.1.0" />
<PackageReference Include="Nito.AsyncEx.Tasks" Version="5.1.2" />