Contacts management.
This commit is contained in:
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user