Create sub folder, delete folder, storage settings, some ui adjustments on threads.
This commit is contained in:
@@ -102,6 +102,8 @@ public static class SynchronizationActionHelper
|
||||
RenameFolderRequest => Translator.SyncAction_RenamingFolder,
|
||||
EmptyFolderRequest => Translator.SyncAction_EmptyingFolder,
|
||||
MarkFolderAsReadRequest => Translator.SyncAction_MarkingFolderAsRead,
|
||||
DeleteFolderRequest => Translator.FolderOperation_Delete,
|
||||
CreateSubFolderRequest => Translator.FolderOperation_CreateSubFolder,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Models.Requests;
|
||||
|
||||
namespace Wino.Core.Requests.Folder;
|
||||
|
||||
public record CreateSubFolderRequest(MailItemFolder Folder, string NewFolderName) : FolderRequestBase(Folder, FolderSynchronizerOperation.CreateSubFolder)
|
||||
{
|
||||
public override void ApplyUIChanges() { }
|
||||
public override void RevertUIChanges() { }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Models.Requests;
|
||||
|
||||
namespace Wino.Core.Requests.Folder;
|
||||
|
||||
public record DeleteFolderRequest(MailItemFolder Folder) : FolderRequestBase(Folder, FolderSynchronizerOperation.DeleteFolder)
|
||||
{
|
||||
public override void ApplyUIChanges() { }
|
||||
public override void RevertUIChanges() { }
|
||||
}
|
||||
@@ -130,6 +130,11 @@ public class WinoRequestDelegator : IWinoRequestDelegator
|
||||
await QueueRequestAsync(request, accountId);
|
||||
await SendSyncActionsAddedAsync([request], accountId);
|
||||
await QueueSynchronizationAsync(accountId);
|
||||
|
||||
if (folderRequest.Action is FolderOperation.Delete or FolderOperation.CreateSubFolder)
|
||||
{
|
||||
await QueueFoldersOnlySynchronizationAsync(accountId);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync(DraftPreparationRequest draftPreperationRequest)
|
||||
@@ -203,6 +208,18 @@ public class WinoRequestDelegator : IWinoRequestDelegator
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task QueueFoldersOnlySynchronizationAsync(Guid accountId)
|
||||
{
|
||||
var options = new MailSynchronizationOptions()
|
||||
{
|
||||
AccountId = accountId,
|
||||
Type = MailSynchronizationType.FoldersOnly
|
||||
};
|
||||
|
||||
WeakReferenceMessenger.Default.Send(new NewMailSynchronizationRequested(options));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task SendSyncActionsAddedAsync(IEnumerable<IRequestBase> requests, Guid accountId, string accountName = null)
|
||||
{
|
||||
if (accountName == null)
|
||||
|
||||
@@ -255,15 +255,29 @@ public class WinoRequestProcessor : IWinoRequestProcessor
|
||||
change = new MarkFolderAsReadRequest(folder, unreadItems);
|
||||
|
||||
break;
|
||||
//case FolderOperation.Delete:
|
||||
// var isConfirmed = await _dialogService.ShowConfirmationDialogAsync($"'{folderStructure.FolderName}' is going to be deleted. Do you want to continue?", "Are you sure?", "Yes delete.");
|
||||
case FolderOperation.Delete:
|
||||
var deleteQuestion = string.Format(Translator.DialogMessage_DeleteAccountConfirmationMessage, folder.FolderName);
|
||||
var shouldDelete = await _dialogService.ShowConfirmationDialogAsync(deleteQuestion, Translator.FolderOperation_Delete, Translator.FolderOperation_Delete);
|
||||
|
||||
// if (isConfirmed)
|
||||
// change = new DeleteFolderRequest(accountId, folderStructure.RemoteFolderId, folderStructure.FolderId);
|
||||
if (shouldDelete)
|
||||
{
|
||||
change = new DeleteFolderRequest(folder);
|
||||
}
|
||||
|
||||
// break;
|
||||
//default:
|
||||
// throw new NotImplementedException();
|
||||
break;
|
||||
case FolderOperation.CreateSubFolder:
|
||||
var subFolderName = await _dialogService.ShowTextInputDialogAsync(
|
||||
string.Empty,
|
||||
Translator.FolderOperation_CreateSubFolder,
|
||||
Translator.DialogMessage_RenameFolderMessage,
|
||||
Translator.FolderOperation_CreateSubFolder);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(subFolderName))
|
||||
{
|
||||
change = new CreateSubFolderRequest(folder, subFolderName.Trim());
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return change;
|
||||
|
||||
@@ -1253,40 +1253,16 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
{
|
||||
try
|
||||
{
|
||||
MimeMessage mimeMessage = null;
|
||||
|
||||
// Extract MIME if we downloaded raw format
|
||||
if (downloadRawMime)
|
||||
{
|
||||
mimeMessage = gmailMessage.GetGmailMimeMessage();
|
||||
|
||||
if (mimeMessage == null)
|
||||
{
|
||||
_logger.Warning("Failed to parse MIME for message {MessageId}", gmailMessage.Id);
|
||||
}
|
||||
}
|
||||
|
||||
// Create mail packages from metadata (or raw if downloaded)
|
||||
// Create mail packages from metadata/raw.
|
||||
// If Gmail response is Raw format, CreateNewMailPackagesAsync will parse MIME and
|
||||
// include it in package(s) so it can be saved to disk.
|
||||
var packages = await CreateNewMailPackagesAsync(gmailMessage, null, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (packages != null)
|
||||
{
|
||||
// For Gmail, multiple packages share the same message (different labels/folders)
|
||||
// They already share the same FileId (set in CreateNewMailPackagesAsync) so MIME is stored only once
|
||||
|
||||
foreach (var package in packages)
|
||||
{
|
||||
// When downloaded with Raw format, Payload.Headers is not populated by Gmail API.
|
||||
// Enrich the MailCopy fields (Subject, From, MessageId, etc.) from the parsed MIME.
|
||||
if (downloadRawMime && mimeMessage != null)
|
||||
EnrichMailCopyFromMime(package.Copy, mimeMessage);
|
||||
|
||||
// Create the mail copy with the MIME (if downloaded)
|
||||
var packageWithMime = downloadRawMime && mimeMessage != null
|
||||
? new NewMailItemPackage(package.Copy, mimeMessage, package.AssignedRemoteFolderId)
|
||||
: package;
|
||||
|
||||
await _gmailChangeProcessor.CreateMailAsync(Account.Id, packageWithMime).ConfigureAwait(false);
|
||||
await _gmailChangeProcessor.CreateMailAsync(Account.Id, package).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1414,6 +1390,38 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
public override List<IRequestBundle<IClientServiceRequest>> MarkFolderAsRead(MarkFolderAsReadRequest request)
|
||||
=> MarkRead(new BatchMarkReadRequest(request.MailsToMarkRead.Select(a => new MarkReadRequest(a, true))));
|
||||
|
||||
public override List<IRequestBundle<IClientServiceRequest>> DeleteFolder(DeleteFolderRequest request)
|
||||
{
|
||||
var networkCall = _gmailService.Users.Labels.Delete("me", request.Folder.RemoteFolderId);
|
||||
return [new HttpRequestBundle<IClientServiceRequest>(networkCall, request, request)];
|
||||
}
|
||||
|
||||
public override List<IRequestBundle<IClientServiceRequest>> CreateSubFolder(CreateSubFolderRequest request)
|
||||
{
|
||||
var parentLabelName = request.Folder.FolderName;
|
||||
|
||||
try
|
||||
{
|
||||
var parentLabel = _gmailService.Users.Labels.Get("me", request.Folder.RemoteFolderId).Execute();
|
||||
if (!string.IsNullOrWhiteSpace(parentLabel?.Name))
|
||||
{
|
||||
parentLabelName = parentLabel.Name;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "Failed to resolve full parent label name for {FolderId}. Falling back to local folder name.", request.Folder.RemoteFolderId);
|
||||
}
|
||||
|
||||
var label = new Label()
|
||||
{
|
||||
Name = $"{parentLabelName}/{request.NewFolderName}"
|
||||
};
|
||||
|
||||
var networkCall = _gmailService.Users.Labels.Create(label, "me");
|
||||
return [new HttpRequestBundle<IClientServiceRequest>(networkCall, request, request)];
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Request Execution
|
||||
@@ -1834,8 +1842,7 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
/// <summary>
|
||||
/// Creates new mail packages for the given message.
|
||||
/// AssignedFolder is null since the LabelId is parsed out of the Message.
|
||||
/// NOTE: This method does NOT download MIME content during synchronization.
|
||||
/// MIME is only downloaded when user explicitly reads the message.
|
||||
/// If Gmail Message includes Raw payload, MIME is parsed and attached to packages.
|
||||
/// </summary>
|
||||
/// <param name="message">Gmail message to create package for (must have Metadata format).</param>
|
||||
/// <param name="assignedFolder">Null, not used.</param>
|
||||
@@ -1846,24 +1853,74 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var packageList = new List<NewMailItemPackage>();
|
||||
MimeMessage mimeMessage = null;
|
||||
|
||||
// Raw format is used in delta sync and does not populate Payload.Headers.
|
||||
// Parse MIME from Raw so we can resolve draft mapping header and persist mime content.
|
||||
if (!string.IsNullOrEmpty(message?.Raw))
|
||||
{
|
||||
try
|
||||
{
|
||||
mimeMessage = message.GetGmailMimeMessage();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "Failed to parse MIME from raw Gmail message {MessageId}", message?.Id);
|
||||
}
|
||||
}
|
||||
|
||||
// Create base MailCopy from metadata only - NO MIME download
|
||||
var baseMailCopy = await CreateMinimalMailCopyAsync(message, assignedFolder, cancellationToken);
|
||||
|
||||
// Check for local draft mapping using X-Wino-Draft-Id header from metadata.
|
||||
// If this is a Wino-created draft, the local copy was already mapped by the CreateDraft response handler
|
||||
// with the correct Gmail Draft ID. We must NOT call MapLocalDraftAsync here because
|
||||
// baseMailCopy.DraftId is derived from CreateMinimalMailCopyAsync (not the real Draft resource ID),
|
||||
// which would overwrite the correctly mapped DraftId and break SendDraft.
|
||||
if (mimeMessage != null)
|
||||
{
|
||||
// Raw responses don't include metadata headers. Backfill important fields from MIME.
|
||||
EnrichMailCopyFromMime(baseMailCopy, mimeMessage);
|
||||
}
|
||||
|
||||
// Check for local draft mapping using X-Wino-Draft-Id header.
|
||||
// For Metadata format we read from Payload.Headers.
|
||||
// For Raw format (Payload is null), we read from parsed MIME headers.
|
||||
if (baseMailCopy.IsDraft)
|
||||
{
|
||||
var draftIdHeader = message.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals(Domain.Constants.WinoLocalDraftHeader, StringComparison.OrdinalIgnoreCase))?.Value;
|
||||
var draftIdHeader = message.Payload?.Headers?.FirstOrDefault(h => h.Name.Equals(Domain.Constants.WinoLocalDraftHeader, StringComparison.OrdinalIgnoreCase))?.Value
|
||||
?? mimeMessage?.Headers?.FirstOrDefault(h => h.Field.Equals(Domain.Constants.WinoLocalDraftHeader, StringComparison.OrdinalIgnoreCase))?.Value;
|
||||
|
||||
if (!string.IsNullOrEmpty(draftIdHeader) && Guid.TryParse(draftIdHeader, out _))
|
||||
{
|
||||
// This message belongs to an existing local draft copy.
|
||||
// Skip creating a new mail copy - the local copy was already mapped by the response handler.
|
||||
return null;
|
||||
if (Guid.TryParse(draftIdHeader, out Guid localDraftCopyUniqueId))
|
||||
{
|
||||
// This message belongs to existing local draft copy.
|
||||
// Map remote ids to local copy and skip creating duplicate rows.
|
||||
bool isMappingSuccessful = await _gmailChangeProcessor.MapLocalDraftAsync(
|
||||
Account.Id,
|
||||
localDraftCopyUniqueId,
|
||||
baseMailCopy.Id,
|
||||
baseMailCopy.DraftId,
|
||||
baseMailCopy.ThreadId).ConfigureAwait(false);
|
||||
|
||||
if (isMappingSuccessful)
|
||||
{
|
||||
// Keep local draft MIME in sync with the fetched remote raw MIME if available.
|
||||
if (mimeMessage != null)
|
||||
{
|
||||
var mappedDraftCopies = await _gmailChangeProcessor.GetMailCopiesAsync([baseMailCopy.Id]).ConfigureAwait(false);
|
||||
if (mappedDraftCopies != null)
|
||||
{
|
||||
var savedFileIds = new HashSet<Guid>();
|
||||
foreach (var mappedCopy in mappedDraftCopies)
|
||||
{
|
||||
if (mappedCopy.FileId == Guid.Empty || !savedFileIds.Add(mappedCopy.FileId))
|
||||
continue;
|
||||
|
||||
await _gmailChangeProcessor.SaveMimeFileAsync(mappedCopy.FileId, mimeMessage, Account.Id).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1887,12 +1944,16 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
// Create a new MailCopy instance for each label to avoid shared reference issues
|
||||
var mailCopyForLabel = await CreateMinimalMailCopyAsync(message, assignedFolder, cancellationToken);
|
||||
|
||||
if (mimeMessage != null)
|
||||
{
|
||||
EnrichMailCopyFromMime(mailCopyForLabel, mimeMessage);
|
||||
}
|
||||
|
||||
// Ensure all copies share the same Id and FileId
|
||||
mailCopyForLabel.Id = sharedId;
|
||||
mailCopyForLabel.FileId = sharedFileId;
|
||||
|
||||
// Pass null for MimeMessage - it will be downloaded later when user reads the mail
|
||||
packageList.Add(new NewMailItemPackage(mailCopyForLabel, null, labelId));
|
||||
packageList.Add(new NewMailItemPackage(mailCopyForLabel, mimeMessage, labelId));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -270,6 +270,24 @@ public class ImapSynchronizer : WinoSynchronizer<ImapRequest, ImapMessageCreatio
|
||||
}, request, request);
|
||||
}
|
||||
|
||||
public override List<IRequestBundle<ImapRequest>> DeleteFolder(DeleteFolderRequest request)
|
||||
{
|
||||
return CreateSingleTaskBundle(async (client, item) =>
|
||||
{
|
||||
var folder = await client.GetFolderAsync(request.Folder.RemoteFolderId).ConfigureAwait(false);
|
||||
await folder.DeleteAsync().ConfigureAwait(false);
|
||||
}, request, request);
|
||||
}
|
||||
|
||||
public override List<IRequestBundle<ImapRequest>> CreateSubFolder(CreateSubFolderRequest request)
|
||||
{
|
||||
return CreateSingleTaskBundle(async (client, item) =>
|
||||
{
|
||||
var parentFolder = await client.GetFolderAsync(request.Folder.RemoteFolderId).ConfigureAwait(false);
|
||||
await parentFolder.CreateAsync(request.NewFolderName, true).ConfigureAwait(false);
|
||||
}, request, request);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
public override async Task<List<NewMailItemPackage>> CreateNewMailPackagesAsync(ImapMessageCreationPackage message, MailItemFolder assignedFolder, CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -1507,6 +1507,23 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
public override List<IRequestBundle<RequestInformation>> MarkFolderAsRead(MarkFolderAsReadRequest request)
|
||||
=> MarkRead(new BatchMarkReadRequest(request.MailsToMarkRead.Select(a => new MarkReadRequest(a, true))));
|
||||
|
||||
public override List<IRequestBundle<RequestInformation>> DeleteFolder(DeleteFolderRequest request)
|
||||
{
|
||||
var networkCall = _graphClient.Me.MailFolders[request.Folder.RemoteFolderId].ToDeleteRequestInformation();
|
||||
return [new HttpRequestBundle<RequestInformation>(networkCall, request)];
|
||||
}
|
||||
|
||||
public override List<IRequestBundle<RequestInformation>> CreateSubFolder(CreateSubFolderRequest request)
|
||||
{
|
||||
var requestBody = new MailFolder
|
||||
{
|
||||
DisplayName = request.NewFolderName
|
||||
};
|
||||
|
||||
var networkCall = _graphClient.Me.MailFolders[request.Folder.RemoteFolderId].ChildFolders.ToPostRequestInformation(requestBody);
|
||||
return [new HttpRequestBundle<RequestInformation>(networkCall, request)];
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
public override async Task ExecuteNativeRequestsAsync(List<IRequestBundle<RequestInformation>> batchedRequests, CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -203,6 +203,12 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
|
||||
case FolderSynchronizerOperation.MarkFolderRead:
|
||||
nativeRequests.AddRange(MarkFolderAsRead(group.ElementAt(0) as MarkFolderAsReadRequest));
|
||||
break;
|
||||
case FolderSynchronizerOperation.DeleteFolder:
|
||||
nativeRequests.AddRange(DeleteFolder(group.ElementAt(0) as DeleteFolderRequest));
|
||||
break;
|
||||
case FolderSynchronizerOperation.CreateSubFolder:
|
||||
nativeRequests.AddRange(CreateSubFolder(group.ElementAt(0) as CreateSubFolderRequest));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -508,6 +514,8 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
|
||||
public virtual List<IRequestBundle<TBaseRequest>> RenameFolder(RenameFolderRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
||||
public virtual List<IRequestBundle<TBaseRequest>> EmptyFolder(EmptyFolderRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
||||
public virtual List<IRequestBundle<TBaseRequest>> MarkFolderAsRead(MarkFolderAsReadRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
||||
public virtual List<IRequestBundle<TBaseRequest>> DeleteFolder(DeleteFolderRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
||||
public virtual List<IRequestBundle<TBaseRequest>> CreateSubFolder(CreateSubFolderRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
Reference in New Issue
Block a user