using FluentAssertions; using MailKit; using MailKit.Net.Imap; using MailKit.Search; using Moq; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.MailItem; using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Integration.Processors; using Wino.Core.Requests.Mail; using Wino.Core.Synchronizers.ImapSync; using Wino.Core.Synchronizers.Mail; using Wino.Services.Extensions; using Xunit; using IMailService = Wino.Core.Domain.Interfaces.IMailService; namespace Wino.Core.Tests.Synchronizers; public sealed class ImapSynchronizerLiveTests { private const string ManualSkipMessage = "Manual live IMAP test. Fill Server/Port/Username/Password placeholders and remove Skip to run."; // Replace placeholders with your own credentials when running these live tests. private const string Server = "imap.example.com"; private const int Port = 993; private const string Username = "REPLACE_WITH_USERNAME"; private const string Password = "REPLACE_WITH_PASSWORD"; [Fact(Skip = ManualSkipMessage)] [Trait("Category", "Live")] public async Task InitialSynchronization_DownloadsInboxMetadata() { using var context = await CreateContextAsync(); var result = await context.Synchronizer.SynchronizeMailsAsync(CreateCustomFolderSyncOptions(context.Account.Id, context.InboxFolder.Id)); result.CompletedState.Should().Be(SynchronizationCompletedState.Success); result.FolderResults.Should().ContainSingle(); result.FolderResults[0].Success.Should().BeTrue(); result.FolderResults[0].DownloadedCount.Should().BeGreaterThan(0); } [Fact(Skip = ManualSkipMessage)] [Trait("Category", "Live")] public async Task DeltaSynchronization_SecondRunDownloadsNoAdditionalMessages() { using var context = await CreateContextAsync(); var initialResult = await context.Synchronizer.SynchronizeMailsAsync(CreateCustomFolderSyncOptions(context.Account.Id, context.InboxFolder.Id)); initialResult.CompletedState.Should().Be(SynchronizationCompletedState.Success); var deltaResult = await context.Synchronizer.SynchronizeMailsAsync(CreateCustomFolderSyncOptions(context.Account.Id, context.InboxFolder.Id)); deltaResult.CompletedState.Should().Be(SynchronizationCompletedState.Success); deltaResult.FolderResults.Should().ContainSingle(); deltaResult.FolderResults[0].DownloadedCount.Should().Be(0); } [Fact(Skip = ManualSkipMessage)] [Trait("Category", "Live")] public async Task MarkFirstInboxMailUnreadThenRead_ValidatesReadFlagChanges() { using var context = await CreateContextAsync(); var firstUid = await GetFirstInboxMessageUidAsync(context.Account); var mailCopy = CreateInboxMailCopy(context.InboxFolder, firstUid); await ExecuteMarkReadAsync(context.Synchronizer, mailCopy, isRead: false); (await GetIsSeenAsync(context.Account, firstUid)).Should().BeFalse(); await ExecuteMarkReadAsync(context.Synchronizer, mailCopy, isRead: true); (await GetIsSeenAsync(context.Account, firstUid)).Should().BeTrue(); } [Fact(Skip = ManualSkipMessage)] [Trait("Category", "Live")] public async Task MarkFirstInboxMailReadThenUnread_ValidatesUnreadFlagChanges() { using var context = await CreateContextAsync(); var firstUid = await GetFirstInboxMessageUidAsync(context.Account); var mailCopy = CreateInboxMailCopy(context.InboxFolder, firstUid); await ExecuteMarkReadAsync(context.Synchronizer, mailCopy, isRead: true); (await GetIsSeenAsync(context.Account, firstUid)).Should().BeTrue(); await ExecuteMarkReadAsync(context.Synchronizer, mailCopy, isRead: false); (await GetIsSeenAsync(context.Account, firstUid)).Should().BeFalse(); } private static MailSynchronizationOptions CreateCustomFolderSyncOptions(Guid accountId, Guid folderId) => new() { AccountId = accountId, Type = MailSynchronizationType.CustomFolders, SynchronizationFolderIds = [folderId] }; private static async Task ExecuteMarkReadAsync(ImapSynchronizer synchronizer, MailCopy mailCopy, bool isRead) { var requests = synchronizer.MarkRead(new BatchMarkReadRequest([new MarkReadRequest(mailCopy, isRead)])); await synchronizer.ExecuteNativeRequestsAsync(requests); } private static MailCopy CreateInboxMailCopy(MailItemFolder folder, UniqueId uid) => new() { Id = MailkitClientExtensions.CreateUid(folder.Id, uid.Id), AssignedFolder = folder, IsRead = false, Subject = "Live test placeholder" }; private static async Task GetFirstInboxMessageUidAsync(MailAccount account) { using var client = await CreateConnectedClientAsync(account); var inbox = client.Inbox; await inbox.OpenAsync(FolderAccess.ReadWrite); var allUids = await inbox.SearchAsync(SearchQuery.All); allUids.Should().NotBeEmpty("Inbox must contain at least one message for mark-read live tests."); var firstUid = allUids.First(); await inbox.CloseAsync(); await client.DisconnectAsync(true); return firstUid; } private static async Task GetIsSeenAsync(MailAccount account, UniqueId uid) { using var client = await CreateConnectedClientAsync(account); var inbox = client.Inbox; await inbox.OpenAsync(FolderAccess.ReadOnly); var summary = await inbox.FetchAsync([uid], MessageSummaryItems.Flags); await inbox.CloseAsync(); await client.DisconnectAsync(true); summary.Should().ContainSingle(); var flags = summary[0].Flags; flags.Should().NotBeNull(); return flags!.Value.HasFlag(MessageFlags.Seen); } private static async Task CreateConnectedClientAsync(MailAccount account) { var client = new ImapClient(); await client.ConnectAsync(account.ServerInformation.IncomingServer, int.Parse(account.ServerInformation.IncomingServerPort), MailKit.Security.SecureSocketOptions.Auto); await client.AuthenticateAsync(account.ServerInformation.IncomingServerUsername, account.ServerInformation.IncomingServerPassword); return client; } private static async Task CreateContextAsync() { var tempDirectory = Path.Combine(Path.GetTempPath(), "wino-imap-live-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(tempDirectory); var account = new MailAccount { Id = Guid.NewGuid(), Name = "IMAP Live Test", Address = Username, ProviderType = MailProviderType.IMAP4, ServerInformation = new CustomServerInformation { Id = Guid.NewGuid(), IncomingServer = Server, IncomingServerPort = Port.ToString(), IncomingServerUsername = Username, IncomingServerPassword = Password, IncomingServerSocketOption = ImapConnectionSecurity.Auto, MaxConcurrentClients = 2 } }; var inboxFolder = new MailItemFolder { Id = Guid.NewGuid(), MailAccountId = account.Id, FolderName = "Inbox", RemoteFolderId = "INBOX", SpecialFolderType = SpecialFolderType.Inbox, IsSynchronizationEnabled = true, ShowUnreadCount = true, UidValidity = 0, HighestModeSeq = 0, HighestKnownUid = 0 }; var storedMails = new Dictionary(); var folderService = new Mock(); folderService.Setup(x => x.GetKnownUidsForFolderAsync(inboxFolder.Id)) .ReturnsAsync(() => storedMails.Values.Select(m => MailkitClientExtensions.ResolveUid(m.Id)).ToList()); folderService.Setup(x => x.UpdateFolderAsync(It.IsAny())).Returns(Task.CompletedTask); folderService.Setup(x => x.UpdateFolderHighestModeSeqAsync(It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); folderService.Setup(x => x.DeleteFolderAsync(It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); var mailService = new Mock(); mailService.Setup(x => x.GetMailsByFolderIdAsync(inboxFolder.Id)).ReturnsAsync(() => storedMails.Values.ToList()); mailService.Setup(x => x.GetExistingMailsAsync(inboxFolder.Id, It.IsAny>())) .ReturnsAsync((Guid _, IEnumerable ids) => ids.Select(uid => MailkitClientExtensions.CreateUid(inboxFolder.Id, uid.Id)) .Where(storedMails.ContainsKey) .Select(id => storedMails[id]) .ToList()); mailService.Setup(x => x.CreateMailAsync(account.Id, It.IsAny())) .ReturnsAsync((Guid _, NewMailItemPackage package) => { storedMails[package.Copy.Id] = package.Copy; return true; }); mailService.Setup(x => x.ChangeReadStatusAsync(It.IsAny(), It.IsAny())) .Returns((string mailCopyId, bool isRead) => { if (storedMails.TryGetValue(mailCopyId, out var copy)) { copy.IsRead = isRead; } return Task.CompletedTask; }); mailService.Setup(x => x.ChangeFlagStatusAsync(It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); mailService.Setup(x => x.DeleteMailAsync(It.IsAny(), It.IsAny())) .Returns((Guid _, string mailCopyId) => { storedMails.Remove(mailCopyId); return Task.CompletedTask; }); var changeProcessor = new Mock(); changeProcessor.Setup(x => x.GetSynchronizationFoldersAsync(It.IsAny())) .ReturnsAsync([inboxFolder]); changeProcessor.Setup(x => x.GetRecentMailIdsForFolderAsync(inboxFolder.Id, It.IsAny())) .ReturnsAsync((Guid _, int count) => storedMails.Keys.Take(count).ToList()); changeProcessor.Setup(x => x.GetDownloadedUnreadMailsAsync(account.Id, It.IsAny>())) .ReturnsAsync(new List()); var appConfiguration = new Mock(); appConfiguration.SetupProperty(x => x.ApplicationDataFolderPath, tempDirectory); appConfiguration.SetupProperty(x => x.PublisherSharedFolderPath, tempDirectory); appConfiguration.SetupProperty(x => x.ApplicationTempFolderPath, tempDirectory); appConfiguration.SetupGet(x => x.SentryDNS).Returns(string.Empty); var unifiedSynchronizer = new UnifiedImapSynchronizer( folderService.Object, mailService.Object, Mock.Of()); var synchronizer = new ImapSynchronizer( account, changeProcessor.Object, appConfiguration.Object, unifiedSynchronizer, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of()); return await Task.FromResult(new LiveTestContext(account, inboxFolder, synchronizer, tempDirectory)); } private sealed class LiveTestContext(MailAccount account, MailItemFolder inboxFolder, ImapSynchronizer synchronizer, string tempDirectory) : IDisposable { public MailAccount Account { get; } = account; public MailItemFolder InboxFolder { get; } = inboxFolder; public ImapSynchronizer Synchronizer { get; } = synchronizer; public void Dispose() { Synchronizer.KillSynchronizerAsync().GetAwaiter().GetResult(); if (Directory.Exists(tempDirectory)) { Directory.Delete(tempDirectory, recursive: true); } } } }