Contacts management.

This commit is contained in:
Burak Kaan Köse
2026-03-01 21:07:10 +01:00
parent bdd32786d6
commit e816e87f61
19 changed files with 855 additions and 32 deletions
@@ -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;
/// <summary>
/// Stores contact pictures as JPEG files under {ApplicationDataFolderPath}/contacts/{fileId}.jpg.
/// This avoids base64 inline storage in SQLite that bloats all AccountContact queries.
/// </summary>
public class ContactPictureFileService : BaseDatabaseService, IContactPictureFileService
{
private const string ContactsSubFolder = "contacts";
private readonly string _contactPicturesFolder;
private readonly ILogger _logger = Log.ForContext<ContactPictureFileService>();
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<Guid> 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<AccountContact>(
"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");
}
+301 -13
View File
@@ -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
{
/// <summary>
/// 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.
/// </summary>
private readonly ConcurrentDictionary<string, AccountContact> _cache
= new(StringComparer.OrdinalIgnoreCase);
public ContactService(IDatabaseService databaseService) : base(databaseService) { }
public async Task<AccountContact> 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<AccountContact>(query, pattern, pattern);
}
public Task<AccountContact> GetAddressInformationByAddressAsync(string address)
=> Connection.Table<AccountContact>().FirstOrDefaultAsync(a => a.Address == address);
public Task<List<AccountContact>> GetContactsByAddressesAsync(IEnumerable<string> addresses)
public async Task<AccountContact> GetAddressInformationByAddressAsync(string address)
{
var addressList = addresses?.Where(a => !string.IsNullOrEmpty(a)).Distinct().ToList();
if (addressList == null || addressList.Count == 0)
return Task.FromResult(new List<AccountContact>());
if (string.IsNullOrEmpty(address))
return null;
var placeholders = string.Join(",", addressList.Select(_ => "?"));
return Connection.QueryAsync<AccountContact>(
$"SELECT * FROM AccountContact WHERE Address IN ({placeholders})",
addressList.Cast<object>().ToArray());
if (_cache.TryGetValue(address, out var cached))
return cached;
var contact = await Connection.Table<AccountContact>().FirstOrDefaultAsync(a => a.Address == address).ConfigureAwait(false);
if (contact != null)
_cache[contact.Address] = contact;
return contact;
}
public async Task<List<AccountContact>> GetContactsByAddressesAsync(IEnumerable<string> addresses)
{
var addressList = addresses?.Where(a => !string.IsNullOrEmpty(a)).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
if (addressList == null || addressList.Count == 0)
return new List<AccountContact>();
var result = new List<AccountContact>(addressList.Count);
var missing = new List<string>();
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<AccountContact>(
$"SELECT * FROM AccountContact WHERE Address IN ({placeholders})",
missing.Cast<object>().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);
}
/// <summary>
/// Detects <see cref="GroupAddress"/> entries in the supplied address lists and upserts
/// corresponding <see cref="ContactGroup"/> and <see cref="ContactGroupMember"/> rows.
/// Individual member contacts are expected to already be saved by the caller.
/// </summary>
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<MailboxAddress>()
.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<ContactGroupMember>(
"SELECT * FROM ContactGroupMember WHERE GroupId = ?", contactGroup.Id
).ConfigureAwait(false);
var existingAddresses = new HashSet<string>(
existingMembers.Select(m => m.MemberAddress),
StringComparer.OrdinalIgnoreCase
);
foreach (var memberAddress in memberAddresses)
{
if (!existingAddresses.Contains(memberAddress))
await AddGroupMemberAsync(contactGroup.Id, memberAddress).ConfigureAwait(false);
}
}
}
}
/// <summary>
/// Returns the <see cref="ContactGroup"/> with the given name, creating it if it does not exist.
/// </summary>
private async Task<ContactGroup> GetOrCreateGroupByNameAsync(string name)
{
var existing = await Connection.QueryAsync<ContactGroup>(
"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<AccountContact> contacts)
@@ -74,7 +186,7 @@ public class ContactService : BaseDatabaseService, IContactService
private async Task SaveAddressInformationInternalAsync(IEnumerable<AccountContact> 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<string> 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<object>().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<List<AccountContact>> GetAllContactsAsync()
{
return Connection.Table<AccountContact>().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<AccountContact>(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<object>().ToArray()
).ConfigureAwait(false);
foreach (var addr in addressList)
_cache.TryRemove(addr, out _);
}
#region Group / Distribution List
public Task<List<ContactGroup>> GetGroupsAsync()
=> Connection.Table<ContactGroup>().OrderBy(g => g.Name).ToListAsync();
public async Task<ContactGroup> 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<ContactGroup>(groupId).ConfigureAwait(false);
}
public async Task<List<AccountContact>> GetGroupMembersAsync(Guid groupId)
{
var members = await Connection.QueryAsync<ContactGroupMember>(
"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<List<AccountContact>> ExpandGroupAsync(Guid groupId)
=> GetGroupMembersAsync(groupId);
#endregion
}
+11
View File
@@ -51,6 +51,8 @@ public class DatabaseService : IDatabaseService
Connection.CreateTableAsync<MailItemFolder>(),
Connection.CreateTableAsync<MailAccount>(),
Connection.CreateTableAsync<AccountContact>(),
Connection.CreateTableAsync<ContactGroup>(),
Connection.CreateTableAsync<ContactGroupMember>(),
Connection.CreateTableAsync<CustomServerInformation>(),
Connection.CreateTableAsync<AccountSignature>(),
Connection.CreateTableAsync<MergedInbox>(),
@@ -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()
+2
View File
@@ -26,6 +26,8 @@ public static class ServicesContainerSetup
services.AddTransient<IContextMenuItemService, ContextMenuItemService>();
services.AddTransient<ISpecialImapProviderConfigResolver, SpecialImapProviderConfigResolver>();
services.AddTransient<IKeyboardShortcutService, KeyboardShortcutService>();
services.AddSingleton<IContactPictureFileService, ContactPictureFileService>();
services.AddTransient<ICalDavClient, CalDavClient>();
}
}