From e816e87f61046b44e24243880bd27c99804a8975 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Sun, 1 Mar 2026 21:07:10 +0100 Subject: [PATCH] Contacts management. --- .../EventDetailsPageViewModel.cs | 27 +- .../Calendar/CalendarEventAttendee.cs | 9 +- .../Entities/Shared/AccountContact.cs | 11 +- .../Entities/Shared/ContactGroup.cs | 19 ++ .../Entities/Shared/ContactGroupMember.cs | 21 ++ .../Interfaces/IContactPictureFileService.cs | 33 ++ .../Interfaces/IContactService.cs | 19 +- .../Helpers/InMemoryDatabaseService.cs | 2 + .../Services/ContactServiceTests.cs | 115 +++++++ Wino.Mail.ViewModels/WelcomePageViewModel.cs | 4 +- Wino.Mail.WinUI/App.xaml.cs | 3 + Wino.Mail.WinUI/Assets/ReleaseNotes/vnext.md | 86 +++++ .../Controls/ImagePreviewControl.cs | 57 +++- .../Dialogs/ContactEditDialog.xaml.cs | 65 +++- Wino.Mail.WinUI/Wino.Mail.WinUI.csproj | 1 + Wino.Services/ContactPictureFileService.cs | 88 +++++ Wino.Services/ContactService.cs | 314 +++++++++++++++++- Wino.Services/DatabaseService.cs | 11 + Wino.Services/ServicesContainerSetup.cs | 2 + 19 files changed, 855 insertions(+), 32 deletions(-) create mode 100644 Wino.Core.Domain/Entities/Shared/ContactGroup.cs create mode 100644 Wino.Core.Domain/Entities/Shared/ContactGroupMember.cs create mode 100644 Wino.Core.Domain/Interfaces/IContactPictureFileService.cs create mode 100644 Wino.Core.Tests/Services/ContactServiceTests.cs create mode 100644 Wino.Mail.WinUI/Assets/ReleaseNotes/vnext.md create mode 100644 Wino.Services/ContactPictureFileService.cs diff --git a/Wino.Calendar.ViewModels/EventDetailsPageViewModel.cs b/Wino.Calendar.ViewModels/EventDetailsPageViewModel.cs index c13bd876..cac9288f 100644 --- a/Wino.Calendar.ViewModels/EventDetailsPageViewModel.cs +++ b/Wino.Calendar.ViewModels/EventDetailsPageViewModel.cs @@ -31,6 +31,7 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel private readonly IWinoRequestDelegator _winoRequestDelegator; private readonly INavigationService _navigationService; private readonly IUnderlyingThemeService _underlyingThemeService; + private readonly IContactService _contactService; public CalendarSettings CurrentSettings { get; } public INativeAppService NativeAppService => _nativeAppService; @@ -143,7 +144,8 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel IMailDialogService dialogService, IWinoRequestDelegator winoRequestDelegator, INavigationService navigationService, - IUnderlyingThemeService underlyingThemeService) + IUnderlyingThemeService underlyingThemeService, + IContactService contactService) { _calendarService = calendarService; _nativeAppService = nativeAppService; @@ -152,6 +154,7 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel _winoRequestDelegator = winoRequestDelegator; _navigationService = navigationService; _underlyingThemeService = underlyingThemeService; + _contactService = contactService; CurrentSettings = _preferencesService.GetCurrentCalendarSettings(); IsDarkWebviewRenderer = _underlyingThemeService.IsUnderlyingThemeDark(); @@ -260,6 +263,24 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel var attendees = await _calendarService.GetAttendeesAsync(calendarItemId); + // Resolve contacts for all attendees in a single batch DB query. + var emails = attendees + .Where(a => !string.IsNullOrEmpty(a.Email)) + .Select(a => a.Email) + .ToList(); + + if (!string.IsNullOrEmpty(calendarItem.OrganizerEmail)) + emails.Add(calendarItem.OrganizerEmail); + + var contacts = await _contactService.GetContactsByAddressesAsync(emails).ConfigureAwait(false); + var contactLookup = contacts.ToDictionary(c => c.Address, StringComparer.OrdinalIgnoreCase); + + foreach (var attendee in attendees) + { + if (!string.IsNullOrEmpty(attendee.Email) && contactLookup.TryGetValue(attendee.Email, out var contact)) + attendee.ResolvedContact = contact; + } + // Separate organizer from other attendees to ensure organizer is always first var organizer = attendees.FirstOrDefault(a => a.IsOrganizer); var nonOrganizerAttendees = attendees.Where(a => !a.IsOrganizer).ToList(); @@ -281,6 +302,10 @@ public partial class EventDetailsPageViewModel : CalendarBaseViewModel IsOrganizer = true, AttendenceStatus = AttendeeStatus.Accepted }; + + if (contactLookup.TryGetValue(calendarItem.OrganizerEmail, out var organizerContact)) + organizerAttendee.ResolvedContact = organizerContact; + CurrentEvent.Attendees.Add(organizerAttendee); } diff --git a/Wino.Core.Domain/Entities/Calendar/CalendarEventAttendee.cs b/Wino.Core.Domain/Entities/Calendar/CalendarEventAttendee.cs index e9c47da3..8935589a 100644 --- a/Wino.Core.Domain/Entities/Calendar/CalendarEventAttendee.cs +++ b/Wino.Core.Domain/Entities/Calendar/CalendarEventAttendee.cs @@ -1,10 +1,10 @@ using System; using SQLite; +using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; namespace Wino.Core.Domain.Entities.Calendar; -// TODO: Connect to Contact store with Wino People. public class CalendarEventAttendee { [PrimaryKey] @@ -16,4 +16,11 @@ public class CalendarEventAttendee public bool IsOrganizer { get; set; } public bool IsOptionalAttendee { get; set; } public string Comment { get; set; } + + /// + /// Resolved contact from the contact store. Populated at runtime via IContactService; + /// not persisted to the database. + /// + [Ignore] + public AccountContact ResolvedContact { get; set; } } diff --git a/Wino.Core.Domain/Entities/Shared/AccountContact.cs b/Wino.Core.Domain/Entities/Shared/AccountContact.cs index 85faa0ed..103b76e7 100644 --- a/Wino.Core.Domain/Entities/Shared/AccountContact.cs +++ b/Wino.Core.Domain/Entities/Shared/AccountContact.cs @@ -25,7 +25,16 @@ public class AccountContact : IEquatable public string Name { get; set; } /// - /// Base64 encoded profile image of the contact. + /// File ID for the contact picture stored on disk. + /// The actual file lives at {ApplicationDataFolderPath}/contacts/{ContactPictureFileId}.jpg. + /// Preferred over Base64ContactPicture — allows native BitmapImage file loading and avoids SQLite bloat. + /// + public Guid? ContactPictureFileId { get; set; } + + /// + /// Legacy base64 encoded profile image of the contact. + /// For user-set contact pictures: migrate to file storage via ContactPictureFileId instead. + /// Still used for OAuth account profile pictures (MailAccount.Base64ProfilePictureData). /// public string Base64ContactPicture { get; set; } diff --git a/Wino.Core.Domain/Entities/Shared/ContactGroup.cs b/Wino.Core.Domain/Entities/Shared/ContactGroup.cs new file mode 100644 index 00000000..6fd28dcd --- /dev/null +++ b/Wino.Core.Domain/Entities/Shared/ContactGroup.cs @@ -0,0 +1,19 @@ +using System; +using SQLite; + +namespace Wino.Core.Domain.Entities.Shared; + +/// +/// A named group of contacts that can be expanded to individual addresses during mail composition. +/// +public class ContactGroup +{ + [PrimaryKey] + public Guid Id { get; set; } + + /// Display name of the group (e.g., "Team Alpha", "Family"). + public string Name { get; set; } + + /// Optional description for the group. + public string Description { get; set; } +} diff --git a/Wino.Core.Domain/Entities/Shared/ContactGroupMember.cs b/Wino.Core.Domain/Entities/Shared/ContactGroupMember.cs new file mode 100644 index 00000000..1209f5e3 --- /dev/null +++ b/Wino.Core.Domain/Entities/Shared/ContactGroupMember.cs @@ -0,0 +1,21 @@ +using System; +using SQLite; + +namespace Wino.Core.Domain.Entities.Shared; + +/// +/// Associates an e-mail address with a . +/// +public class ContactGroupMember +{ + [PrimaryKey, AutoIncrement] + public int Id { get; set; } + + /// Group this member belongs to. + [Indexed] + public Guid GroupId { get; set; } + + /// E-mail address of the member (FK to AccountContact.Address). + [Indexed] + public string MemberAddress { get; set; } +} diff --git a/Wino.Core.Domain/Interfaces/IContactPictureFileService.cs b/Wino.Core.Domain/Interfaces/IContactPictureFileService.cs new file mode 100644 index 00000000..f3b59df1 --- /dev/null +++ b/Wino.Core.Domain/Interfaces/IContactPictureFileService.cs @@ -0,0 +1,33 @@ +using System; +using System.Threading.Tasks; + +namespace Wino.Core.Domain.Interfaces; + +/// +/// Manages contact picture files stored on disk instead of as base64 in SQLite, +/// eliminating DB bloat and enabling native WIC hardware-accelerated image loading. +/// +public interface IContactPictureFileService +{ + /// + /// Returns the full file path for the given file ID, or null if the file does not exist on disk. + /// + string GetContactPicturePath(Guid fileId); + + /// + /// Saves raw image bytes to disk and returns the new file ID. + /// + Task SaveContactPictureAsync(byte[] imageData); + + /// + /// Deletes the picture file for the given file ID if it exists. + /// + Task DeleteContactPictureAsync(Guid fileId); + + /// + /// One-time startup migration: reads AccountContact rows where Base64ContactPicture is set + /// but ContactPictureFileId is null, writes the picture bytes to disk, updates the DB row, + /// and clears the Base64ContactPicture column. + /// + Task MigrateBase64PicturesAsync(); +} diff --git a/Wino.Core.Domain/Interfaces/IContactService.cs b/Wino.Core.Domain/Interfaces/IContactService.cs index b70bb957..6462026d 100644 --- a/Wino.Core.Domain/Interfaces/IContactService.cs +++ b/Wino.Core.Domain/Interfaces/IContactService.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using MimeKit; @@ -16,11 +17,25 @@ public interface IContactService Task SaveAddressInformationAsync(IEnumerable contacts); Task CreateNewContactAsync(string address, string displayName); - // New methods for ContactsPage + // Paged contact queries for ContactsPage Task> GetAllContactsAsync(); Task> SearchContactsAsync(string searchQuery); Task GetContactsPageAsync(int offset, int pageSize, string searchQuery = null, bool excludeRootContacts = false); Task UpdateContactAsync(AccountContact contact); Task DeleteContactAsync(string address); Task DeleteContactsAsync(IEnumerable addresses); + + // Group / distribution list support + Task> GetGroupsAsync(); + Task CreateGroupAsync(string name, string description = null); + Task DeleteGroupAsync(Guid groupId); + Task> GetGroupMembersAsync(Guid groupId); + Task AddGroupMemberAsync(Guid groupId, string memberAddress); + Task RemoveGroupMemberAsync(Guid groupId, string memberAddress); + + /// + /// Expands a contact group to the individual entries of its members. + /// Returns an empty list if the group does not exist or has no members. + /// + Task> ExpandGroupAsync(Guid groupId); } diff --git a/Wino.Core.Tests/Helpers/InMemoryDatabaseService.cs b/Wino.Core.Tests/Helpers/InMemoryDatabaseService.cs index e952a96a..a95a3e97 100644 --- a/Wino.Core.Tests/Helpers/InMemoryDatabaseService.cs +++ b/Wino.Core.Tests/Helpers/InMemoryDatabaseService.cs @@ -41,6 +41,8 @@ public class InMemoryDatabaseService : IDatabaseService await Connection.CreateTableAsync(); await Connection.CreateTableAsync(); await Connection.CreateTableAsync(); + await Connection.CreateTableAsync(); + await Connection.CreateTableAsync(); await Connection.CreateTableAsync(); await Connection.CreateTableAsync(); await Connection.CreateTableAsync(); diff --git a/Wino.Core.Tests/Services/ContactServiceTests.cs b/Wino.Core.Tests/Services/ContactServiceTests.cs new file mode 100644 index 00000000..4e3e10ef --- /dev/null +++ b/Wino.Core.Tests/Services/ContactServiceTests.cs @@ -0,0 +1,115 @@ +using FluentAssertions; +using MimeKit; +using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Tests.Helpers; +using Wino.Services; +using Xunit; + +namespace Wino.Core.Tests.Services; + +public class ContactServiceTests : IAsyncLifetime +{ + private InMemoryDatabaseService _databaseService = null!; + private ContactService _contactService = null!; + + public async Task InitializeAsync() + { + _databaseService = new InMemoryDatabaseService(); + await _databaseService.InitializeAsync(); + _contactService = new ContactService(_databaseService); + } + + public async Task DisposeAsync() + { + await _databaseService.DisposeAsync(); + } + + [Fact] + public async Task SaveAddressInformationAsync_WithNotificationReplyAddress_DoesNotPersistContact() + { + await _contactService.SaveAddressInformationAsync( + [ + new AccountContact + { + Address = "reply+ABCD1234@reply.github.com", + Name = "[owner/repository] Issue #42" + } + ]); + + var contact = await _databaseService.Connection + .Table() + .Where(c => c.Address == "reply+ABCD1234@reply.github.com") + .FirstOrDefaultAsync(); + + contact.Should().BeNull(); + } + + [Fact] + public async Task SaveAddressInformationAsync_WithHumanContact_PersistsContact() + { + await _contactService.SaveAddressInformationAsync( + [ + new AccountContact + { + Address = "alice@example.com", + Name = "Alice Example" + } + ]); + + var contact = await _databaseService.Connection + .Table() + .Where(c => c.Address == "alice@example.com") + .FirstOrDefaultAsync(); + + contact.Should().NotBeNull(); + contact!.Name.Should().Be("Alice Example"); + } + + [Fact] + public async Task SaveAddressInformationAsync_WithExistingNoisyContact_RemovesAutoCapturedEntry() + { + await _databaseService.Connection.InsertAsync( + new AccountContact + { + Address = "notifications@github.com", + Name = "GitHub Notifications" + }, + typeof(AccountContact)); + + await _contactService.SaveAddressInformationAsync( + [ + new AccountContact + { + Address = "notifications@github.com", + Name = "[owner/repository] Issue #99" + } + ]); + + var contact = await _databaseService.Connection + .Table() + .Where(c => c.Address == "notifications@github.com") + .FirstOrDefaultAsync(); + + contact.Should().BeNull(); + } + + [Fact] + public async Task SaveAddressInformationAsync_WithNoisyMimeGroup_SkipsGroupAndNoisyMembers() + { + var message = new MimeMessage(); + message.To.Add(new GroupAddress("[owner/repository] Issue #123", new InternetAddressList + { + new MailboxAddress("Alice Example", "alice@example.com"), + new MailboxAddress("[owner/repository] Issue #123", "notifications@github.com") + })); + + await _contactService.SaveAddressInformationAsync(message); + + var contacts = await _databaseService.Connection.Table().ToListAsync(); + var groups = await _databaseService.Connection.Table().ToListAsync(); + + contacts.Select(c => c.Address).Should().Contain("alice@example.com"); + contacts.Select(c => c.Address).Should().NotContain("notifications@github.com"); + groups.Should().BeEmpty(); + } +} diff --git a/Wino.Mail.ViewModels/WelcomePageViewModel.cs b/Wino.Mail.ViewModels/WelcomePageViewModel.cs index abe77009..4893d0e1 100644 --- a/Wino.Mail.ViewModels/WelcomePageViewModel.cs +++ b/Wino.Mail.ViewModels/WelcomePageViewModel.cs @@ -8,12 +8,12 @@ namespace Wino.Mail.ViewModels; public partial class WelcomePageViewModel : MailBaseViewModel { - public const string VersionFile = "1102.md"; + public const string VersionFile = "vnext.md"; private readonly IMailDialogService _dialogService; private readonly IFileService _fileService; [ObservableProperty] - private string currentVersionNotes; + public partial string CurrentVersionNotes { get; set; } = string.Empty; public WelcomePageViewModel(IMailDialogService dialogService, IFileService fileService) { diff --git a/Wino.Mail.WinUI/App.xaml.cs b/Wino.Mail.WinUI/App.xaml.cs index 3f7286bb..161a5fcc 100644 --- a/Wino.Mail.WinUI/App.xaml.cs +++ b/Wino.Mail.WinUI/App.xaml.cs @@ -146,6 +146,9 @@ public partial class App : WinoApplication, // Note: Theme service is initialized separately after window creation. await InitializeServicesAsync(); + // Migrate existing base64 contact pictures to file system (one-time, no-op on subsequent starts). + await Services.GetRequiredService().MigrateBase64PicturesAsync(); + _synchronizationManager = Services.GetRequiredService(); _preferencesService = Services.GetRequiredService(); _accountService = Services.GetRequiredService(); diff --git a/Wino.Mail.WinUI/Assets/ReleaseNotes/vnext.md b/Wino.Mail.WinUI/Assets/ReleaseNotes/vnext.md new file mode 100644 index 00000000..3e014bd0 --- /dev/null +++ b/Wino.Mail.WinUI/Assets/ReleaseNotes/vnext.md @@ -0,0 +1,86 @@ +# 🎉 Welcome to Wino Mail – What's New? + +Thank you for using Wino Mail! This update is one of the biggest yet, bringing a brand-new Wino Calendar, major security improvements, and a ton of quality-of-life upgrades. Here's a tour of everything new: + +--- + +## 📅 Wino Calendar + +Wino now ships with a fully integrated calendar alongside your mail. You can view, create, and manage your events without ever leaving the app. If you use any CalDAV-compatible service (like iCloud, Fastmail, or a self-hosted server), your events will sync automatically and stay up to date. Recurring events, reminders, RSVP responses, and online meeting links are all supported. When someone sends you a calendar invitation by email, Wino will recognize it and let you accept or decline right from the mail reading view. + +- View, create, edit, and delete calendar events +- Sync with any CalDAV-compatible calendar service +- Full recurring event support +- RSVP directly from invitation emails +- Reminders and "Join Online" links for virtual meetings +- Calendar settings integrated into the main Settings page + +--- + +## 🔒 Email Signing & Encryption (S/MIME) + +You can now send digitally signed emails so recipients know a message genuinely came from you, and encrypt outgoing emails so only the intended recipient can read them. When you receive a signed or encrypted email, Wino will verify the signature and decrypt the content automatically. Import your personal certificate once from **Settings → Signature & Encryption**, and Wino takes care of the rest. Each email address (alias) can have its own certificate. + +- Import your personal S/MIME certificate (PKCS#12 / .pfx) +- Sign and/or encrypt outgoing emails with toggle buttons in the compose toolbar +- Visual indicator on received emails that are signed or encrypted +- Automatic signature verification and decryption on incoming mail + +--- + +## 💬 Threaded Mail View + +Emails that belong to the same conversation are now grouped into threads, making it much easier to follow a back-and-forth discussion without scrolling through your entire inbox. Threads expand and collapse smoothly, and you can select or act on individual messages within a conversation. + +--- + +## 📎 Large Attachments for Outlook + +Sending large files via your Outlook or Microsoft 365 account no longer fails. Wino now uses Microsoft's upload session API behind the scenes, which handles big attachments reliably regardless of file size. + +--- + +## 🔔 Smarter Notifications + +Toast notifications now let you act on emails directly from the notification (mark as read, delete, etc.) even if the app is not open. Clicking a calendar reminder notification takes you straight to that event. Notifications for mail and calendar are now routed to the correct app entry automatically. + +--- + +## 🗂️ Folder Management + +You can now create new sub-folders and delete existing folders directly from the sidebar — no need to go to your webmail to organize your mailbox. A new Storage settings page also lets you see how much space Wino is using on your device. + +--- + +## 💫 Swipe Actions + +Swipe left or right on emails in the mail list to quickly archive, delete, or mark them — ideal for touch screen devices or when you want to process your inbox fast. + +--- + +## ⌨️ Keyboard Shortcuts + +A new keyboard shortcuts dialog is available so you can discover all the keyboard shortcuts Wino supports. Press the shortcut or find it in the app menu to open it. + +--- + +## 🖨️ Custom Print Dialog + +Printing an email now uses Wino's own print dialog, giving you a cleaner and more consistent experience. + +--- + +## 🚀 Faster App Startup + +Wino's internals have been modernized to take full advantage of the latest .NET runtime optimizations. While this is a behind-the-scenes change, it means the app starts quicker, uses less memory, and is set up for even better performance in future updates. + +--- + +## 🐛 Bug Fixes & Stability + +- Fixed several issues with Outlook sync reliability and speed +- Improved IMAP synchronization to be more stable and resource-efficient +- Fixed duplicate mail and calendar event issues +- Improved account sign-out and re-authentication handling +- Better error messages when something goes wrong during sync +- Dozens of smaller fixes throughout the app diff --git a/Wino.Mail.WinUI/Controls/ImagePreviewControl.cs b/Wino.Mail.WinUI/Controls/ImagePreviewControl.cs index dff30f3f..fae89a8f 100644 --- a/Wino.Mail.WinUI/Controls/ImagePreviewControl.cs +++ b/Wino.Mail.WinUI/Controls/ImagePreviewControl.cs @@ -23,7 +23,7 @@ namespace Wino.Controls; /// public sealed partial class ImagePreviewControl : PersonPicture { - private sealed record RefreshSnapshot(string DisplayName, string Address, string Base64Picture); + private sealed record RefreshSnapshot(string DisplayName, string Address, Guid? ContactPictureFileId, string Base64Picture); private static readonly TimeSpan RefreshDebounceDuration = TimeSpan.FromMilliseconds(40); @@ -32,6 +32,7 @@ public sealed partial class ImagePreviewControl : PersonPicture private readonly IThumbnailService? _thumbnailService; private readonly IPreferencesService? _preferencesService; + private readonly IContactPictureFileService? _contactPictureFileService; private INotifyPropertyChanged? _mailItemInformationPropertySource; private CancellationTokenSource? _refreshCancellationTokenSource; private CancellationTokenSource? _scheduledRefreshCancellationTokenSource; @@ -45,6 +46,7 @@ public sealed partial class ImagePreviewControl : PersonPicture { _thumbnailService = App.Current.Services.GetService(); _preferencesService = App.Current.Services.GetService(); + _contactPictureFileService = App.Current.Services.GetService(); } catch { @@ -187,7 +189,26 @@ public sealed partial class ImagePreviewControl : PersonPicture await ApplyInitialVisualStateAsync(snapshot.DisplayName, refreshVersion, cancellationToken).ConfigureAwait(false); - // 1) Explicit contact picture. + // Skip all picture loading if the user has disabled sender pictures. + if (_preferencesService?.IsShowSenderPicturesEnabled == false) + return; + + // 1) File-based contact picture (preferred — native WIC decode, no base64 overhead). + if (snapshot.ContactPictureFileId.HasValue && _contactPictureFileService != null) + { + var filePath = _contactPictureFileService.GetContactPicturePath(snapshot.ContactPictureFileId.Value); + if (!string.IsNullOrEmpty(filePath)) + { + var fileBitmap = await CreateBitmapFromFileAsync(filePath, cancellationToken).ConfigureAwait(false); + if (fileBitmap != null) + { + await ApplyProfilePictureAsync(fileBitmap, refreshVersion, cancellationToken).ConfigureAwait(false); + return; + } + } + } + + // 2) Legacy base64 contact picture (used until migration completes or for fallback). if (!string.IsNullOrWhiteSpace(snapshot.Base64Picture)) { var localBitmap = await CreateBitmapFromBase64Async(snapshot.Base64Picture, cancellationToken).ConfigureAwait(false); @@ -198,7 +219,7 @@ public sealed partial class ImagePreviewControl : PersonPicture } } - // 2) Gravatar lookup through thumbnail service (if enabled). + // 3) Gravatar lookup through thumbnail service (if enabled). if (_preferencesService?.IsGravatarEnabled == true && _thumbnailService != null && !string.IsNullOrWhiteSpace(snapshot.Address) && @@ -219,7 +240,7 @@ public sealed partial class ImagePreviewControl : PersonPicture } } - // 3) Initials fallback is already in place via DisplayName + ProfilePicture = null. + // 4) Initials fallback is already in place via DisplayName + ProfilePicture = null. } catch (OperationCanceledException) { @@ -242,8 +263,9 @@ public sealed partial class ImagePreviewControl : PersonPicture var address = ResolveAddress(); var displayName = ResolveDisplayName(address); var base64Picture = ResolveBase64Picture(); + var contactPictureFileId = MailItemInformation?.SenderContact?.ContactPictureFileId; - return new RefreshSnapshot(displayName, address, base64Picture); + return new RefreshSnapshot(displayName, address, contactPictureFileId, base64Picture); }).ConfigureAwait(false); } @@ -373,6 +395,31 @@ public sealed partial class ImagePreviewControl : PersonPicture return await completion.Task.ConfigureAwait(false); } + private async Task CreateBitmapFromFileAsync(string filePath, CancellationToken cancellationToken) + { + byte[] bytes; + try + { + bytes = await File.ReadAllBytesAsync(filePath, cancellationToken).ConfigureAwait(false); + } + catch + { + return null; + } + + cancellationToken.ThrowIfCancellationRequested(); + + return await ExecuteOnUiThreadAsync(() => + { + cancellationToken.ThrowIfCancellationRequested(); + + using var memoryStream = new MemoryStream(bytes); + var bitmapImage = new BitmapImage(); + bitmapImage.SetSource(memoryStream.AsRandomAccessStream()); + return bitmapImage; + }).ConfigureAwait(false); + } + private async Task CreateBitmapFromBase64Async(string base64, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(base64)) diff --git a/Wino.Mail.WinUI/Dialogs/ContactEditDialog.xaml.cs b/Wino.Mail.WinUI/Dialogs/ContactEditDialog.xaml.cs index 363f5bb0..b5cb0442 100644 --- a/Wino.Mail.WinUI/Dialogs/ContactEditDialog.xaml.cs +++ b/Wino.Mail.WinUI/Dialogs/ContactEditDialog.xaml.cs @@ -1,11 +1,13 @@ using System; using System.IO; +using Microsoft.Extensions.DependencyInjection; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media.Imaging; using Wino.Core.Domain; using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Interfaces; +using Wino.Mail.WinUI; namespace Wino.Dialogs; @@ -13,6 +15,7 @@ public sealed partial class ContactEditDialog : ContentDialog { private AccountContact _contact; private IDialogServiceBase? _dialogService; + private IContactPictureFileService? _contactPictureFileService; private bool _isEditMode; public AccountContact Contact => _contact; @@ -23,6 +26,7 @@ public sealed partial class ContactEditDialog : ContentDialog _contact = contact ?? new AccountContact(); _dialogService = dialogService; + _contactPictureFileService = App.Current.Services.GetService(); _isEditMode = contact != null && !string.IsNullOrEmpty(contact.Address); Title = _isEditMode ? Translator.ContactEditDialog_Title : Translator.ContactEditDialog_AddTitle; @@ -47,10 +51,19 @@ public sealed partial class ContactEditDialog : ContentDialog if (_contact.IsOverridden) OverriddenContactInfoBorder.Visibility = Visibility.Visible; - // Load existing photo. - if (!string.IsNullOrEmpty(_contact.Base64ContactPicture)) + // Load existing photo — prefer file-based picture, fall back to legacy base64. + if (_contact.ContactPictureFileId.HasValue && _contactPictureFileService != null) { - LoadContactPhoto(_contact.Base64ContactPicture); + var filePath = _contactPictureFileService.GetContactPicturePath(_contact.ContactPictureFileId.Value); + if (!string.IsNullOrEmpty(filePath)) + { + LoadContactPhotoFromFile(filePath); + RemovePhotoButton.Visibility = Visibility.Visible; + } + } + else if (!string.IsNullOrEmpty(_contact.Base64ContactPicture)) + { + LoadContactPhotoFromBase64(_contact.Base64ContactPicture); RemovePhotoButton.Visibility = Visibility.Visible; } else @@ -72,9 +85,27 @@ public sealed partial class ContactEditDialog : ContentDialog if (files?.Count > 0) { var file = files[0]; - var base64 = Convert.ToBase64String(file.Data); - _contact.Base64ContactPicture = base64; - LoadContactPhoto(base64); + + if (_contactPictureFileService != null) + { + // Delete existing file if replacing. + if (_contact.ContactPictureFileId.HasValue) + await _contactPictureFileService.DeleteContactPictureAsync(_contact.ContactPictureFileId.Value); + + var fileId = await _contactPictureFileService.SaveContactPictureAsync(file.Data); + _contact.ContactPictureFileId = fileId; + + var filePath = _contactPictureFileService.GetContactPicturePath(fileId); + if (!string.IsNullOrEmpty(filePath)) + LoadContactPhotoFromFile(filePath); + } + else + { + // Fallback to legacy base64 when service is unavailable (e.g. design-time). + _contact.Base64ContactPicture = Convert.ToBase64String(file.Data); + LoadContactPhotoFromBase64(_contact.Base64ContactPicture); + } + RemovePhotoButton.Visibility = Visibility.Visible; } } @@ -86,13 +117,33 @@ public sealed partial class ContactEditDialog : ContentDialog private void RemovePhotoClicked(object sender, RoutedEventArgs e) { + if (_contact.ContactPictureFileId.HasValue && _contactPictureFileService != null) + _ = _contactPictureFileService.DeleteContactPictureAsync(_contact.ContactPictureFileId.Value); + + _contact.ContactPictureFileId = null; _contact.Base64ContactPicture = null; ContactPhotoPersonPicture.ProfilePicture = null; ContactPhotoPersonPicture.DisplayName = ContactNameTextBox.Text; RemovePhotoButton.Visibility = Visibility.Collapsed; } - private void LoadContactPhoto(string base64String) + private void LoadContactPhotoFromFile(string filePath) + { + try + { + var bytes = File.ReadAllBytes(filePath); + using var stream = new MemoryStream(bytes); + var bitmap = new BitmapImage(); + bitmap.SetSource(stream.AsRandomAccessStream()); + ContactPhotoPersonPicture.ProfilePicture = bitmap; + } + catch + { + // Failed to load image, ignore. + } + } + + private void LoadContactPhotoFromBase64(string base64String) { try { diff --git a/Wino.Mail.WinUI/Wino.Mail.WinUI.csproj b/Wino.Mail.WinUI/Wino.Mail.WinUI.csproj index 1c7628cf..69aaa56e 100644 --- a/Wino.Mail.WinUI/Wino.Mail.WinUI.csproj +++ b/Wino.Mail.WinUI/Wino.Mail.WinUI.csproj @@ -141,6 +141,7 @@ + diff --git a/Wino.Services/ContactPictureFileService.cs b/Wino.Services/ContactPictureFileService.cs new file mode 100644 index 00000000..99e1eb9f --- /dev/null +++ b/Wino.Services/ContactPictureFileService.cs @@ -0,0 +1,88 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Serilog; +using Wino.Core.Domain.Entities.Shared; +using Wino.Core.Domain.Interfaces; + +namespace Wino.Services; + +/// +/// Stores contact pictures as JPEG files under {ApplicationDataFolderPath}/contacts/{fileId}.jpg. +/// This avoids base64 inline storage in SQLite that bloats all AccountContact queries. +/// +public class ContactPictureFileService : BaseDatabaseService, IContactPictureFileService +{ + private const string ContactsSubFolder = "contacts"; + + private readonly string _contactPicturesFolder; + private readonly ILogger _logger = Log.ForContext(); + + public ContactPictureFileService(IDatabaseService databaseService, IApplicationConfiguration applicationConfiguration) + : base(databaseService) + { + _contactPicturesFolder = Path.Combine(applicationConfiguration.ApplicationDataFolderPath, ContactsSubFolder); + Directory.CreateDirectory(_contactPicturesFolder); + } + + public string GetContactPicturePath(Guid fileId) + { + var path = BuildFilePath(fileId); + return File.Exists(path) ? path : null; + } + + public async Task SaveContactPictureAsync(byte[] imageData) + { + var fileId = Guid.NewGuid(); + var filePath = BuildFilePath(fileId); + await File.WriteAllBytesAsync(filePath, imageData).ConfigureAwait(false); + return fileId; + } + + public Task DeleteContactPictureAsync(Guid fileId) + { + var filePath = BuildFilePath(fileId); + if (File.Exists(filePath)) + File.Delete(filePath); + return Task.CompletedTask; + } + + public async Task MigrateBase64PicturesAsync() + { + try + { + var contacts = await Connection + .QueryAsync( + "SELECT * FROM AccountContact WHERE Base64ContactPicture IS NOT NULL AND ContactPictureFileId IS NULL") + .ConfigureAwait(false); + + foreach (var contact in contacts) + { + try + { + var base64 = contact.Base64ContactPicture; + if (string.IsNullOrEmpty(base64)) + continue; + + var bytes = Convert.FromBase64String(base64); + var fileId = await SaveContactPictureAsync(bytes).ConfigureAwait(false); + + contact.ContactPictureFileId = fileId; + contact.Base64ContactPicture = null; + + await Connection.UpdateAsync(contact, typeof(AccountContact)).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to migrate Base64ContactPicture for contact {Address}.", contact.Address); + } + } + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to migrate contact pictures from base64 to file system."); + } + } + + private string BuildFilePath(Guid fileId) => Path.Combine(_contactPicturesFolder, $"{fileId}.jpg"); +} diff --git a/Wino.Services/ContactService.cs b/Wino.Services/ContactService.cs index 7bb0094a..3ab4e893 100644 --- a/Wino.Services/ContactService.cs +++ b/Wino.Services/ContactService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -13,6 +14,14 @@ namespace Wino.Services; public class ContactService : BaseDatabaseService, IContactService { + /// + /// In-memory contact cache keyed by e-mail address (case-insensitive). + /// Eliminates per-mail DB round-trips during bulk mail list loads. + /// Entries are added on fetch and invalidated on update/delete. + /// + private readonly ConcurrentDictionary _cache + = new(StringComparer.OrdinalIgnoreCase); + public ContactService(IDatabaseService databaseService) : base(databaseService) { } public async Task CreateNewContactAsync(string address, string displayName) @@ -21,6 +30,7 @@ public class ContactService : BaseDatabaseService, IContactService await Connection.InsertAsync(contact, typeof(AccountContact)).ConfigureAwait(false); + _cache[address] = contact; return contact; } @@ -34,25 +44,61 @@ public class ContactService : BaseDatabaseService, IContactService return Connection.QueryAsync(query, pattern, pattern); } - public Task GetAddressInformationByAddressAsync(string address) - => Connection.Table().FirstOrDefaultAsync(a => a.Address == address); - - public Task> GetContactsByAddressesAsync(IEnumerable addresses) + public async Task GetAddressInformationByAddressAsync(string address) { - var addressList = addresses?.Where(a => !string.IsNullOrEmpty(a)).Distinct().ToList(); - if (addressList == null || addressList.Count == 0) - return Task.FromResult(new List()); + if (string.IsNullOrEmpty(address)) + return null; - var placeholders = string.Join(",", addressList.Select(_ => "?")); - return Connection.QueryAsync( - $"SELECT * FROM AccountContact WHERE Address IN ({placeholders})", - addressList.Cast().ToArray()); + if (_cache.TryGetValue(address, out var cached)) + return cached; + + var contact = await Connection.Table().FirstOrDefaultAsync(a => a.Address == address).ConfigureAwait(false); + + if (contact != null) + _cache[contact.Address] = contact; + + return contact; + } + + public async Task> GetContactsByAddressesAsync(IEnumerable addresses) + { + var addressList = addresses?.Where(a => !string.IsNullOrEmpty(a)).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + if (addressList == null || addressList.Count == 0) + return new List(); + + var result = new List(addressList.Count); + var missing = new List(); + + foreach (var addr in addressList) + { + if (_cache.TryGetValue(addr, out var cached)) + result.Add(cached); + else + missing.Add(addr); + } + + if (missing.Count > 0) + { + var placeholders = string.Join(",", missing.Select(_ => "?")); + var fromDb = await Connection.QueryAsync( + $"SELECT * FROM AccountContact WHERE Address IN ({placeholders})", + missing.Cast().ToArray()).ConfigureAwait(false); + + foreach (var contact in fromDb) + { + _cache[contact.Address] = contact; + result.Add(contact); + } + } + + return result; } public async Task SaveAddressInformationAsync(MimeMessage message) { if (message == null) return; + // Save all individual contacts (GetRecipients expands GroupAddress members automatically). var contacts = message .GetRecipients(true) .Where(a => !string.IsNullOrWhiteSpace(a.Address)) @@ -63,6 +109,72 @@ public class ContactService : BaseDatabaseService, IContactService }); await SaveAddressInformationInternalAsync(contacts).ConfigureAwait(false); + + // Persist named RFC 2822 group structure (e.g. "Team Alpha: alice@x.com, bob@x.com;"). + await SaveGroupsFromInternetAddressesAsync(message.To, message.Cc, message.Bcc).ConfigureAwait(false); + } + + /// + /// Detects entries in the supplied address lists and upserts + /// corresponding and rows. + /// Individual member contacts are expected to already be saved by the caller. + /// + private async Task SaveGroupsFromInternetAddressesAsync(params InternetAddressList[] addressLists) + { + foreach (var list in addressLists) + { + if (list == null) continue; + + foreach (var address in list) + { + if (address is not GroupAddress group) continue; + var groupName = group.Name?.Trim(); + if (!ShouldPersistGroupName(groupName)) continue; + + var memberAddresses = group.Members + .OfType() + .Where(m => !string.IsNullOrWhiteSpace(m.Address)) + .Select(m => new { Address = m.Address.Trim(), m.Name }) + .Where(m => ShouldPersistAutoCollectedContact(m.Address, m.Name)) + .Select(m => m.Address) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (memberAddresses.Count == 0) continue; + + var contactGroup = await GetOrCreateGroupByNameAsync(groupName!).ConfigureAwait(false); + + // Fetch current members once to avoid duplicate inserts. + var existingMembers = await Connection.QueryAsync( + "SELECT * FROM ContactGroupMember WHERE GroupId = ?", contactGroup.Id + ).ConfigureAwait(false); + + var existingAddresses = new HashSet( + existingMembers.Select(m => m.MemberAddress), + StringComparer.OrdinalIgnoreCase + ); + + foreach (var memberAddress in memberAddresses) + { + if (!existingAddresses.Contains(memberAddress)) + await AddGroupMemberAsync(contactGroup.Id, memberAddress).ConfigureAwait(false); + } + } + } + } + + /// + /// Returns the with the given name, creating it if it does not exist. + /// + private async Task GetOrCreateGroupByNameAsync(string name) + { + var existing = await Connection.QueryAsync( + "SELECT * FROM ContactGroup WHERE Name = ? LIMIT 1", name + ).ConfigureAwait(false); + + return existing.Count > 0 + ? existing[0] + : await CreateGroupAsync(name).ConfigureAwait(false); } public async Task SaveAddressInformationAsync(IEnumerable contacts) @@ -74,7 +186,7 @@ public class ContactService : BaseDatabaseService, IContactService private async Task SaveAddressInformationInternalAsync(IEnumerable contacts) { - var addressInformations = contacts + var normalizedContacts = contacts .Where(a => a != null && !string.IsNullOrWhiteSpace(a.Address)) .Select(a => new AccountContact { @@ -85,10 +197,25 @@ public class ContactService : BaseDatabaseService, IContactService .Select(g => g.First()) .ToList(); - if (addressInformations.Count == 0) return; + if (normalizedContacts.Count == 0) return; try { + var noiseAddresses = normalizedContacts + .Where(a => !ShouldPersistAutoCollectedContact(a.Address, a.Name)) + .Select(a => a.Address) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (noiseAddresses.Count > 0) + await DeleteAutoCapturedContactsAsync(noiseAddresses).ConfigureAwait(false); + + var addressInformations = normalizedContacts + .Where(a => ShouldPersistAutoCollectedContact(a.Address, a.Name)) + .ToList(); + + if (addressInformations.Count == 0) return; + // Batch-fetch all existing contacts in one query. var addresses = addressInformations.Select(a => a.Address).ToList(); var placeholders = string.Join(",", addresses.Select((_, i) => "?")); @@ -130,6 +257,12 @@ public class ContactService : BaseDatabaseService, IContactService foreach (var contact in toUpdate) conn.Update(contact, typeof(AccountContact)); }).ConfigureAwait(false); + + // Update cache for inserted and updated contacts. + foreach (var c in toInsert) + _cache[c.Address] = c; + foreach (var c in toUpdate) + _cache[c.Address] = c; } } catch (Exception ex) @@ -138,6 +271,108 @@ public class ContactService : BaseDatabaseService, IContactService } } + private async Task DeleteAutoCapturedContactsAsync(IReadOnlyList addresses) + { + if (addresses == null || addresses.Count == 0) return; + + var placeholders = string.Join(",", addresses.Select(_ => "?")); + await Connection.ExecuteAsync( + $"DELETE FROM AccountContact WHERE Address IN ({placeholders}) AND IsRootContact = 0 AND IsOverridden = 0", + addresses.Cast().ToArray() + ).ConfigureAwait(false); + + foreach (var address in addresses) + _cache.TryRemove(address, out _); + } + + private static bool ShouldPersistAutoCollectedContact(string address, string displayName) + { + if (!TryGetLocalPart(address, out var localPart)) + return false; + + var localPartLower = localPart.ToLowerInvariant(); + + // High confidence machine-generated senders/recipients that should not pollute the contact list. + if (localPartLower.StartsWith("reply+", StringComparison.Ordinal) || + localPartLower.Contains("noreply", StringComparison.Ordinal) || + localPartLower.Contains("no-reply", StringComparison.Ordinal) || + localPartLower.Contains("donotreply", StringComparison.Ordinal) || + localPartLower.Contains("do-not-reply", StringComparison.Ordinal) || + localPartLower == "mailer-daemon" || + localPartLower == "postmaster") + { + return false; + } + + // Generic notification mailboxes are only persisted when they look human-assigned. + if (localPartLower is "notification" or "notifications" or "updates" or "digest") + return !IsLikelyMachineGeneratedDisplayName(displayName); + + return true; + } + + private static bool ShouldPersistGroupName(string groupName) + { + if (string.IsNullOrWhiteSpace(groupName)) return false; + + var trimmed = groupName.Trim(); + var lower = trimmed.ToLowerInvariant(); + + if (lower.Contains("issue #", StringComparison.Ordinal) || + lower.Contains("pull request #", StringComparison.Ordinal) || + lower.Contains("discussion #", StringComparison.Ordinal) || + lower.Contains("notification", StringComparison.Ordinal)) + { + return false; + } + + // GitHub-like dynamic repository labels: [owner/repository] + if (trimmed.StartsWith("[", StringComparison.Ordinal) && + trimmed.Contains('/') && + trimmed.Contains("]")) + { + return false; + } + + return true; + } + + private static bool IsLikelyMachineGeneratedDisplayName(string displayName) + { + if (string.IsNullOrWhiteSpace(displayName)) return false; + + var trimmed = displayName.Trim(); + var lower = trimmed.ToLowerInvariant(); + + if (lower.Contains("notification", StringComparison.Ordinal) || + lower.Contains("issue #", StringComparison.Ordinal) || + lower.Contains("pull request #", StringComparison.Ordinal) || + lower.Contains("discussion #", StringComparison.Ordinal)) + { + return true; + } + + return trimmed.StartsWith("[", StringComparison.Ordinal) && + trimmed.Contains('/') && + trimmed.Contains("]"); + } + + private static bool TryGetLocalPart(string address, out string localPart) + { + localPart = string.Empty; + + if (string.IsNullOrWhiteSpace(address)) + return false; + + var trimmed = address.Trim(); + var atIndex = trimmed.LastIndexOf('@'); + if (atIndex <= 0 || atIndex == trimmed.Length - 1) + return false; + + localPart = trimmed[..atIndex]; + return !string.IsNullOrWhiteSpace(localPart); + } + public Task> GetAllContactsAsync() { return Connection.Table().ToListAsync(); @@ -201,6 +436,7 @@ public class ContactService : BaseDatabaseService, IContactService await Connection.UpdateAsync(contact, typeof(AccountContact)).ConfigureAwait(false); + _cache[contact.Address] = contact; return contact; } @@ -211,6 +447,7 @@ public class ContactService : BaseDatabaseService, IContactService if (contact != null && !contact.IsRootContact) { await Connection.DeleteAsync(contact.Address).ConfigureAwait(false); + _cache.TryRemove(address, out _); } } @@ -224,5 +461,56 @@ public class ContactService : BaseDatabaseService, IContactService $"DELETE FROM AccountContact WHERE Address IN ({placeholders}) AND IsRootContact = 0", addressList.Cast().ToArray() ).ConfigureAwait(false); + + foreach (var addr in addressList) + _cache.TryRemove(addr, out _); } + + #region Group / Distribution List + + public Task> GetGroupsAsync() + => Connection.Table().OrderBy(g => g.Name).ToListAsync(); + + public async Task CreateGroupAsync(string name, string description = null) + { + var group = new ContactGroup { Id = Guid.NewGuid(), Name = name, Description = description }; + await Connection.InsertAsync(group, typeof(ContactGroup)).ConfigureAwait(false); + return group; + } + + public async Task DeleteGroupAsync(Guid groupId) + { + // Remove members first to avoid orphaned rows. + await Connection.ExecuteAsync( + "DELETE FROM ContactGroupMember WHERE GroupId = ?", groupId).ConfigureAwait(false); + await Connection.DeleteAsync(groupId).ConfigureAwait(false); + } + + public async Task> GetGroupMembersAsync(Guid groupId) + { + var members = await Connection.QueryAsync( + "SELECT * FROM ContactGroupMember WHERE GroupId = ?", groupId).ConfigureAwait(false); + + var addresses = members.Select(m => m.MemberAddress).ToList(); + return await GetContactsByAddressesAsync(addresses).ConfigureAwait(false); + } + + public async Task AddGroupMemberAsync(Guid groupId, string memberAddress) + { + var member = new ContactGroupMember { GroupId = groupId, MemberAddress = memberAddress }; + await Connection.InsertAsync(member, typeof(ContactGroupMember)).ConfigureAwait(false); + } + + public async Task RemoveGroupMemberAsync(Guid groupId, string memberAddress) + { + await Connection.ExecuteAsync( + "DELETE FROM ContactGroupMember WHERE GroupId = ? AND MemberAddress = ?", + groupId, memberAddress).ConfigureAwait(false); + } + + public Task> ExpandGroupAsync(Guid groupId) + => GetGroupMembersAsync(groupId); + + #endregion } + diff --git a/Wino.Services/DatabaseService.cs b/Wino.Services/DatabaseService.cs index fe4a40f7..f9981192 100644 --- a/Wino.Services/DatabaseService.cs +++ b/Wino.Services/DatabaseService.cs @@ -51,6 +51,8 @@ public class DatabaseService : IDatabaseService Connection.CreateTableAsync(), Connection.CreateTableAsync(), Connection.CreateTableAsync(), + Connection.CreateTableAsync(), + Connection.CreateTableAsync(), Connection.CreateTableAsync(), Connection.CreateTableAsync(), Connection.CreateTableAsync(), @@ -117,6 +119,15 @@ public class DatabaseService : IDatabaseService .ExecuteAsync($"ALTER TABLE {nameof(CustomServerInformation)} ADD COLUMN {nameof(CustomServerInformation.CalendarSupportMode)} INTEGER NOT NULL DEFAULT 0") .ConfigureAwait(false); } + + var contactColumns = await Connection.GetTableInfoAsync(nameof(AccountContact)).ConfigureAwait(false); + + if (!contactColumns.Any(c => c.Name == nameof(AccountContact.ContactPictureFileId))) + { + await Connection + .ExecuteAsync($"ALTER TABLE {nameof(AccountContact)} ADD COLUMN {nameof(AccountContact.ContactPictureFileId)} TEXT NULL") + .ConfigureAwait(false); + } } private async Task EnsureIndexesAsync() diff --git a/Wino.Services/ServicesContainerSetup.cs b/Wino.Services/ServicesContainerSetup.cs index dc0588e0..054a0e5f 100644 --- a/Wino.Services/ServicesContainerSetup.cs +++ b/Wino.Services/ServicesContainerSetup.cs @@ -26,6 +26,8 @@ public static class ServicesContainerSetup services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddSingleton(); + services.AddTransient(); } }