Merge pull request #324 from bkaankose/feature/Aliases
E-mail Aliases Support
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,8 @@ namespace Wino.Core.Services
|
||||
typeof(CustomServerInformation),
|
||||
typeof(AccountSignature),
|
||||
typeof(MergedInbox),
|
||||
typeof(MailAccountPreferences)
|
||||
typeof(MailAccountPreferences),
|
||||
typeof(MailAccountAlias)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user