Files
Wino-Mail/Wino.Services/MailService.cs
T

1303 lines
51 KiB
C#
Raw Normal View History

2024-04-18 01:44:37 +02:00
using System;
2025-10-31 01:47:33 +01:00
using System.Collections.Concurrent;
2024-04-18 01:44:37 +02:00
using System.Collections.Generic;
using System.Linq;
2025-11-15 13:29:02 +01:00
using System.Text;
using System.Threading;
2024-04-18 01:44:37 +02:00
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
2024-04-18 01:44:37 +02:00
using MimeKit;
using Serilog;
using Wino.Core.Domain;
2024-11-10 23:28:25 +01:00
using Wino.Core.Domain.Entities.Mail;
using Wino.Core.Domain.Entities.Shared;
2024-04-18 01:44:37 +02:00
using Wino.Core.Domain.Enums;
2024-08-17 22:55:58 +02:00
using Wino.Core.Domain.Exceptions;
using Wino.Core.Domain.Extensions;
2024-04-18 01:44:37 +02:00
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.MailItem;
using Wino.Messaging.UI;
using Wino.Services.Extensions;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
namespace Wino.Services;
public class MailService : BaseDatabaseService, IMailService
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
private const int ItemLoadCount = 100;
private readonly IFolderService _folderService;
private readonly IContactService _contactService;
private readonly IAccountService _accountService;
private readonly ISignatureService _signatureService;
private readonly IMimeFileService _mimeFileService;
private readonly IPreferencesService _preferencesService;
private readonly ILogger _logger = Log.ForContext<MailService>();
public MailService(IDatabaseService databaseService,
IFolderService folderService,
IContactService contactService,
IAccountService accountService,
ISignatureService signatureService,
IMimeFileService mimeFileService,
IPreferencesService preferencesService) : base(databaseService)
2025-02-16 11:35:43 +01:00
{
2025-02-16 11:54:23 +01:00
_folderService = folderService;
_contactService = contactService;
_accountService = accountService;
_signatureService = signatureService;
_mimeFileService = mimeFileService;
_preferencesService = preferencesService;
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
public async Task<(MailCopy draftMailCopy, string draftBase64MimeMessage)> CreateDraftAsync(Guid accountId, DraftCreationOptions draftCreationOptions)
{
var composerAccount = await _accountService.GetAccountAsync(accountId).ConfigureAwait(false);
var createdDraftMimeMessage = await CreateDraftMimeAsync(composerAccount, draftCreationOptions);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
var draftFolder = await _folderService.GetSpecialFolderByAccountIdAsync(composerAccount.Id, SpecialFolderType.Draft);
2024-04-18 01:44:37 +02:00
if (draftFolder == null)
throw new UnavailableSpecialFolderException(SpecialFolderType.Draft, accountId);
2025-02-16 11:54:23 +01:00
// Get locally created unique id from the mime headers.
// This header will be used to map the local draft copy with the remote draft copy.
var mimeUniqueId = createdDraftMimeMessage.Headers[Constants.WinoLocalDraftHeader];
2025-02-16 11:43:30 +01:00
2025-02-16 11:54:23 +01:00
var primaryAlias = await _accountService.GetPrimaryAccountAliasAsync(accountId).ConfigureAwait(false);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
var copy = new MailCopy
{
UniqueId = Guid.Parse(mimeUniqueId),
Id = Guid.NewGuid().ToString(), // This will be replaced after network call with the remote draft id.
CreationDate = DateTime.UtcNow,
FromAddress = primaryAlias?.AliasAddress ?? composerAccount.Address,
FromName = composerAccount.SenderName,
HasAttachments = false,
Importance = MailImportance.Normal,
Subject = createdDraftMimeMessage.Subject,
PreviewText = createdDraftMimeMessage.TextBody,
IsRead = true,
IsDraft = true,
FolderId = draftFolder.Id,
DraftId = $"{Constants.LocalDraftStartPrefix}{Guid.NewGuid()}",
AssignedFolder = draftFolder,
AssignedAccount = composerAccount,
FileId = Guid.NewGuid()
};
2026-02-06 20:13:44 +01:00
// If replying, add In-Reply-To, ThreadId and References per RFC 5322.
// References must include all previous References + the Message-ID of the message being replied to.
2025-02-16 11:54:23 +01:00
if (draftCreationOptions.ReferencedMessage != null)
{
2026-02-06 20:13:44 +01:00
var refMime = draftCreationOptions.ReferencedMessage.MimeMessage;
var refs = new List<string>();
2024-04-18 01:44:37 +02:00
2026-02-06 20:13:44 +01:00
if (refMime.References != null)
refs.AddRange(refMime.References);
if (!string.IsNullOrEmpty(refMime.MessageId))
{
copy.InReplyTo = refMime.MessageId;
refs.Add(refMime.MessageId);
}
if (refs.Count > 0)
copy.References = string.Join(";", refs);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
if (!string.IsNullOrEmpty(draftCreationOptions.ReferencedMessage.MailCopy?.ThreadId))
copy.ThreadId = draftCreationOptions.ReferencedMessage.MailCopy.ThreadId;
}
2024-04-18 01:44:37 +02:00
2025-11-14 14:28:10 +01:00
await Connection.InsertAsync(copy, typeof(MailCopy));
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
await _mimeFileService.SaveMimeMessageAsync(copy.FileId, createdDraftMimeMessage, composerAccount.Id);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
ReportUIChange(new DraftCreated(copy, composerAccount));
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return (copy, createdDraftMimeMessage.GetBase64MimeMessage());
}
2025-02-16 11:54:23 +01:00
public async Task<List<MailCopy>> GetMailsByFolderIdAsync(Guid folderId)
{
var mails = await Connection.QueryAsync<MailCopy>("SELECT * FROM MailCopy WHERE FolderId = ?", folderId);
2025-02-16 11:54:23 +01:00
foreach (var mail in mails)
{
await LoadAssignedPropertiesAsync(mail).ConfigureAwait(false);
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return mails;
}
public async Task<bool> HasAccountAnyDraftAsync(Guid accountId)
{
// Get the draft folder.
var draftFolder = await _folderService.GetSpecialFolderByAccountIdAsync(accountId, SpecialFolderType.Draft);
if (draftFolder == null) return false;
var draftCount = await Connection.Table<MailCopy>().Where(a => a.FolderId == draftFolder.Id).CountAsync();
return draftCount > 0;
}
2025-02-16 11:54:23 +01:00
public async Task<List<MailCopy>> GetUnreadMailsByFolderIdAsync(Guid folderId)
{
var unreadMails = await Connection.QueryAsync<MailCopy>("SELECT * FROM MailCopy WHERE FolderId = ? AND IsRead = 0", folderId);
2025-02-16 11:54:23 +01:00
foreach (var mail in unreadMails)
{
await LoadAssignedPropertiesAsync(mail).ConfigureAwait(false);
2025-02-16 11:43:30 +01:00
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return unreadMails;
}
2025-11-15 13:29:02 +01:00
private static (string Query, object[] Parameters) BuildMailFetchQuery(MailListInitializationOptions options)
2025-02-16 11:54:23 +01:00
{
2025-11-15 13:29:02 +01:00
var sql = new StringBuilder();
sql.Append("SELECT MailCopy.* FROM MailCopy INNER JOIN MailItemFolder ON MailCopy.FolderId = MailItemFolder.Id");
2025-11-15 13:29:02 +01:00
var whereClauses = new List<string>();
var parameters = new List<object>();
2025-11-15 13:29:02 +01:00
// Folder filter
var folderPlaceholders = string.Join(",", options.Folders.Select(_ => "?"));
whereClauses.Add($"MailCopy.FolderId IN ({folderPlaceholders})");
parameters.AddRange(options.Folders.Select(f => (object)f.Id));
2025-11-15 13:29:02 +01:00
// Filter type
2025-02-16 11:54:23 +01:00
switch (options.FilterType)
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
case FilterOptionType.Unread:
2025-11-15 13:29:02 +01:00
whereClauses.Add("MailCopy.IsRead = 0");
2025-02-16 11:54:23 +01:00
break;
case FilterOptionType.Flagged:
2025-11-15 13:29:02 +01:00
whereClauses.Add("MailCopy.IsFlagged = 1");
2025-02-16 11:54:23 +01:00
break;
case FilterOptionType.Files:
2025-11-15 13:29:02 +01:00
whereClauses.Add("MailCopy.HasAttachments = 1");
2025-02-16 11:54:23 +01:00
break;
}
2025-11-15 13:29:02 +01:00
// Focused filter
2025-02-16 11:54:23 +01:00
if (options.IsFocusedOnly != null)
2025-11-15 13:29:02 +01:00
{
whereClauses.Add($"MailCopy.IsFocused = {(options.IsFocusedOnly.Value ? "1" : "0")}");
}
2025-11-15 13:29:02 +01:00
// Search query
2025-02-16 11:54:23 +01:00
if (!string.IsNullOrEmpty(options.SearchQuery))
2025-11-15 13:29:02 +01:00
{
whereClauses.Add("(MailCopy.PreviewText LIKE ? OR MailCopy.Subject LIKE ? OR MailCopy.FromName LIKE ? OR MailCopy.FromAddress LIKE ?)");
var searchPattern = $"%{options.SearchQuery}%";
parameters.Add(searchPattern);
parameters.Add(searchPattern);
parameters.Add(searchPattern);
parameters.Add(searchPattern);
}
2025-11-15 13:29:02 +01:00
// Exclude existing items
2025-02-16 11:54:23 +01:00
if (options.ExistingUniqueIds?.Any() ?? false)
2025-02-16 11:43:30 +01:00
{
2025-11-15 13:29:02 +01:00
var excludePlaceholders = string.Join(",", options.ExistingUniqueIds.Select(_ => "?"));
whereClauses.Add($"MailCopy.UniqueId NOT IN ({excludePlaceholders})");
parameters.AddRange(options.ExistingUniqueIds.Keys.Select(id => (object)id));
2025-02-16 11:54:23 +01:00
}
2025-11-15 13:29:02 +01:00
if (whereClauses.Any())
2025-10-18 11:45:10 +02:00
{
2025-11-15 13:29:02 +01:00
sql.Append(" WHERE ");
sql.Append(string.Join(" AND ", whereClauses));
2025-10-18 11:45:10 +02:00
}
2025-11-15 13:29:02 +01:00
// Sorting
if (options.SortingOptionType == SortingOptionType.ReceiveDate)
sql.Append(" ORDER BY CreationDate DESC");
else if (options.SortingOptionType == SortingOptionType.Sender)
sql.Append(" ORDER BY FromName ASC");
2025-11-15 13:29:02 +01:00
// Pagination
var limit = options.Take > 0 ? options.Take : ItemLoadCount;
sql.Append($" LIMIT {limit}");
2025-11-15 13:29:02 +01:00
if (options.Skip > 0)
2025-10-18 11:45:10 +02:00
{
2025-11-15 13:29:02 +01:00
sql.Append($" OFFSET {options.Skip}");
2025-10-18 11:45:10 +02:00
}
2025-11-15 13:29:02 +01:00
return (sql.ToString(), parameters.ToArray());
2025-02-16 11:54:23 +01:00
}
2024-04-18 01:44:37 +02:00
2026-02-12 18:57:55 +01:00
private static List<MailCopy> ApplyOptionsToPreFetchedMails(MailListInitializationOptions options)
{
var allowedFolderIds = options.Folders.Select(f => f.Id).ToHashSet();
IEnumerable<MailCopy> query = options.PreFetchMailCopies
.Where(m => m != null && allowedFolderIds.Contains(m.FolderId))
.GroupBy(m => m.UniqueId)
.Select(g => g.First());
switch (options.FilterType)
{
case FilterOptionType.Unread:
query = query.Where(m => !m.IsRead);
break;
case FilterOptionType.Flagged:
query = query.Where(m => m.IsFlagged);
break;
case FilterOptionType.Files:
query = query.Where(m => m.HasAttachments);
break;
}
if (options.IsFocusedOnly is bool isFocused)
{
query = query.Where(m => m.IsFocused == isFocused);
}
if (!string.IsNullOrWhiteSpace(options.SearchQuery))
{
var search = options.SearchQuery.Trim();
query = query.Where(m =>
(!string.IsNullOrEmpty(m.PreviewText) && m.PreviewText.Contains(search, StringComparison.OrdinalIgnoreCase))
|| (!string.IsNullOrEmpty(m.Subject) && m.Subject.Contains(search, StringComparison.OrdinalIgnoreCase))
|| (!string.IsNullOrEmpty(m.FromName) && m.FromName.Contains(search, StringComparison.OrdinalIgnoreCase))
|| (!string.IsNullOrEmpty(m.FromAddress) && m.FromAddress.Contains(search, StringComparison.OrdinalIgnoreCase)));
}
if (options.ExistingUniqueIds?.Any() ?? false)
{
query = query.Where(m => !options.ExistingUniqueIds.ContainsKey(m.UniqueId));
}
query = options.SortingOptionType switch
{
SortingOptionType.Sender => query.OrderBy(m => m.FromName).ThenByDescending(m => m.CreationDate),
_ => query.OrderByDescending(m => m.CreationDate)
};
if (options.Skip > 0)
{
query = query.Skip(options.Skip);
}
if (options.Take > 0)
{
query = query.Take(options.Take);
}
return query.ToList();
}
2025-10-03 15:46:38 +02:00
public async Task<List<MailCopy>> FetchMailsAsync(MailListInitializationOptions options, CancellationToken cancellationToken = default)
2025-02-16 11:54:23 +01:00
{
2025-02-22 00:22:00 +01:00
List<MailCopy> mails = null;
2024-04-18 01:44:37 +02:00
2025-02-22 00:22:00 +01:00
// If user performs an online search, mail copies are passed to options.
if (options.PreFetchMailCopies != null)
{
2026-02-12 18:57:55 +01:00
mails = ApplyOptionsToPreFetchedMails(options);
2025-02-22 00:22:00 +01:00
}
else
{
// If not just do the query.
2025-11-15 13:29:02 +01:00
var (query, parameters) = BuildMailFetchQuery(options);
mails = await Connection.QueryAsync<MailCopy>(query, parameters);
2025-02-22 00:22:00 +01:00
}
2024-04-18 01:44:37 +02:00
2025-10-31 01:47:33 +01:00
ConcurrentDictionary<Guid, MailItemFolder> folderCache = new();
ConcurrentDictionary<Guid, MailAccount> accountCache = new();
ConcurrentDictionary<string, AccountContact> contactCache = new();
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
// Populate Folder Assignment for each single mail, to be able later group by "MailAccountId".
// This is needed to execute threading strategy by account type.
// Avoid DBs calls as possible, storing info in a dictionary.
foreach (var mail in mails)
{
await LoadAssignedPropertiesWithCacheAsync(mail, folderCache, accountCache, contactCache).ConfigureAwait(false);
2025-02-16 11:54:23 +01:00
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
// Remove items that has no assigned account or folder.
mails.RemoveAll(a => a.AssignedAccount == null || a.AssignedFolder == null);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
cancellationToken.ThrowIfCancellationRequested();
2024-04-18 01:44:37 +02:00
2025-10-26 23:35:09 +01:00
// If CreateThreads is false, just return the mails as-is
if (!options.CreateThreads)
{
return [.. mails];
}
2025-10-31 01:41:51 +01:00
// Include other mails in the same threads - batch process to reduce DB calls
2025-10-26 23:35:09 +01:00
var expandedMails = new List<MailCopy>(mails);
2025-10-31 01:41:51 +01:00
var uniqueThreadIds = mails
.Where(m => !string.IsNullOrEmpty(m.ThreadId))
.Select(m => m.ThreadId)
.Distinct()
.ToList();
2025-10-26 23:35:09 +01:00
2025-10-31 01:41:51 +01:00
if (uniqueThreadIds.Count > 0)
2025-10-26 23:35:09 +01:00
{
2025-10-31 01:41:51 +01:00
// Get all thread mails in a single DB call
var existingMailIds = expandedMails.Select(m => m.Id).ToHashSet();
var allThreadMails = await GetMailsByThreadIdsAsync(uniqueThreadIds, existingMailIds).ConfigureAwait(false);
2025-10-26 23:35:09 +01:00
2025-10-31 01:41:51 +01:00
if (allThreadMails?.Count > 0)
{
// Process thread mails in parallel to improve performance
var tasks = allThreadMails.Select(async threadMail =>
2025-10-26 23:35:09 +01:00
{
2025-10-31 01:41:51 +01:00
await LoadAssignedPropertiesWithCacheAsync(threadMail, folderCache, accountCache, contactCache).ConfigureAwait(false);
return threadMail;
});
var processedThreadMails = await Task.WhenAll(tasks).ConfigureAwait(false);
2025-10-31 01:41:51 +01:00
// Filter out items with no assigned account or folder
var validThreadMails = processedThreadMails.Where(m => m.AssignedAccount != null && m.AssignedFolder != null);
2025-10-31 01:41:51 +01:00
expandedMails.AddRange(validThreadMails);
2025-10-26 23:35:09 +01:00
}
cancellationToken.ThrowIfCancellationRequested();
}
return [.. expandedMails];
}
private async Task<List<MailCopy>> GetMailsByThreadIdAsync(string threadId, HashSet<string> excludeMailIds)
{
if (string.IsNullOrEmpty(threadId))
return [];
2025-11-15 13:29:02 +01:00
var placeholders = string.Join(",", excludeMailIds.Select(_ => "?"));
var sql = $"SELECT MailCopy.* FROM MailCopy WHERE ThreadId = ? AND Id NOT IN ({placeholders})";
var parameters = new List<object> { threadId };
parameters.AddRange(excludeMailIds.Cast<object>());
2025-10-26 23:35:09 +01:00
2025-11-15 13:29:02 +01:00
return await Connection.QueryAsync<MailCopy>(sql, parameters.ToArray());
}
2024-04-18 01:44:37 +02:00
2025-10-31 01:41:51 +01:00
private async Task<List<MailCopy>> GetMailsByThreadIdsAsync(List<string> threadIds, HashSet<string> excludeMailIds)
{
if (threadIds?.Count == 0)
return [];
2025-11-15 13:29:02 +01:00
var threadPlaceholders = string.Join(",", threadIds.Select(_ => "?"));
var excludePlaceholders = string.Join(",", excludeMailIds.Select(_ => "?"));
var sql = $"SELECT MailCopy.* FROM MailCopy WHERE ThreadId IN ({threadPlaceholders}) AND Id NOT IN ({excludePlaceholders})";
var parameters = new List<object>();
parameters.AddRange(threadIds.Cast<object>());
parameters.AddRange(excludeMailIds.Cast<object>());
2025-10-31 01:41:51 +01:00
2025-11-15 13:29:02 +01:00
return await Connection.QueryAsync<MailCopy>(sql, parameters.ToArray()).ConfigureAwait(false);
2025-10-31 01:41:51 +01:00
}
/// <summary>
/// This method should used for operations with multiple mailItems. Don't use this for single mail items.
/// Called method should provide own instances for caches.
/// </summary>
2025-10-31 01:47:33 +01:00
private async Task LoadAssignedPropertiesWithCacheAsync(MailCopy mail, ConcurrentDictionary<Guid, MailItemFolder> folderCache, ConcurrentDictionary<Guid, MailAccount> accountCache, ConcurrentDictionary<string, AccountContact> contactCache)
{
if (mail is MailCopy mailCopy)
{
var isFolderCached = folderCache.TryGetValue(mailCopy.FolderId, out MailItemFolder folderAssignment);
MailAccount accountAssignment = null;
if (!isFolderCached)
2025-02-16 11:43:30 +01:00
{
folderAssignment = await _folderService.GetFolderAsync(mailCopy.FolderId).ConfigureAwait(false);
folderCache.TryAdd(mailCopy.FolderId, folderAssignment);
}
2025-02-16 11:54:23 +01:00
if (folderAssignment != null)
{
var isAccountCached = accountCache.TryGetValue(folderAssignment.MailAccountId, out accountAssignment);
if (!isAccountCached)
{
accountAssignment = await _accountService.GetAccountAsync(folderAssignment.MailAccountId).ConfigureAwait(false);
2024-04-18 01:44:37 +02:00
accountCache.TryAdd(folderAssignment.MailAccountId, accountAssignment);
2025-02-16 11:54:23 +01:00
}
}
AccountContact contactAssignment = null;
bool isContactCached = !string.IsNullOrEmpty(mailCopy.FromAddress) &&
contactCache.TryGetValue(mailCopy.FromAddress, out contactAssignment);
if (!isContactCached && accountAssignment != null)
{
contactAssignment = await GetSenderContactForAccountAsync(accountAssignment, mailCopy.FromAddress).ConfigureAwait(false);
2024-08-23 02:07:50 +02:00
if (contactAssignment != null)
2025-02-16 11:54:23 +01:00
{
contactCache.TryAdd(mailCopy.FromAddress, contactAssignment);
2025-02-16 11:43:30 +01:00
}
2024-04-18 01:44:37 +02:00
}
mailCopy.AssignedFolder = folderAssignment;
mailCopy.AssignedAccount = accountAssignment;
mailCopy.SenderContact = contactAssignment ?? CreateUnknownContact(mailCopy.FromName, mailCopy.FromAddress);
2024-04-18 01:44:37 +02:00
}
2025-02-16 11:54:23 +01:00
}
2024-04-18 01:44:37 +02:00
private static AccountContact CreateUnknownContact(string fromName, string fromAddress)
2025-02-16 11:54:23 +01:00
{
if (string.IsNullOrEmpty(fromName) && string.IsNullOrEmpty(fromAddress))
{
2025-02-16 11:54:23 +01:00
return new AccountContact()
{
2025-02-16 11:54:23 +01:00
Name = Translator.UnknownSender,
Address = Translator.UnknownAddress
};
2025-02-16 11:43:30 +01:00
}
2025-02-16 11:54:23 +01:00
else
{
2025-02-16 11:54:23 +01:00
if (string.IsNullOrEmpty(fromName)) fromName = fromAddress;
2025-02-16 11:54:23 +01:00
return new AccountContact()
2025-02-16 11:43:30 +01:00
{
2025-02-16 11:54:23 +01:00
Name = fromName,
Address = fromAddress
};
2025-02-16 11:35:43 +01:00
}
2025-02-16 11:54:23 +01:00
}
2025-02-16 11:43:30 +01:00
2025-02-16 11:54:23 +01:00
private async Task<List<MailCopy>> GetMailItemsAsync(string mailCopyId)
{
var mailCopies = await Connection.Table<MailCopy>().Where(a => a.Id == mailCopyId).ToListAsync();
foreach (var mailCopy in mailCopies)
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
await LoadAssignedPropertiesAsync(mailCopy).ConfigureAwait(false);
2025-02-16 11:35:43 +01:00
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return mailCopies;
}
private Task<AccountContact> GetSenderContactForAccountAsync(MailAccount account, string fromAddress)
{
// Make sure to return the latest up to date contact information for the original account.
if (fromAddress == account.Address)
{
return Task.FromResult(new AccountContact() { Address = account.Address, Name = account.SenderName, Base64ContactPicture = account.Base64ProfilePictureData });
}
else
2025-02-16 11:43:30 +01:00
{
2025-02-16 11:54:23 +01:00
return _contactService.GetAddressInformationByAddressAsync(fromAddress);
}
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
private async Task LoadAssignedPropertiesAsync(MailCopy mailCopy)
{
if (mailCopy == null) return;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
// Load AssignedAccount, AssignedFolder and SenderContact.
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
var folder = await _folderService.GetFolderAsync(mailCopy.FolderId);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
if (folder == null) return;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
var account = await _accountService.GetAccountAsync(folder.MailAccountId);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
if (account == null) return;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
mailCopy.AssignedAccount = account;
mailCopy.AssignedFolder = folder;
mailCopy.SenderContact = await GetSenderContactForAccountAsync(account, mailCopy.FromAddress).ConfigureAwait(false);
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
public async Task<MailCopy> GetSingleMailItemWithoutFolderAssignmentAsync(string mailCopyId)
{
var mailCopy = await Connection.Table<MailCopy>().FirstOrDefaultAsync(a => a.Id == mailCopyId);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
if (mailCopy == null) return null;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
await LoadAssignedPropertiesAsync(mailCopy).ConfigureAwait(false);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return mailCopy;
}
2024-04-18 01:44:37 +02:00
2025-02-22 00:22:00 +01:00
/// <summary>
/// Using this override is dangerous.
/// Gmail stores multiple copies of same mail in different folders.
/// This one will always return the first one. Use with caution.
/// </summary>
/// <param name="mailCopyId">Mail copy id.</param>
public async Task<MailCopy> GetSingleMailItemAsync(string mailCopyId)
{
2025-11-15 13:29:02 +01:00
var mailCopy = await Connection.FindWithQueryAsync<MailCopy>(
"SELECT MailCopy.* FROM MailCopy WHERE MailCopy.Id = ?",
mailCopyId);
2025-02-22 00:22:00 +01:00
if (mailCopy == null) return null;
await LoadAssignedPropertiesAsync(mailCopy).ConfigureAwait(false);
return mailCopy;
}
2025-02-16 11:54:23 +01:00
public async Task<MailCopy> GetSingleMailItemAsync(string mailCopyId, string remoteFolderId)
{
2025-11-15 13:29:02 +01:00
var mailItem = await Connection.FindWithQueryAsync<MailCopy>(
"SELECT MailCopy.* FROM MailCopy INNER JOIN MailItemFolder ON MailCopy.FolderId = MailItemFolder.Id WHERE MailCopy.Id = ? AND MailItemFolder.RemoteFolderId = ?",
mailCopyId, remoteFolderId);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
if (mailItem == null) return null;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
await LoadAssignedPropertiesAsync(mailItem).ConfigureAwait(false);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return mailItem;
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
public async Task<MailCopy> GetSingleMailItemAsync(Guid uniqueMailId)
{
var mailItem = await Connection.FindAsync<MailCopy>(uniqueMailId);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
if (mailItem == null) return null;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
await LoadAssignedPropertiesAsync(mailItem).ConfigureAwait(false);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return mailItem;
}
2024-08-29 23:58:39 +02:00
2025-02-16 11:54:23 +01:00
// v2
2025-02-16 11:35:43 +01:00
2025-02-16 11:54:23 +01:00
public async Task DeleteMailAsync(Guid accountId, string mailCopyId)
{
var allMails = await GetMailItemsAsync(mailCopyId).ConfigureAwait(false);
2025-02-16 11:43:30 +01:00
2025-02-16 11:54:23 +01:00
foreach (var mailItem in allMails)
{
// Delete mime file as well.
// Even though Gmail might have multiple copies for the same mail, we only have one MIME file for all.
// Their FileId is inserted same.
await DeleteMailInternalAsync(mailItem, preserveMimeFile: false).ConfigureAwait(false);
2024-04-18 01:44:37 +02:00
}
2025-02-16 11:54:23 +01:00
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
#region Repository Calls
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
private async Task InsertMailAsync(MailCopy mailCopy)
{
if (mailCopy == null)
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
_logger.Warning("Null mail passed to InsertMailAsync call.");
return;
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
if (mailCopy.FolderId == Guid.Empty)
{
_logger.Warning("Invalid FolderId for MailCopyId {Id} for InsertMailAsync", mailCopy.Id);
return;
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
_logger.Debug("Inserting mail {MailCopyId} to {FolderName}", mailCopy.Id, mailCopy.AssignedFolder.FolderName);
2024-04-18 01:44:37 +02:00
2025-11-14 14:28:10 +01:00
await Connection.InsertAsync(mailCopy, typeof(MailCopy)).ConfigureAwait(false);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
ReportUIChange(new MailAddedMessage(mailCopy));
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
public async Task UpdateMailAsync(MailCopy mailCopy)
{
if (mailCopy == null)
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
_logger.Warning("Null mail passed to UpdateMailAsync call.");
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return;
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
_logger.Debug("Updating mail {MailCopyId} with Folder {FolderId}", mailCopy.Id, mailCopy.FolderId);
2024-04-18 01:44:37 +02:00
2025-11-14 14:28:10 +01:00
await Connection.UpdateAsync(mailCopy, typeof(MailCopy)).ConfigureAwait(false);
2024-04-18 01:44:37 +02:00
2026-01-27 21:21:04 +01:00
ReportUIChange(new MailUpdatedMessage(mailCopy, MailUpdateSource.Server));
2025-02-16 11:54:23 +01:00
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
private async Task DeleteMailInternalAsync(MailCopy mailCopy, bool preserveMimeFile)
{
if (mailCopy == null)
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
_logger.Warning("Null mail passed to DeleteMailAsync call.");
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return;
}
2024-06-11 22:48:18 +02:00
2025-02-16 11:54:23 +01:00
_logger.Debug("Deleting mail {Id} from folder {FolderName}", mailCopy.Id, mailCopy.AssignedFolder.FolderName);
2024-06-11 22:48:18 +02:00
await Connection.DeleteAsync<MailCopy>(mailCopy.UniqueId).ConfigureAwait(false);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
// If there are no more copies exists of the same mail, delete the MIME file as well.
var isMailExists = await IsMailExistsAsync(mailCopy.Id).ConfigureAwait(false);
2025-02-16 11:35:43 +01:00
2025-02-16 11:54:23 +01:00
if (!isMailExists && !preserveMimeFile)
{
await _mimeFileService.DeleteMimeMessageAsync(mailCopy.AssignedAccount.Id, mailCopy.FileId).ConfigureAwait(false);
2025-02-16 11:43:30 +01:00
}
2025-02-16 11:35:43 +01:00
2025-02-16 11:54:23 +01:00
ReportUIChange(new MailRemovedMessage(mailCopy));
}
#endregion
private async Task UpdateAllMailCopiesAsync(string mailCopyId, Func<MailCopy, bool> action)
{
var mailCopies = await GetMailItemsAsync(mailCopyId);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
if (mailCopies == null || !mailCopies.Any())
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
_logger.Warning("Updating mail copies failed because there are no copies available with Id {MailCopyId}", mailCopyId);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return;
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
_logger.Debug("Updating {MailCopyCount} mail copies with Id {MailCopyId}", mailCopies.Count, mailCopyId);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
foreach (var mailCopy in mailCopies)
{
bool shouldUpdateItem = action(mailCopy);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
if (shouldUpdateItem)
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
await UpdateMailAsync(mailCopy).ConfigureAwait(false);
2024-04-18 01:44:37 +02:00
}
2025-02-16 11:54:23 +01:00
else
_logger.Debug("Skipped updating mail because it is already in the desired state.");
2024-04-18 01:44:37 +02:00
}
2025-02-16 11:54:23 +01:00
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
public Task ChangeReadStatusAsync(string mailCopyId, bool isRead)
=> UpdateAllMailCopiesAsync(mailCopyId, (item) =>
{
if (item.IsRead == isRead) return false;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
item.IsRead = isRead;
if (isRead && item.UniqueId != Guid.Empty)
{
WeakReferenceMessenger.Default.Send(new MailReadStatusChanged(item.UniqueId));
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return true;
});
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
public Task ChangeFlagStatusAsync(string mailCopyId, bool isFlagged)
=> UpdateAllMailCopiesAsync(mailCopyId, (item) =>
2025-02-16 11:35:43 +01:00
{
2025-02-16 11:54:23 +01:00
if (item.IsFlagged == isFlagged) return false;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
item.IsFlagged = isFlagged;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return true;
});
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
public async Task CreateAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId)
{
// Note: Folder might not be available at the moment due to user not syncing folders before the delta processing.
// This is a problem, because assignments won't be created.
// Therefore we sync folders every time before the delta processing.
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
var localFolder = await _folderService.GetFolderAsync(accountId, remoteFolderId);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
if (localFolder == null)
{
_logger.Warning("Local folder not found for remote folder {RemoteFolderId}", remoteFolderId);
_logger.Warning("Skipping assignment creation for the the message {MailCopyId}", mailCopyId);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return;
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
var mailCopy = await GetSingleMailItemWithoutFolderAssignmentAsync(mailCopyId);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
if (mailCopy == null)
{
_logger.Warning("Can't create assignment for mail {MailCopyId} because it does not exist.", mailCopyId);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return;
2024-04-18 01:44:37 +02:00
}
2025-02-16 11:54:23 +01:00
if (mailCopy.AssignedFolder.SpecialFolderType == SpecialFolderType.Sent &&
localFolder.SpecialFolderType == SpecialFolderType.Deleted)
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
// Sent item is deleted.
// Gmail does not delete the sent items, but moves them to the deleted folder.
// API doesn't allow removing Sent label.
// Here we intercept this behavior, removing the Sent copy of the mail and adding the Deleted copy.
// This way item will only be visible in Trash folder as in Gmail Web UI.
// Don't delete MIME file since if exists.
await DeleteMailInternalAsync(mailCopy, preserveMimeFile: true).ConfigureAwait(false);
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
// Copy one of the mail copy and assign it to the new folder.
// We don't need to create a new MIME pack.
// Therefore FileId is not changed for the new MailCopy.
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
mailCopy.UniqueId = Guid.NewGuid();
mailCopy.FolderId = localFolder.Id;
mailCopy.AssignedFolder = localFolder;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
await InsertMailAsync(mailCopy).ConfigureAwait(false);
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
public async Task DeleteAssignmentAsync(Guid accountId, string mailCopyId, string remoteFolderId)
{
var mailItem = await GetSingleMailItemAsync(mailCopyId, remoteFolderId).ConfigureAwait(false);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
if (mailItem == null)
{
_logger.Warning("Mail not found with id {MailCopyId} with remote folder {RemoteFolderId}", mailCopyId, remoteFolderId);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return;
2025-02-16 11:35:43 +01:00
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
var localFolder = await _folderService.GetFolderAsync(accountId, remoteFolderId);
if (localFolder == null)
2025-02-16 11:35:43 +01:00
{
2025-02-16 11:54:23 +01:00
_logger.Warning("Local folder not found for remote folder {RemoteFolderId}", remoteFolderId);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return;
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
await DeleteMailInternalAsync(mailItem, preserveMimeFile: false).ConfigureAwait(false);
}
2025-02-16 11:35:43 +01:00
2025-02-22 00:22:00 +01:00
public async Task CreateMailRawAsync(MailAccount account, MailItemFolder mailItemFolder, NewMailItemPackage package)
{
var mailCopy = package.Copy;
var mimeMessage = package.Mime;
mailCopy.UniqueId = Guid.NewGuid();
mailCopy.AssignedAccount = account;
mailCopy.AssignedFolder = mailItemFolder;
mailCopy.SenderContact = await GetSenderContactForAccountAsync(account, mailCopy.FromAddress).ConfigureAwait(false);
mailCopy.FolderId = mailItemFolder.Id;
await SaveContactsForPackageAsync(package).ConfigureAwait(false);
2025-02-22 00:22:00 +01:00
var mimeSaveTask = _mimeFileService.SaveMimeMessageAsync(mailCopy.FileId, mimeMessage, account.Id);
var insertMailTask = InsertMailAsync(mailCopy);
await Task.WhenAll(mimeSaveTask, insertMailTask).ConfigureAwait(false);
2025-02-22 00:22:00 +01:00
}
2025-10-30 17:15:05 +01:00
public async Task CreateMailAsyncEx(Guid accountId, NewMailItemPackage package)
{
}
2025-02-16 11:54:23 +01:00
public async Task<bool> CreateMailAsync(Guid accountId, NewMailItemPackage package)
{
var account = await _accountService.GetAccountAsync(accountId).ConfigureAwait(false);
2025-02-16 11:35:43 +01:00
2025-02-16 11:54:23 +01:00
if (account == null) return false;
2025-02-16 11:35:43 +01:00
2025-02-16 11:54:23 +01:00
if (string.IsNullOrEmpty(package.AssignedRemoteFolderId))
{
_logger.Warning("Remote folder id is not set for {MailCopyId}.", package.Copy.Id);
_logger.Warning("Ignoring creation of mail.");
2025-02-16 11:43:30 +01:00
2025-02-16 11:54:23 +01:00
return false;
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
var assignedFolder = await _folderService.GetFolderAsync(accountId, package.AssignedRemoteFolderId).ConfigureAwait(false);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
if (assignedFolder == null)
{
_logger.Warning("Assigned folder not found for {MailCopyId}.", package.Copy.Id);
_logger.Warning("Ignoring creation of mail.");
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return false;
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
var mailCopy = package.Copy;
var mimeMessage = package.Mime;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
mailCopy.UniqueId = Guid.NewGuid();
mailCopy.AssignedAccount = account;
mailCopy.AssignedFolder = assignedFolder;
mailCopy.SenderContact = await GetSenderContactForAccountAsync(account, mailCopy.FromAddress).ConfigureAwait(false);
mailCopy.FolderId = assignedFolder.Id;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
// Only save MIME files if they don't exists.
// This is because 1 mail may have multiple copies in different folders.
// but only single MIME to represent all.
2025-10-12 16:23:33 +02:00
// Save mime file to disk if provided.
2025-02-16 11:54:23 +01:00
2025-10-12 16:23:33 +02:00
if (mimeMessage != null)
2025-02-16 11:54:23 +01:00
{
2025-10-12 16:23:33 +02:00
var isMimeExists = await _mimeFileService.IsMimeExistAsync(accountId, mailCopy.FileId).ConfigureAwait(false);
2025-02-16 11:54:23 +01:00
2025-10-12 16:23:33 +02:00
if (!isMimeExists)
2025-02-16 11:54:23 +01:00
{
2025-10-12 16:23:33 +02:00
bool isMimeSaved = await _mimeFileService.SaveMimeMessageAsync(mailCopy.FileId, mimeMessage, accountId).ConfigureAwait(false);
if (!isMimeSaved)
{
_logger.Warning("Failed to save mime file for {MailCopyId}.", mailCopy.Id);
}
2025-02-16 11:43:30 +01:00
}
2024-04-18 01:44:37 +02:00
2025-10-12 16:23:33 +02:00
}
2025-02-16 11:35:43 +01:00
// Save contact information extracted from provider API or MIME before insert/update.
await SaveContactsForPackageAsync(package).ConfigureAwait(false);
2025-02-16 11:54:23 +01:00
// Create mail copy in the database.
// Update if exists.
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
var existingCopyItem = await Connection.Table<MailCopy>()
.FirstOrDefaultAsync(a => a.Id == mailCopy.Id && a.FolderId == assignedFolder.Id);
2024-08-17 22:55:58 +02:00
2025-02-16 11:54:23 +01:00
if (existingCopyItem != null)
{
mailCopy.UniqueId = existingCopyItem.UniqueId;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
await UpdateMailAsync(mailCopy).ConfigureAwait(false);
2024-08-17 22:55:58 +02:00
2025-02-16 11:54:23 +01:00
return false;
}
else
{
if (account.ProviderType != MailProviderType.Gmail)
{
// Make sure there is only 1 instance left of this mail copy id.
var allMails = await GetMailItemsAsync(mailCopy.Id).ConfigureAwait(false);
await DeleteMailAsync(accountId, mailCopy.Id).ConfigureAwait(false);
}
2025-02-16 11:54:23 +01:00
await InsertMailAsync(mailCopy).ConfigureAwait(false);
2024-08-17 22:55:58 +02:00
2025-02-16 11:54:23 +01:00
return true;
2025-02-16 11:43:30 +01:00
}
2025-02-16 11:54:23 +01:00
}
private async Task SaveContactsForPackageAsync(NewMailItemPackage package)
{
if (package == null) return;
if (package.Mime != null)
{
await _contactService.SaveAddressInformationAsync(package.Mime).ConfigureAwait(false);
return;
}
var contacts = package.ExtractedContacts?
.Where(c => c != null && !string.IsNullOrWhiteSpace(c.Address))
.ToList() ?? new List<AccountContact>();
var senderAddress = package.Copy?.FromAddress;
if (!string.IsNullOrWhiteSpace(senderAddress))
{
contacts.Add(new AccountContact
{
Address = senderAddress,
Name = string.IsNullOrWhiteSpace(package.Copy?.FromName) ? senderAddress : package.Copy.FromName
});
}
if (contacts.Count == 0) return;
await _contactService.SaveAddressInformationAsync(contacts).ConfigureAwait(false);
}
2025-02-16 11:54:23 +01:00
private async Task<MimeMessage> CreateDraftMimeAsync(MailAccount account, DraftCreationOptions draftCreationOptions)
{
// This unique id is stored in mime headers for Wino to identify remote message with local copy.
// Same unique id will be used for the local copy as well.
// Synchronizer will map this unique id to the local draft copy after synchronization.
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
var message = new MimeMessage()
2025-02-16 11:35:43 +01:00
{
2025-02-16 11:54:23 +01:00
Headers = { { Constants.WinoLocalDraftHeader, Guid.NewGuid().ToString() } },
};
2024-08-10 14:33:02 +02:00
2025-02-16 11:54:23 +01:00
var primaryAlias = await _accountService.GetPrimaryAccountAliasAsync(account.Id) ?? throw new MissingAliasException();
2024-08-10 14:33:02 +02:00
2025-02-16 11:54:23 +01:00
// Set FromName and FromAddress by alias.
message.From.Add(new MailboxAddress(account.SenderName, primaryAlias.AliasAddress));
2024-08-10 14:33:02 +02:00
2025-02-16 11:54:23 +01:00
var builder = new BodyBuilder();
2024-08-10 14:33:02 +02:00
2025-02-16 11:54:23 +01:00
var signature = await GetSignature(account, draftCreationOptions.Reason);
2024-08-10 14:33:02 +02:00
2025-02-16 11:54:23 +01:00
_ = draftCreationOptions.Reason switch
{
DraftCreationReason.Empty => CreateEmptyDraft(builder, message, draftCreationOptions, signature),
_ => CreateReferencedDraft(builder, message, draftCreationOptions, account, signature),
};
2024-08-10 14:33:02 +02:00
2025-02-16 11:54:23 +01:00
// TODO: Migration
// builder.SetHtmlBody(builder.HtmlBody);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
message.Body = builder.ToMessageBody();
2024-08-10 14:33:02 +02:00
2025-02-16 11:54:23 +01:00
return message;
}
2025-02-16 11:35:43 +01:00
2025-02-16 11:54:23 +01:00
private string CreateHtmlGap()
{
var template = $"""<div style="font-family: '{_preferencesService.ComposerFont}', Arial, sans-serif; font-size: {_preferencesService.ComposerFontSize}px"><br></div>""";
return string.Concat(Enumerable.Repeat(template, 2));
}
2025-02-16 11:43:30 +01:00
2025-02-16 11:54:23 +01:00
private async Task<string> GetSignature(MailAccount account, DraftCreationReason reason)
{
if (account.Preferences.IsSignatureEnabled)
2024-08-10 14:33:02 +02:00
{
2025-02-16 11:54:23 +01:00
var signatureId = reason == DraftCreationReason.Empty ?
account.Preferences.SignatureIdForNewMessages :
account.Preferences.SignatureIdForFollowingMessages;
2025-02-16 11:35:43 +01:00
2025-02-16 11:54:23 +01:00
if (signatureId != null)
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
var signature = await _signatureService.GetSignatureAsync(signatureId.Value);
2025-02-16 11:35:43 +01:00
2025-02-16 11:54:23 +01:00
return signature.HtmlBody;
2025-02-16 11:43:30 +01:00
}
2025-02-16 11:35:43 +01:00
}
2024-08-10 14:33:02 +02:00
2025-02-16 11:54:23 +01:00
return null;
}
private MimeMessage CreateEmptyDraft(BodyBuilder builder, MimeMessage message, DraftCreationOptions draftCreationOptions, string signature)
{
builder.HtmlBody = CreateHtmlGap();
if (draftCreationOptions.MailToUri != null)
2025-02-16 11:43:30 +01:00
{
2025-02-16 11:54:23 +01:00
if (draftCreationOptions.MailToUri.Subject != null)
message.Subject = draftCreationOptions.MailToUri.Subject;
if (draftCreationOptions.MailToUri.Body != null)
2025-02-16 11:43:30 +01:00
{
2025-02-16 11:54:23 +01:00
// TODO: In .NET 6+ replace with string "ReplaceLineEndings" method.
var escapedBody = draftCreationOptions.MailToUri.Body.Replace("\r\n", "<br>").Replace("\n", "<br>").Replace("\r", "<br>");
builder.HtmlBody = $"""<div style="font-family: '{_preferencesService.ComposerFont}', Arial, sans-serif; font-size: {_preferencesService.ComposerFontSize}px">{escapedBody}</div>""" + builder.HtmlBody;
}
2024-08-10 14:33:02 +02:00
2025-02-16 11:54:23 +01:00
if (draftCreationOptions.MailToUri.To.Any())
message.To.AddRange(draftCreationOptions.MailToUri.To.Select(x => new MailboxAddress(x, x)));
if (draftCreationOptions.MailToUri.Cc.Any())
message.Cc.AddRange(draftCreationOptions.MailToUri.Cc.Select(x => new MailboxAddress(x, x)));
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
if (draftCreationOptions.MailToUri.Bcc.Any())
message.Bcc.AddRange(draftCreationOptions.MailToUri.Bcc.Select(x => new MailboxAddress(x, x)));
}
2024-08-10 14:33:02 +02:00
2025-02-16 11:54:23 +01:00
if (signature != null)
builder.HtmlBody += signature;
2024-08-10 14:33:02 +02:00
2025-02-16 11:54:23 +01:00
return message;
}
2025-02-16 11:43:30 +01:00
2025-02-16 11:54:23 +01:00
private MimeMessage CreateReferencedDraft(BodyBuilder builder, MimeMessage message, DraftCreationOptions draftCreationOptions, MailAccount account, string signature)
{
var reason = draftCreationOptions.Reason;
var referenceMessage = draftCreationOptions.ReferencedMessage.MimeMessage;
2025-02-16 11:43:30 +01:00
2025-02-16 11:54:23 +01:00
var gap = CreateHtmlGap();
builder.HtmlBody = gap + CreateHtmlForReferencingMessage(referenceMessage);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
if (signature != null)
2025-02-16 11:35:43 +01:00
{
2025-02-16 11:54:23 +01:00
builder.HtmlBody = gap + signature + builder.HtmlBody;
}
2025-02-16 11:43:30 +01:00
2025-02-16 11:54:23 +01:00
// Manage "To"
if (reason == DraftCreationReason.Reply || reason == DraftCreationReason.ReplyAll)
{
// Reply to the sender of the message
if (referenceMessage.ReplyTo.Count > 0)
message.To.AddRange(referenceMessage.ReplyTo);
else if (referenceMessage.From.Count > 0)
message.To.AddRange(referenceMessage.From);
else if (referenceMessage.Sender != null)
message.To.Add(referenceMessage.Sender);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
if (reason == DraftCreationReason.ReplyAll)
2024-08-10 14:33:02 +02:00
{
2025-02-16 11:54:23 +01:00
// Include all of the other original recipients
message.To.AddRange(referenceMessage.To.Where(x => x is MailboxAddress mailboxAddress && !mailboxAddress.Address.Equals(account.Address, StringComparison.OrdinalIgnoreCase)));
message.Cc.AddRange(referenceMessage.Cc.Where(x => x is MailboxAddress mailboxAddress && !mailboxAddress.Address.Equals(account.Address, StringComparison.OrdinalIgnoreCase)));
2024-08-10 14:33:02 +02:00
}
2024-07-18 20:04:11 +02:00
2025-02-16 11:54:23 +01:00
// Self email can be present at this step, when replying to own message. It should be removed only in case there no other recipients.
if (message.To.Count > 1)
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
var self = message.To.FirstOrDefault(x => x is MailboxAddress mailboxAddress && mailboxAddress.Address.Equals(account.Address, StringComparison.OrdinalIgnoreCase));
if (self != null)
message.To.Remove(self);
2024-07-18 20:04:11 +02:00
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
// Manage "ThreadId-ConversationId"
2025-11-01 21:46:23 +01:00
// CRITICAL: In-Reply-To and References headers are essential for threading
// They must reference the original message's Message-ID from the MIME headers
2025-02-16 11:54:23 +01:00
if (!string.IsNullOrEmpty(referenceMessage.MessageId))
2024-04-18 01:44:37 +02:00
{
2025-02-16 11:54:23 +01:00
message.InReplyTo = referenceMessage.MessageId;
2025-11-01 21:46:23 +01:00
// Add all previous References first
if (referenceMessage.References != null && referenceMessage.References.Count > 0)
{
message.References.AddRange(referenceMessage.References);
}
2025-11-01 21:46:23 +01:00
// Then add the message we're replying to
2025-02-16 11:54:23 +01:00
message.References.Add(referenceMessage.MessageId);
2024-04-18 01:44:37 +02:00
}
2025-11-01 21:46:23 +01:00
else
{
// WARNING: Reference message has no Message-ID!
// This will break threading. Try to use the MessageId from MailCopy if available.
var referenceMailCopy = draftCreationOptions.ReferencedMessage.MailCopy;
if (referenceMailCopy != null && !string.IsNullOrEmpty(referenceMailCopy.MessageId))
{
message.InReplyTo = referenceMailCopy.MessageId;
2025-11-01 21:46:23 +01:00
if (!string.IsNullOrEmpty(referenceMailCopy.References))
{
2026-02-06 20:13:44 +01:00
// Parse the References string (supports both ";" and "," separators for backward compatibility)
var references = referenceMailCopy.References.Split(new[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries);
2025-11-01 21:46:23 +01:00
foreach (var reference in references)
{
message.References.Add(reference.Trim());
}
}
2025-11-01 21:46:23 +01:00
message.References.Add(referenceMailCopy.MessageId);
}
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
message.Headers.Add("Thread-Topic", referenceMessage.Subject);
}
// Manage Subject
if (reason == DraftCreationReason.Forward && !referenceMessage.Subject.StartsWith("FW: ", StringComparison.OrdinalIgnoreCase))
message.Subject = $"FW: {referenceMessage.Subject}";
else if ((reason == DraftCreationReason.Reply || reason == DraftCreationReason.ReplyAll) && !referenceMessage.Subject.StartsWith("RE: ", StringComparison.OrdinalIgnoreCase))
message.Subject = $"RE: {referenceMessage.Subject}";
else if (referenceMessage != null)
message.Subject = referenceMessage.Subject;
2024-07-03 23:54:19 +02:00
2025-02-16 11:54:23 +01:00
// Only include attachments if forwarding.
if (reason == DraftCreationReason.Forward && (referenceMessage?.Attachments?.Any() ?? false))
{
foreach (var attachment in referenceMessage.Attachments)
2024-07-03 23:54:19 +02:00
{
2025-02-16 11:54:23 +01:00
builder.Attachments.Add(attachment);
2024-07-03 23:54:19 +02:00
}
2025-02-16 11:35:43 +01:00
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return message;
// Generates html representation of To/Cc/From/Time and so on from referenced message.
string CreateHtmlForReferencingMessage(MimeMessage referenceMessage)
2025-02-16 11:43:30 +01:00
{
2025-02-16 11:54:23 +01:00
var htmlMimeInfo = string.Empty;
// Separation Line
htmlMimeInfo += "<hr style='display:inline-block;width:100%' tabindex='-1'>";
var visitor = _mimeFileService.CreateHTMLPreviewVisitor(referenceMessage, string.Empty);
visitor.Visit(referenceMessage);
htmlMimeInfo += $"""
<div id="divRplyFwdMsg" dir="ltr">
<font face="Calibri, sans-serif" style="font-size: 11pt;" color="#000000">
<b>From:</b> {ParticipantsToHtml(referenceMessage.From)}<br>
<b>Sent:</b> {referenceMessage.Date.ToLocalTime()}<br>
<b>To:</b> {ParticipantsToHtml(referenceMessage.To)}<br>
{(referenceMessage.Cc.Count > 0 ? $"<b>Cc:</b> {ParticipantsToHtml(referenceMessage.Cc)}<br>" : string.Empty)}
<b>Subject:</b> {referenceMessage.Subject}
</font>
<div>&nbsp;</div>
{visitor.HtmlBody}
</div>
""";
return htmlMimeInfo;
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
static string ParticipantsToHtml(InternetAddressList internetAddresses) =>
string.Join("; ", internetAddresses.Mailboxes
.Select(x => $"{x.Name ?? Translator.UnknownSender} &lt;<a href=\"mailto:{x.Address ?? Translator.UnknownAddress}\">{x.Address ?? Translator.UnknownAddress}</a>&gt;"));
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
public async Task<bool> MapLocalDraftAsync(Guid accountId, Guid localDraftCopyUniqueId, string newMailCopyId, string newDraftId, string newThreadId)
{
2025-11-15 13:29:02 +01:00
var localDraftCopy = await Connection.FindWithQueryAsync<MailCopy>(
"SELECT MailCopy.* FROM MailCopy INNER JOIN MailItemFolder ON MailCopy.FolderId = MailItemFolder.Id WHERE MailCopy.UniqueId = ? AND MailItemFolder.MailAccountId = ?",
localDraftCopyUniqueId, accountId);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
if (localDraftCopy == null)
{
_logger.Warning("Draft mapping failed because local draft copy with unique id {LocalDraftCopyUniqueId} does not exist.", localDraftCopyUniqueId);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return false;
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
var oldLocalDraftId = localDraftCopy.Id;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
await LoadAssignedPropertiesAsync(localDraftCopy).ConfigureAwait(false);
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
bool isIdChanging = localDraftCopy.Id != newMailCopyId;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
localDraftCopy.Id = newMailCopyId;
if (!string.IsNullOrEmpty(newDraftId))
localDraftCopy.DraftId = newDraftId;
if (!string.IsNullOrEmpty(newThreadId))
localDraftCopy.ThreadId = newThreadId;
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
await UpdateMailAsync(localDraftCopy).ConfigureAwait(false);
2024-04-18 01:44:37 +02:00
ReportUIChange(new DraftMapped(oldLocalDraftId, localDraftCopy.DraftId));
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return true;
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
public Task MapLocalDraftAsync(string mailCopyId, string newDraftId, string newThreadId)
{
return UpdateAllMailCopiesAsync(mailCopyId, (item) =>
{
var shouldUpdateThreadId = !string.IsNullOrEmpty(newThreadId);
var shouldUpdateDraftId = !string.IsNullOrEmpty(newDraftId);
if ((shouldUpdateThreadId && item.ThreadId != newThreadId) ||
(shouldUpdateDraftId && item.DraftId != newDraftId))
2025-02-16 11:54:23 +01:00
{
var oldDraftId = item.DraftId;
2024-04-18 01:44:37 +02:00
if (shouldUpdateDraftId)
item.DraftId = newDraftId;
if (shouldUpdateThreadId)
item.ThreadId = newThreadId;
2024-04-18 01:44:37 +02:00
ReportUIChange(new DraftMapped(oldDraftId, item.DraftId));
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return true;
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
return false;
});
}
2024-04-18 01:44:37 +02:00
2025-02-16 11:54:23 +01:00
public Task<List<MailCopy>> GetDownloadedUnreadMailsAsync(Guid accountId, IEnumerable<string> downloadedMailCopyIds)
{
2025-11-15 13:29:02 +01:00
var placeholders = string.Join(",", downloadedMailCopyIds.Select(_ => "?"));
var sql = $"SELECT MailCopy.* FROM MailCopy INNER JOIN MailItemFolder ON MailCopy.FolderId = MailItemFolder.Id WHERE MailCopy.Id IN ({placeholders}) AND MailCopy.IsRead = ? AND MailItemFolder.MailAccountId = ? AND MailItemFolder.SpecialFolderType = ?";
var parameters = new List<object>();
parameters.AddRange(downloadedMailCopyIds.Cast<object>());
parameters.Add(false);
parameters.Add(accountId);
parameters.Add((int)SpecialFolderType.Inbox);
return Connection.QueryAsync<MailCopy>(sql, parameters.ToArray());
2025-02-16 11:54:23 +01:00
}
2025-02-16 11:54:23 +01:00
public Task<MailAccount> GetMailAccountByUniqueIdAsync(Guid uniqueMailId)
{
2025-11-15 13:29:02 +01:00
return Connection.FindWithQueryAsync<MailAccount>(
"SELECT MailAccount.* FROM MailCopy INNER JOIN MailItemFolder ON MailCopy.FolderId = MailItemFolder.Id INNER JOIN MailAccount ON MailItemFolder.MailAccountId = MailAccount.Id WHERE MailCopy.UniqueId = ?",
uniqueMailId);
2025-02-16 11:54:23 +01:00
}
2025-02-15 12:53:32 +01:00
2025-02-16 11:54:23 +01:00
public Task<bool> IsMailExistsAsync(string mailCopyId)
=> Connection.ExecuteScalarAsync<bool>("SELECT EXISTS(SELECT 1 FROM MailCopy WHERE Id = ?)", mailCopyId);
2025-02-15 12:53:32 +01:00
2025-02-16 11:54:23 +01:00
public async Task<List<MailCopy>> GetExistingMailsAsync(Guid folderId, IEnumerable<MailKit.UniqueId> uniqueIds)
{
2025-10-18 11:45:10 +02:00
var localMailIds = uniqueIds.Select(a => MailkitClientExtensions.CreateUid(folderId, a.Id)).ToArray();
2025-02-16 11:35:43 +01:00
2025-11-15 13:29:02 +01:00
var placeholders = string.Join(",", localMailIds.Select(_ => "?"));
var sql = $"SELECT * FROM MailCopy WHERE Id IN ({placeholders})";
2025-02-16 11:43:30 +01:00
2025-11-15 13:29:02 +01:00
return await Connection.QueryAsync<MailCopy>(sql, localMailIds.Cast<object>().ToArray());
2025-02-16 11:43:30 +01:00
}
2025-02-16 11:54:23 +01:00
public Task<bool> IsMailExistsAsync(string mailCopyId, Guid folderId)
=> Connection.ExecuteScalarAsync<bool>("SELECT EXISTS(SELECT 1 FROM MailCopy WHERE Id = ? AND FolderId = ?)", mailCopyId, folderId);
2025-02-23 17:05:46 +01:00
public async Task<GmailArchiveComparisonResult> GetGmailArchiveComparisonResultAsync(Guid archiveFolderId, List<string> onlineArchiveMailIds)
{
2026-02-12 18:57:55 +01:00
onlineArchiveMailIds ??= [];
2025-02-23 17:05:46 +01:00
var localArchiveMails = await Connection.Table<MailCopy>()
.Where(a => a.FolderId == archiveFolderId)
.ToListAsync().ConfigureAwait(false);
2026-02-12 18:57:55 +01:00
var onlineArchiveIdSet = onlineArchiveMailIds
.Where(a => !string.IsNullOrWhiteSpace(a))
.ToHashSet(StringComparer.Ordinal);
var localArchiveIdSet = localArchiveMails
.Select(a => a.Id)
.Where(a => !string.IsNullOrWhiteSpace(a))
.ToHashSet(StringComparer.Ordinal);
var removedMails = localArchiveIdSet.Except(onlineArchiveIdSet).ToArray();
var addedMails = onlineArchiveIdSet.Except(localArchiveIdSet).ToArray();
2025-02-23 17:05:46 +01:00
return new GmailArchiveComparisonResult(addedMails, removedMails);
}
public async Task<IEnumerable<string>> GetRecentMailIdsForFolderAsync(Guid folderId, int count)
{
var recentMails = await Connection.Table<MailCopy>()
.Where(a => a.FolderId == folderId)
.OrderByDescending(a => a.CreationDate)
.Take(count)
.ToListAsync()
.ConfigureAwait(false);
return recentMails.Select(m => m.Id);
}
public async Task<List<MailCopy>> GetMailItemsAsync(IEnumerable<string> mailCopyIds)
{
if (!mailCopyIds.Any()) return [];
2025-11-15 13:29:02 +01:00
var placeholders = string.Join(",", mailCopyIds.Select(_ => "?"));
var sql = $"SELECT MailCopy.* FROM MailCopy WHERE MailCopy.Id IN ({placeholders})";
2025-11-15 13:29:02 +01:00
var mailCopies = await Connection.QueryAsync<MailCopy>(sql, mailCopyIds.Cast<object>().ToArray());
if (mailCopies?.Count == 0) return [];
2025-10-31 01:47:33 +01:00
ConcurrentDictionary<Guid, MailItemFolder> folderCache = new();
ConcurrentDictionary<Guid, MailAccount> accountCache = new();
ConcurrentDictionary<string, AccountContact> contactCache = new();
foreach (var mail in mailCopies)
{
await LoadAssignedPropertiesWithCacheAsync(mail, folderCache, accountCache, contactCache).ConfigureAwait(false);
}
return mailCopies;
}
public async Task<List<string>> AreMailsExistsAsync(IEnumerable<string> mailCopyIds)
{
2025-11-15 13:29:02 +01:00
var placeholders = string.Join(",", mailCopyIds.Select(_ => "?"));
var sql = $"SELECT Id FROM MailCopy WHERE Id IN ({placeholders})";
2025-11-15 13:29:02 +01:00
return await Connection.QueryScalarsAsync<string>(sql, mailCopyIds.Cast<object>().ToArray());
}
public Task<List<MailCopy>> GetMailCopiesBeforeDateAsync(Guid accountId, DateTime cutoffDateUtc)
{
const string query = """
SELECT MailCopy.*
FROM MailCopy
INNER JOIN MailItemFolder ON MailCopy.FolderId = MailItemFolder.Id
WHERE MailItemFolder.MailAccountId = ?
AND MailCopy.CreationDate < ?
""";
return Connection.QueryAsync<MailCopy>(query, accountId, cutoffDateUtc);
}
2024-04-18 01:44:37 +02:00
}