using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using MimeKit; using Serilog; using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Contacts; using Wino.Services.Extensions; 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) { var contact = new AccountContact() { Address = address, Name = displayName }; await Connection.InsertAsync(contact, typeof(AccountContact)).ConfigureAwait(false); _cache[address] = contact; return contact; } public Task> GetAddressInformationAsync(string queryText) { if (queryText == null || queryText.Length < 2) return Task.FromResult>(null); const string query = "SELECT * FROM AccountContact WHERE Address LIKE ? OR Name LIKE ?"; var pattern = $"%{queryText}%"; return Connection.QueryAsync(query, pattern, pattern); } public async Task GetAddressInformationByAddressAsync(string address) { if (string.IsNullOrEmpty(address)) return null; 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)) .Select(a => new AccountContact { Name = string.IsNullOrWhiteSpace(a.Name) ? a.Address : a.Name, Address = a.Address }); 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) { if (contacts == null) return; await SaveAddressInformationInternalAsync(contacts).ConfigureAwait(false); } private async Task SaveAddressInformationInternalAsync(IEnumerable contacts) { var normalizedContacts = contacts .Where(a => a != null && !string.IsNullOrWhiteSpace(a.Address)) .Select(a => new AccountContact { Address = a.Address.Trim(), Name = string.IsNullOrWhiteSpace(a.Name) ? a.Address.Trim() : a.Name.Trim() }) .GroupBy(a => a.Address, StringComparer.OrdinalIgnoreCase) .Select(g => g.First()) .ToList(); 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) => "?")); var existingContacts = await Connection.QueryAsync( $"SELECT * FROM AccountContact WHERE Address IN ({placeholders})", addresses.Cast().ToArray() ).ConfigureAwait(false); var existingLookup = existingContacts.ToDictionary(c => c.Address, StringComparer.OrdinalIgnoreCase); var toInsert = new List(); var toUpdate = new List(); foreach (var info in addressInformations) { if (!existingLookup.TryGetValue(info.Address, out var existing)) { toInsert.Add(info); } else if (!existing.IsRootContact && !existing.IsOverridden) { // Only update if the new name is more informative (not just the email address) // and actually different from the current name. if (info.Name != info.Address && existing.Name != info.Name) { existing.Name = info.Name; toUpdate.Add(existing); } } } if (toInsert.Count > 0 || toUpdate.Count > 0) { await Connection.RunInTransactionAsync(conn => { if (toInsert.Count > 0) conn.InsertAll(toInsert, typeof(AccountContact)); 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) { Log.Error(ex, "Failed to batch save contact information to the database."); } } 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(); } public Task> SearchContactsAsync(string searchQuery) { if (string.IsNullOrWhiteSpace(searchQuery)) return GetAllContactsAsync(); const string query = "SELECT * FROM AccountContact WHERE Address LIKE ? OR Name LIKE ?"; var pattern = $"%{searchQuery.Trim()}%"; return Connection.QueryAsync(query, pattern, pattern); } public async Task GetContactsPageAsync(int offset, int pageSize, string searchQuery = null, bool excludeRootContacts = false) { offset = Math.Max(0, offset); pageSize = Math.Max(1, pageSize); var whereClauses = new List(); var parameters = new List(); if (excludeRootContacts) { whereClauses.Add("IsRootContact = 0"); } if (!string.IsNullOrWhiteSpace(searchQuery)) { var pattern = $"%{searchQuery.Trim()}%"; whereClauses.Add("(Address LIKE ? OR Name LIKE ?)"); parameters.Add(pattern); parameters.Add(pattern); } var whereSql = whereClauses.Count > 0 ? $" WHERE {string.Join(" AND ", whereClauses)}" : string.Empty; var countQuery = $"SELECT COUNT(*) FROM AccountContact{whereSql}"; var totalCount = await Connection.ExecuteScalarAsync(countQuery, parameters.ToArray()).ConfigureAwait(false); var pageParameters = new List(parameters) { pageSize, offset }; var pageQuery = $"SELECT * FROM AccountContact{whereSql} ORDER BY COALESCE(Name, Address) COLLATE NOCASE, Address COLLATE NOCASE LIMIT ? OFFSET ?"; var contacts = await Connection.QueryAsync(pageQuery, pageParameters.ToArray()).ConfigureAwait(false); var hasMore = offset + contacts.Count < totalCount; return new PagedContactsResult(contacts, totalCount, hasMore, offset, pageSize); } public async Task UpdateContactAsync(AccountContact contact) { // Mark the contact as overridden when manually updated contact.IsOverridden = true; await Connection.UpdateAsync(contact, typeof(AccountContact)).ConfigureAwait(false); _cache[contact.Address] = contact; return contact; } public async Task DeleteContactAsync(string address) { var contact = await GetAddressInformationByAddressAsync(address).ConfigureAwait(false); if (contact != null && !contact.IsRootContact) { await Connection.DeleteAsync(contact.Address).ConfigureAwait(false); _cache.TryRemove(address, out _); } } public async Task DeleteContactsAsync(IEnumerable addresses) { var addressList = addresses.Where(a => !string.IsNullOrEmpty(a)).ToList(); if (addressList.Count == 0) return; var placeholders = string.Join(",", addressList.Select((_, i) => "?")); await Connection.ExecuteAsync( $"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 }