ADDED .editorconfig Index: .editorconfig ================================================================== --- /dev/null +++ .editorconfig @@ -0,0 +1,34 @@ +[*.cs] + +# CA2007: Consider calling ConfigureAwait on the awaited task +dotnet_diagnostic.CA2007.severity = silent + +# CA1051: Do not declare visible instance fields +dotnet_diagnostic.CA1051.severity = silent + +# CA1303: Do not pass literals as localized parameters +dotnet_diagnostic.CA1303.severity = suggestion + +# CA1031: Do not catch general exception types +dotnet_diagnostic.CA1031.severity = suggestion + +# CA1820: Test for empty strings using string length +dotnet_diagnostic.CA1820.severity = none + +# CA1305: Specify IFormatProvider +dotnet_diagnostic.CA1305.severity = suggestion + +# CS8629: Nullable value type may be null. +dotnet_diagnostic.CS8629.severity = suggestion + +# CA1819: Properties should not return arrays +dotnet_diagnostic.CA1819.severity = silent + +# CA1062: Validate arguments of public methods +dotnet_diagnostic.CA1062.severity = suggestion + +# CS8602: Dereference of a possibly null reference. +dotnet_diagnostic.CS8602.severity = suggestion + +# Default severity for analyzer diagnostics with category 'Globalization' +dotnet_analyzer_diagnostic.category-Globalization.severity = silent Index: .fossil-settings/ignore-glob ================================================================== --- .fossil-settings/ignore-glob +++ .fossil-settings/ignore-glob @@ -2,5 +2,7 @@ .vscode/ bin/ obj/ mailjanitor*.sqlite **.sqlite-journal +**.sqlite-shm +**.sqlite-wal Index: MailJanitor.csproj ================================================================== --- MailJanitor.csproj +++ MailJanitor.csproj @@ -1,21 +1,28 @@ Exe - netcoreapp2.2 + netcoreapp3.1 + MailJanitor.Program + enable + + + + false - - - + + + + runtime; build; native; contentfiles; analyzers all Index: MailJanitorContext.cs ================================================================== --- MailJanitorContext.cs +++ MailJanitorContext.cs @@ -1,26 +1,23 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; using MailJanitor.Models; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace MailJanitor { - public class MailJanitorContext : DbContext + public class MailJanitorContext : DbContext { +#nullable disable public DbSet Accounts { get; set; } public DbSet Folders { get; set; } public DbSet FolderMessages { get; set; } public DbSet Messages { get; set; } public DbSet MessageParticipants { get; set; } public DbSet Participants { get; set; } public DbSet Keywords { get; set; } public DbSet MessageKeywords { get; set; } public DbSet MessageReferences { get; set; } +#nullable restore protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder .UseSqlite("Data source=mailjanitor.sqlite"); Index: MailSynchronizer.cs ================================================================== --- MailSynchronizer.cs +++ MailSynchronizer.cs @@ -11,11 +11,10 @@ using System.Threading.Tasks; using MailJanitor.Models; using MailKit; using MC.Utilities; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.ChangeTracking; using MimeKit; namespace MailJanitor { public class MailSynchronizer @@ -27,12 +26,12 @@ RemoveComments = false, SmartHrefHandling = true, TableWithoutHeaderRowHandling = ReverseMarkdown.Config.TableWithoutHeaderRowHandlingOption.Default, UnknownTags = ReverseMarkdown.Config.UnknownTagsOption.Bypass, }; - private Dictionary<(string, bool), long> _keywords; - private Dictionary _participants; + private Dictionary<(string, bool), long> _keywords = new Dictionary<(string, bool), long>(); + private Dictionary _participants = new Dictionary(); private int _totalCountAllFolders; private int _countAllFolders; private DateTime _startAccount; private bool _haveDeleted = false; @@ -41,18 +40,25 @@ { _cancellationToken = cancellationToken; } public async Task SynchronizeAccount(string host, short port, NetworkCredential credentials, - Func folderFilter = null, + Func? folderFilter = null, bool vacuumDatabase = false) { + _startAccount = DateTime.UtcNow; + Con.WriteLine($"{_startAccount.ToLocalTime():s} Synchronizing account {host}...", TraceLevel.Verbose); + if (credentials is null) + { + throw new ArgumentNullException(nameof(credentials)); + } + Account account; using (var work = new UnitOfWork(_cancellationToken)) { account = await work.Accounts.GetOrCreateAsync(host, port, credentials.UserName); - _participants = await work.Participants.GetDictionaryAsync(p => p.Address ?? p.Name, p => p.ID, StringComparer.InvariantCultureIgnoreCase); + _participants = await work.Participants.GetDictionaryAsync(p => p.Address ?? p.Name ?? "", p => p.ID, StringComparer.InvariantCultureIgnoreCase); _keywords = await work.Keywords.GetDictionaryAsync(kw => (kw.Name, kw.IsLabel), kw => kw.ID, KeywordEqualityComparer.InvariantCultureIgnoreCase); } using (var client = new MailKit.Net.Imap.ImapClient()) { @@ -73,11 +79,10 @@ && !mf.Attributes.HasFlag(FolderAttributes.NonExistent) && (folderFilter == null || folderFilter(mf))); _totalCountAllFolders = mailFolders.Sum(mf => mf.Count); _countAllFolders = 0; - _startAccount = DateTime.UtcNow; // Keep track of processed folders, to see if one has been deleted var remainingFolderIDs = new List(); using (var work = new UnitOfWork(_cancellationToken)) { @@ -111,36 +116,32 @@ await SynchronizeFolder(mailFolder, dbFolder); if (mailFolder.UidNext != null) { - using (var work = new UnitOfWork(_cancellationToken)) - { - dbFolder = await work.Folders.GetByIdAsync(dbFolder.ID); - dbFolder.UIDNext = mailFolder.UidNext.Value.Id; - await work.SaveChangesAsync(); - } + using var work = new UnitOfWork(_cancellationToken); + dbFolder = await work.Folders.GetByIdAsync(dbFolder.ID); + dbFolder.UIDNext = mailFolder.UidNext.Value.Id; + await work.SaveChangesAsync(); } } // foreach folder // If we have remaining folders that aren't on the server anymore, delete them if (remainingFolderIDs.Any()) { - using (var work = new UnitOfWork(_cancellationToken)) - { - // First verify that they don't have any child folders; otherwise we'd be deleting those too... - remainingFolderIDs = work.Folders.Select(f => remainingFolderIDs.Contains(f.ID) && !f.Children.Any(), - f => f.ID).ToList(); - if (remainingFolderIDs.Any()) - { - Con.WriteLine("Deleting local folder(s):"); - Con.WriteLine("- " + string.Join("\n- ", await work.Folders.GetFullNamesAsync(remainingFolderIDs))); - - work.Folders.DeleteByIDs(remainingFolderIDs); - await work.SaveChangesAsync(); - _haveDeleted = true; - } + using var work = new UnitOfWork(_cancellationToken); + // First verify that they don't have any child folders; otherwise we'd be deleting those too... + remainingFolderIDs = work.Folders.Select(f => remainingFolderIDs.Contains(f.ID) && !f.Children.Any(), + f => f.ID).ToList(); + if (remainingFolderIDs.Any()) + { + Con.WriteLine("Deleting local folder(s):"); + Con.WriteLine("- " + string.Join("\n- ", await work.Folders.GetFullNamesAsync(remainingFolderIDs))); + + work.Folders.DeleteByIDs(remainingFolderIDs); + await work.SaveChangesAsync(); + _haveDeleted = true; } } } // clean up free messages and participants @@ -199,11 +200,11 @@ int totalCount = summaries.Count, counter = 0; Con.WriteLine($"\tFetching {totalCount} new and/or updated messages...", TraceLevel.Verbose); DateTime start = DateTime.UtcNow; DateTime lastProgressUpdate = DateTime.MinValue; - foreach (var summary in summaries) + foreach (IMessageSummary summary in summaries) { counter++; _countAllFolders++; long? folderMsgID = null; @@ -248,99 +249,97 @@ } private async Task SynchronizeMessage(IMessageSummary summary, IMailFolder mailFolder, long folderID, long? folderMsgID, long? messageID) { - using (var work = new UnitOfWork(_cancellationToken)) - { - try - { - // Deleted message: don't download it; delete it from the database instead - if (summary.Flags.Value.HasFlag(MessageFlags.Deleted)) - { - if (folderMsgID != null) - { - work.FolderMessages.DeleteByID(folderMsgID.Value); - await work.SaveChangesAsync(); - _haveDeleted = true; - } - return; - } - - // Otherwise, check out the existing message - Message dbMessage = null; - if (messageID != null) - { - dbMessage = await work.Messages.GetByIdAsync(messageID.Value); - } - FolderMessage folderMessage = null; - if (folderMsgID == null - && dbMessage != null - && summary.Flags == dbMessage.Flags - && summary.InternalDate == dbMessage.DateReceived - && string.Join(", ", summary.Keywords) == dbMessage.Keywords) - { - folderMessage = new FolderMessage - { - FolderID = folderID, - UniqueId = summary.UniqueId, - Message = dbMessage, - }; - await work.FolderMessages.AddAsync(folderMessage); - } - else if (dbMessage == null - || summary.Flags != dbMessage.Flags - || summary.InternalDate != dbMessage.DateReceived - || string.Join(", ", summary.Keywords) != dbMessage.Keywords) - { - MimeMessage mailMessage = await GetMailMessageAsync(summary, mailFolder); - // save message to database - if (folderMsgID != null) - { - folderMessage = await work.DB.FolderMessages - .Where(fm => fm.ID == folderMsgID) - .Include(fm => fm.Message) - .ThenInclude(m => m.Participants) - .SingleOrDefaultAsync(_cancellationToken); - } - if (folderMessage == null) - { - folderMessage = new FolderMessage - { - FolderID = folderID, - UniqueId = summary.UniqueId, - Message = dbMessage ?? new Message - { - Participants = new List(), - }, - }; - await work.FolderMessages.AddAsync(folderMessage); - } - dbMessage = folderMessage.Message; - dbMessage.GlobalID = summary.GMailMessageId; - dbMessage.DateRetrievedUTC = DateTime.UtcNow; - dbMessage.Flags = summary.Flags ?? MessageFlags.None; - dbMessage.DateReceived = summary.InternalDate; - await PopulateMessageAsync(dbMessage, mailMessage, summary.Keywords); - - // Store all separate entities contained in the mail message - await PopulateParticipantsAsync(work, dbMessage, mailMessage); // from headers - await PopulateKeywordsAsync(work, dbMessage, summary.Keywords, summary.GMailLabels); // from summary - await PopulateReferencesAsync(work, dbMessage, mailMessage); // from summary + headers - - } - await work.SaveChangesAsync(); - } - catch (Exception ex) - { - if (ex is TaskCanceledException || ex is OperationCanceledException || ex is ServiceNotConnectedException) - { - throw; // those need to fall through - } - await Con.WriteLineAsync(); - await Con.WriteLineAsync(ex); - } + using var work = new UnitOfWork(_cancellationToken); + try + { + // Deleted message: don't download it; delete it from the database instead + if (summary.Flags.Value.HasFlag(MessageFlags.Deleted)) + { + if (folderMsgID != null) + { + work.FolderMessages.DeleteByID(folderMsgID.Value); + await work.SaveChangesAsync(); + _haveDeleted = true; + } + return; + } + + // Otherwise, check out the existing message + Message? dbMessage = null; + if (messageID != null) + { + dbMessage = await work.Messages.GetByIdAsync(messageID.Value); + } + FolderMessage? folderMessage = null; + if (folderMsgID == null + && dbMessage != null + && summary.Flags == dbMessage.Flags + && summary.InternalDate == dbMessage.DateReceived + && string.Join(", ", summary.Keywords) == dbMessage.Keywords) + { + folderMessage = new FolderMessage + { + FolderID = folderID, + UID = summary.UniqueId.Id, + Message = dbMessage, + }; + await work.FolderMessages.AddAsync(folderMessage); + } + else if (dbMessage == null + || summary.Flags != dbMessage.Flags + || summary.InternalDate != dbMessage.DateReceived + || string.Join(", ", summary.Keywords) != dbMessage.Keywords) + { + MimeMessage mailMessage = await GetMailMessageAsync(summary, mailFolder); + // save message to database + if (folderMsgID != null) + { + folderMessage = await work.DB.FolderMessages + .Where(fm => fm.ID == folderMsgID) + .Include(fm => fm.Message) + .ThenInclude(m => m.Participants) + .SingleOrDefaultAsync(_cancellationToken); + } + if (folderMessage == null) + { + folderMessage = new FolderMessage + { + FolderID = folderID, + UID = summary.UniqueId.Id, + Message = dbMessage ?? new Message + { + Participants = new List(), + }, + }; + await work.FolderMessages.AddAsync(folderMessage); + } + dbMessage = folderMessage.Message; + dbMessage.GlobalID = summary.GMailMessageId; + dbMessage.DateRetrievedUTC = DateTime.UtcNow; + dbMessage.Flags = summary.Flags ?? MessageFlags.None; + dbMessage.DateReceived = summary.InternalDate; + await PopulateMessageAsync(dbMessage, mailMessage, summary.Keywords); + + // Store all separate entities contained in the mail message + await PopulateParticipantsAsync(work, dbMessage, mailMessage); // from headers + await PopulateKeywordsAsync(work, dbMessage, summary.Keywords, summary.GMailLabels); // from summary + await PopulateReferencesAsync(work, dbMessage, mailMessage); // from summary + headers + + } + await work.SaveChangesAsync(); + } + catch (Exception ex) + { + if (ex is TaskCanceledException || ex is OperationCanceledException || ex is ServiceNotConnectedException) + { + throw; // those need to fall through + } + await Con.WriteLineAsync(); + await Con.WriteLineAsync(ex); } } private async Task GetMailMessageAsync(IMessageSummary summary, IMailFolder mailFolder) { @@ -351,71 +350,65 @@ } catch (FormatException) { var mailEncoding = Encoding.GetEncoding("ISO-8859-1"); // Upon failure to parse the message, try to download the entire message stream - using (Stream mailStream = await mailFolder.GetStreamAsync(summary.UniqueId, 0, (int)summary.Size.Value, _cancellationToken)) - using (StreamReader reader = new StreamReader(mailStream, mailEncoding, true)) - { - using (var fs = new FileStream($"{summary.GMailMessageId}.eml", FileMode.Create, FileAccess.ReadWrite, FileShare.Read)) - { - await mailStream.CopyToAsync(fs, _cancellationToken); - mailStream.Position = 0; - } - // Now, attempt to fix the mail message, by skipping lines until one doesn't start with a spacing character - var sbSource = new StringBuilder((int)mailStream.Length); - while (!reader.EndOfStream) - { - string line = await reader.ReadLineAsync(); - if (line.Length == 0 || !char.IsWhiteSpace(line[0])) - { - sbSource.Append(line); - sbSource.Append("\r\n"); - sbSource.Append(await reader.ReadToEndAsync()); - } - } - using (var fixedStream = new MemoryStream((int)mailStream.Length)) - { - await fixedStream.WriteAsync(reader.CurrentEncoding.GetBytes(sbSource.ToString()), _cancellationToken); - fixedStream.Position = 0; - var options = new ParserOptions - { - AddressParserComplianceMode = RfcComplianceMode.Loose, - AllowAddressesWithoutDomain = true, - AllowUnquotedCommasInAddresses = false, - CharsetEncoding = mailEncoding, - MaxAddressGroupDepth = 3, - ParameterComplianceMode = RfcComplianceMode.Loose, - RespectContentLength = true, - Rfc2047ComplianceMode = RfcComplianceMode.Loose, - }; - mailMessage = await MimeMessage.LoadAsync(options, fixedStream, _cancellationToken); - } - } + using Stream mailStream = await mailFolder.GetStreamAsync(summary.UniqueId, 0, (int)summary.Size.Value, _cancellationToken); + using StreamReader reader = new StreamReader(mailStream, mailEncoding, true); + using (var fs = new FileStream($"{summary.GMailMessageId}.eml", FileMode.Create, FileAccess.ReadWrite, FileShare.Read)) + { + await mailStream.CopyToAsync(fs, _cancellationToken); + mailStream.Position = 0; + } + // Now, attempt to fix the mail message, by skipping lines until one doesn't start with a spacing character + var sbSource = new StringBuilder((int)mailStream.Length); + while (!reader.EndOfStream) + { + string line = await reader.ReadLineAsync() ?? ""; + if (line.Length == 0 || !char.IsWhiteSpace(line[0])) + { + sbSource.Append(line); + sbSource.Append("\r\n"); + sbSource.Append(await reader.ReadToEndAsync()); + } + } + using var fixedStream = new MemoryStream((int)mailStream.Length); + await fixedStream.WriteAsync(reader.CurrentEncoding.GetBytes(sbSource.ToString()), _cancellationToken); + fixedStream.Position = 0; + var options = new ParserOptions + { + AddressParserComplianceMode = RfcComplianceMode.Loose, + AllowAddressesWithoutDomain = true, + AllowUnquotedCommasInAddresses = false, + CharsetEncoding = mailEncoding, + MaxAddressGroupDepth = 3, + ParameterComplianceMode = RfcComplianceMode.Loose, + RespectContentLength = true, + Rfc2047ComplianceMode = RfcComplianceMode.Loose, + }; + mailMessage = await MimeMessage.LoadAsync(options, fixedStream, _cancellationToken); } return mailMessage; } public async Task ClearUnunsedObjectsAsync(bool vacuumDatabase = false) { - using (var work = new UnitOfWork(_cancellationToken)) - { - await Con.WriteLineAsync("Cleaning up database...", TraceLevel.Verbose); - - // Delete any messages not linked to a folder - work.Messages.DeleteByIDs(m => !m.Folders.Any(), m => m.ID); - int numDeleted = await work.SaveChangesAsync(); - - // Delete any participants not linked to a message - work.Participants.DeleteByIDs(p => !p.Messages.Any(), p => p.ID); - numDeleted += await work.SaveChangesAsync(); - - if (vacuumDatabase && (_haveDeleted || numDeleted > 0)) - { - await work.DB.Database.ExecuteSqlCommandAsync("VACUUM", _cancellationToken); - } + using var work = new UnitOfWork(_cancellationToken); + await Con.WriteLineAsync("Cleaning up database...", TraceLevel.Verbose); + + // Delete any messages not linked to a folder + work.Messages.DeleteByIDs(m => !m.Folders.Any(), m => m.ID); + int numDeleted = await work.SaveChangesAsync(); + + // Delete any participants not linked to a message + work.Participants.DeleteByIDs(p => !p.Messages.Any(), p => p.ID); + numDeleted += await work.SaveChangesAsync(); + + if (vacuumDatabase && (_haveDeleted || numDeleted > 0)) + { + await work.DB.Database.ExecuteSqlRawAsync("VACUUM", _cancellationToken); } } private async Task PopulateMessageAsync(Message targetMsg, MimeMessage sourceMsg, IEnumerable keywords) { @@ -434,37 +427,34 @@ // store text (if present) and ALSO extract text from HTML (if present); they might not contain the same text. targetMsg.Body = await ExtractBodyText(sourceMsg); if (sourceMsg.Attachments.Any()) { - targetMsg.AttachmentSummary = string.Join(Environment.NewLine, - sourceMsg.Attachments.Select((a, i) => $"{i + 1}. {(a is MimePart part ? part.FileName : null)} [{a.ContentType.MimeType}] " + a.Headers[HeaderId.ContentDescription])); + targetMsg.AttachmentSummary = string.Join(Environment.NewLine, sourceMsg.Attachments.Select(SummarizeAttachment)); } else { targetMsg.AttachmentSummary = null; } - using (var zipStream = new MemoryStream()) - { - using (var zip = new ZipArchive(zipStream, ZipArchiveMode.Update)) - { - string filename = CreateMessageFilename(sourceMsg); - var entry = zip.CreateEntry(filename, CompressionLevel.Optimal); - try - { - entry.LastWriteTime = sourceMsg.Date; - } - catch - { - // ignore errors; just use today's date instead - } - using (var compressedStream = entry.Open()) - await sourceMsg.WriteToAsync(compressedStream); - } - targetMsg.Original = zipStream.ToArray(); - } + using var zipStream = new MemoryStream(); + using (var zip = new ZipArchive(zipStream, ZipArchiveMode.Update)) + { + string filename = CreateMessageFilename(sourceMsg); + var entry = zip.CreateEntry(filename, CompressionLevel.Optimal); + try + { + entry.LastWriteTime = sourceMsg.Date; + } + catch + { + // ignore errors; just use today's date instead + } + using var compressedStream = entry.Open(); + await sourceMsg.WriteToAsync(compressedStream); + } + targetMsg.Original = zipStream.ToArray(); // TODO: skip the above if we only have the headers } catch (Exception ex) { if (ex is TaskCanceledException || ex is OperationCanceledException || ex is ServiceNotConnectedException) @@ -476,13 +466,13 @@ } } // PopulateMessageAsync private static string CreateMessageFilename(MimeMessage sourceMsg, string extension = ".eml") { - string subject = MakeSafe(sourceMsg.Subject); + string? subject = MakeSafe(sourceMsg.Subject); InternetAddress sender = sourceMsg.From.FirstOrDefault(); - string senderName = MakeSafe(sender?.Name ?? sender?.ToString()); + string? senderName = MakeSafe(sender?.Name ?? sender?.ToString()); string fileName = $"{subject}{(subject != null && senderName != null ? " - " : "")}{senderName}"; // If there's nothing left, just use a default name if (fileName.Length <= 3) @@ -498,11 +488,11 @@ { UnicodeCategory.LineSeparator, UnicodeCategory.ParagraphSeparator, UnicodeCategory.SpaceSeparator, }; - private static string MakeSafe(string text) + private static string? MakeSafe(string? text) { if (string.IsNullOrWhiteSpace(text)) return null; // First, trim spaces and strip all diacritics; replace all spacing by a normal space @@ -519,13 +509,13 @@ return new string(chars.Where(c => c >= ' ' && !_forbiddenChars.Contains(c)).ToArray()) .Normalize(NormalizationForm.FormC) .Trim(); } - private async Task ExtractBodyText(MimeMessage sourceMsg) + private async Task ExtractBodyText(MimeMessage sourceMsg) { - string bodyText = sourceMsg.TextBody?.Trim(); + string? bodyText = sourceMsg.TextBody?.Trim(); if (sourceMsg.HtmlBody != null) { if (bodyText != null) bodyText += Environment.NewLine + Environment.NewLine; else @@ -551,11 +541,11 @@ else { throw new AggregateException(errList); } } - bodyText += result.Code.Trim(); + bodyText += result.Code?.Trim(); return bodyText; } catch (Exception ex) { errors.Add(ex); @@ -564,11 +554,11 @@ { var html = sourceMsg.HtmlBody; // This HtmlConverter is known to sometimes get stuck in an infinite loop, so wait for a finite amount of time using(var timeoutTokenSource = new CancellationTokenSource()) { - var task = Task.Run(() => new ReverseMarkdown.Converter(_htmlConverterConfig).Convert(html).TrimEnd(), timeoutTokenSource.Token); + Task task = Task.Run(() => new ReverseMarkdown.Converter(_htmlConverterConfig).Convert(html)?.TrimEnd(), timeoutTokenSource.Token); if (task.Wait(300000)) { task.Dispose(); } else @@ -583,17 +573,18 @@ File.SetLastWriteTimeUtc(tempFileName, sourceMsg.Date.UtcDateTime); // TODO: remove above temp file saving _ = task.ConfigureAwait(false); task = task.ContinueWith((completedTask, data) => { + if (data is null) throw new ArgumentNullException(nameof(data)); var (gaveupAt, newFileName) = ((DateTime, string))data; Con.WriteLineErr($"\nHTML conversion finished, but at {DateTime.Now:HH:mm:ss}; we stopped waiting at {gaveupAt:HH:mm:ss}.", ConsoleColor.Cyan); var markDown = completedTask.Result; using (var writer = new StreamWriter(newFileName, false, Encoding.UTF8)) writer.Write(markDown); - return (string)null; - }, (DateTime.Now, Path.ChangeExtension(tempFileName, ".md"))); + return (string?)null; + }, (DateTime.Now, Path.ChangeExtension(tempFileName, ".md")), TaskScheduler.Current); throw new TimeoutException("Timeout while converting HTML to markdown."); } bodyText += task.Result; } return bodyText; @@ -614,16 +605,51 @@ //await Con.WriteLineAsync(sourceMsg.HtmlBody?.Trim(), ConsoleColor.DarkYellow); } } return bodyText; } + + private string SummarizeAttachment(MimeEntity attachment, int index) + { + string? fileName = attachment is MimePart part ? part.FileName : null; + string? size = long.TryParse(attachment.Headers[HeaderId.ContentLength], out long length) ? "; " + FormatSize(length) : null; + string? description = attachment.Headers[HeaderId.ContentDescription]; + if (description == fileName) description = ""; + return $"{index + 1}. {fileName} [{attachment.ContentType.MimeType}{size}] {description}".TrimEnd(); + } + + private static readonly List<(string Prefix, long Multiplier)> _siMultipliers = " kMGTPEZY".Select((prefix, index) => (prefix.ToString().Trim(), (long)Math.Pow(10, index))).ToList(); + private string FormatSize(long bytes, int decimals = 1) + { + var (prefix, multiplier) = _siMultipliers.LastOrDefault(x => bytes < x.Multiplier); + if (multiplier == 0) + { + (prefix, multiplier) = _siMultipliers.Last(); + } + if (multiplier > 1 && decimals > 0) + return string.Format("#,##0." + new string('0', decimals), bytes / multiplier) + ' ' + prefix + 'B'; + else + return $"{bytes / multiplier:#,##0} {prefix}B"; + } + + private static readonly List<(string Prefix, long Multiplier)> _biMultipliers = "_KMGTPEZY".Select((prefix, index) => (Prefix: prefix.ToString().Trim(), (long)Math.Pow(2, Math.Pow(10, index)))) + .Select(x => x.Prefix == "_" ? ("", 1) : x).ToList(); + private string FormatSizeBinary(long bytes) + { + var (prefix, multiplier) = _biMultipliers.LastOrDefault(x => bytes < x.Multiplier); + if (multiplier == 0) + { + (prefix, multiplier) = _biMultipliers.Last(); + } + return $"{bytes / multiplier:#,##0} {prefix}iB"; + } private async Task PopulateParticipantsAsync(UnitOfWork work, Message targetMsg, MimeMessage sourceMsg) { try { - var participantList = new List<(ParticipantField Field, string Address, string Name)>(); + var participantList = new List<(ParticipantField Field, string? Address, string Name)>(); GatherParticipants(ref participantList, sourceMsg.From, ParticipantField.From); GatherParticipants(ref participantList, sourceMsg.To, ParticipantField.To); GatherParticipants(ref participantList, sourceMsg.Cc, ParticipantField.Cc); GatherParticipants(ref participantList, sourceMsg.Bcc, ParticipantField.Bcc); GatherParticipants(ref participantList, sourceMsg.ReplyTo, ParticipantField.ReplyTo); @@ -636,12 +662,12 @@ GatherParticipants(ref participantList, sourceMsg.ResentSender, ParticipantField.ResentSender); int order = 0; foreach (var (field, mailAddress, mailName) in participantList) { - string address = mailAddress == "" ? null : mailAddress; - string name = mailName == "" ? null : mailName; + string? address = mailAddress == "" ? null : mailAddress; + string? name = mailName == "" ? null : mailName; if (name != null && name.Trim(' ', '\'', '"', '<', '>', '(', ')') == address) name = null; var msgParticipant = new MessageParticipant { @@ -649,11 +675,11 @@ Field = field, Order = ++order, Name = name, }; - var key = address ?? name; + string key = address ?? name ?? ""; if (_participants.TryGetValue(key, out long participantID)) { msgParticipant.ParticipantID = participantID; /* TODO: when cleaning up the database? Put the most used name for this address in the participant. UPDATE Participants p @@ -704,12 +730,11 @@ try { // Store the keywords separately, and link them to this message var allKeywords = keywords.Select(k => (Name: k, IsLabel: false)) .Concat(labels.Select(l => (Name: l, IsLabel: true))); - var triplets = allKeywords.Select(mailKeyword => (Name: mailKeyword.Name, - IsLabel: mailKeyword.IsLabel, + var triplets = allKeywords.Select(mailKeyword => (mailKeyword.Name, mailKeyword.IsLabel, ID: _keywords.GetValueOrDefault(mailKeyword, 0))); var keywordSets = from triplet in triplets join dbMsgKeyword in work.DB.MessageKeywords.Where(mk => mk.Message == targetMsg) on triplet.ID equals dbMsgKeyword.KeywordID into msgKeywordSet @@ -761,11 +786,11 @@ /// Store any references between messages. /// /// The to use when storing the references. /// The (newly stored) that should link to the references. /// The containing the references. - private async Task PopulateReferencesAsync(UnitOfWork work, Message targetMsg, MimeMessage sourceMsg) + private static async Task PopulateReferencesAsync(UnitOfWork work, Message targetMsg, MimeMessage sourceMsg) { try { // Include the In-Reply-To header in the list of references var sourceReferences = new List(sourceMsg.References); @@ -775,18 +800,18 @@ var dbMessageRefs = await work.MessageReferences .GetDictionaryAsync(mr => mr.Message == targetMsg, mr => mr.ReferencedRfcMessageID, mr => mr); var dbReferencedMessageIDs = work.Messages - .GetLookup(m => sourceReferences.Contains(m.RfcMessageID), + .GetLookup(m => sourceReferences.Contains(m.RfcMessageID ?? ""), m => m.RfcMessageID, m => m?.ID); // make this a nullable long, so FirstOrDefault will return a null. int index = 0; foreach (string mailRef in sourceReferences) { index++; - MessageReference dbReference = dbMessageRefs.GetValueOrDefault(mailRef, null); + MessageReference? dbReference = dbMessageRefs.GetValueOrDefault(mailRef, null); long? dbMessageID = null; if (dbReferencedMessageIDs.Contains(mailRef)) { dbMessageID = dbReferencedMessageIDs[mailRef].FirstOrDefault(id => id.HasValue); } @@ -834,27 +859,27 @@ await Con.WriteLineAsync(); await Con.WriteLineAsync(ex); } } // PopulateReferencesAsync - private static void GatherParticipants(ref List<(ParticipantField Field, string Address, string Name)> participantList, InternetAddress ia, ParticipantField field) + private static void GatherParticipants(ref List<(ParticipantField Field, string? Address, string Name)> participantList, InternetAddress ia, ParticipantField field) { if (ia == null) return; var list = new InternetAddressList(new InternetAddress[] { ia }); GatherParticipants(ref participantList, list, field); } - private static void GatherParticipants(ref List<(ParticipantField Field, string Address, string Name)> participantList, InternetAddressList list, ParticipantField field) + private static void GatherParticipants(ref List<(ParticipantField Field, string? Address, string Name)> participantList, InternetAddressList list, ParticipantField field) { foreach (var ia in list) { if (ia is GroupAddress group) { if (group.Members.Count > 0) { participantList.AddRange(group.Members .Select(ma => ma as MailboxAddress) - .Select(ma => (field, ma.Address, ma.Name))); + .Select(ma => (field, ma?.Address, ma?.Name ?? ""))); } else { participantList.Add((field, null, group.Name)); } @@ -870,22 +895,21 @@ } } private class KeywordEqualityComparer : IEqualityComparer<(string Name, bool IsLabel)> { - private static readonly KeywordEqualityComparer _default = new KeywordEqualityComparer(); - public static KeywordEqualityComparer InvariantCultureIgnoreCase { get => _default; } + public static KeywordEqualityComparer InvariantCultureIgnoreCase { get; } = new KeywordEqualityComparer(); public bool Equals((string Name, bool IsLabel) x, (string Name, bool IsLabel) y) { return x.IsLabel == y.IsLabel && x.Name.Equals(y.Name, StringComparison.InvariantCultureIgnoreCase); } public int GetHashCode((string Name, bool IsLabel) obj) { - return obj.Name.GetHashCode() ^ obj.IsLabel.GetHashCode(); + return obj.Name.GetHashCode(StringComparison.InvariantCultureIgnoreCase) ^ obj.IsLabel.GetHashCode(); } } } } Index: Models/Account.cs ================================================================== --- Models/Account.cs +++ Models/Account.cs @@ -7,15 +7,15 @@ namespace MailJanitor.Models { public class Account { public long ID { get; set; } - [Required][Column(TypeName = "TEXT COLLATE NOCASE")] - public string Host { get; set; } + [Column(TypeName = "TEXT COLLATE NOCASE")] + public string Host { get; set; } = ""; public short Port { get; set; } - [Required][Column(TypeName = "TEXT COLLATE NOCASE")] - public string UserName { get; set; } + [Column(TypeName = "TEXT COLLATE NOCASE")] + public string UserName { get; set; } = ""; public long? InboxFolderID { get; set; } //public Folder InboxFolder { get; set; } TODO: restore these, and use annotations to fix migration public long? DraftsFolderID { get; set; } //public Folder DraftsFolder { get; set; } public long? SentFolderID { get; set; } @@ -29,8 +29,8 @@ public long? TrashFolderID { get; set; } //public Folder TrashFolder { get; set; } public long? SpamFolderID { get; set; } //public Folder SpamFolder { get; set; } - public ICollection Folders { get; set; } + public ICollection? Folders { get; internal set; } } } Index: Models/Folder.cs ================================================================== --- Models/Folder.cs +++ Models/Folder.cs @@ -8,18 +8,17 @@ { public class Folder { public long ID { get; set; } public long AccountID { get; set; } - public Account Account { get; set; } - [Required] - public string Name { get; set; } + public Account? Account { get; set; } + public string Name { get; set; } = ""; public uint UIDValidity { get; set; } public uint? UIDNext { get; set; } public FolderAttributes Attributes { get; set; } public long? ParentFolderID { get; set; } - public Folder ParentFolder { get; set; } + public Folder? ParentFolder { get; set; } - public ICollection Children { get; set; } - public ICollection Messages { get; set; } + public ICollection? Children { get; } + public ICollection? Messages { get; } } } Index: Models/FolderMessage.cs ================================================================== --- Models/FolderMessage.cs +++ Models/FolderMessage.cs @@ -9,17 +9,11 @@ public class FolderMessage { public long ID { get; set; } public long FolderID { get; set; } - public Folder Folder { get; set; } + public Folder? Folder { get; set; } public long MessageID { get; set; } - public Message Message { get; set; } + public Message? Message { get; set; } public uint UID { get; set; } - [NotMapped] - public UniqueId UniqueId - { - get => new UniqueId(Folder.UIDValidity, UID); - set => UID = value.Id; - } } } Index: Models/Keyword.cs ================================================================== --- Models/Keyword.cs +++ Models/Keyword.cs @@ -5,11 +5,12 @@ namespace MailJanitor.Models { public class Keyword { public long ID { get; set; } - [Required][Column(TypeName = "TEXT COLLATE NOCASE")] - public string Name { get; set; } + [Column(TypeName = "TEXT COLLATE NOCASE")] + public string Name { get; set; } = ""; public bool IsLabel { get; set; } - public ICollection Messages { get; set; } + + public ICollection? Messages { get; internal set; } } } Index: Models/Message.cs ================================================================== --- Models/Message.cs +++ Models/Message.cs @@ -10,37 +10,37 @@ { public long ID { get; set; } public MessageFlags Flags { get; set; } public ulong? GlobalID { get; set; } - public string RfcMessageID { get; set; } + public string? RfcMessageID { get; set; } public DateTimeOffset Date { get; set; } public DateTimeOffset? DateReceived { get; set; } public DateTime DateRetrievedUTC { get; set; } = DateTime.UtcNow; - public string From { get; set; } - public string To { get; set; } - public string Cc { get; set; } - public string Bcc { get; set; } - - public string Subject { get; set; } - public string Body { get; set; } - public string HTMLBody { get; set; } - public string Keywords { get; set; } - public string AttachmentSummary { get; set; } - - public byte[] Original { get; set; } + public string? From { get; set; } + public string? To { get; set; } + public string? Cc { get; set; } + public string? Bcc { get; set; } + + public string? Subject { get; set; } + public string? Body { get; set; } + public string? HTMLBody { get; set; } + public string? Keywords { get; set; } + public string? AttachmentSummary { get; set; } + + public byte[]? Original { get; set; } public DownloadStatus DownloadStatus { get; set; } - public ICollection Participants { get; set; } - public ICollection Folders { get; set; } - public ICollection KeywordList { get; set; } + public ICollection? Participants { get; internal set; } + public ICollection? Folders { get; } + public ICollection? KeywordList { get; } [InverseProperty("Message")] - public ICollection References { get; set; } + public ICollection? References { get; } [InverseProperty("ReferencedMessage")] - public ICollection ReferencedBy { get; set; } + public ICollection? ReferencedBy { get; } } public enum DownloadStatus { Nothing, Index: Models/MessageKeyword.cs ================================================================== --- Models/MessageKeyword.cs +++ Models/MessageKeyword.cs @@ -6,11 +6,11 @@ { public class MessageKeyword { public long ID { get; set; } public long MessageID { get; set; } - public Message Message { get; set; } + public Message? Message { get; set; } public long KeywordID { get; set; } - public Keyword Keyword { get; set; } + public Keyword? Keyword { get; set; } public int Order { get; set; } } } Index: Models/MessageParticipant.cs ================================================================== --- Models/MessageParticipant.cs +++ Models/MessageParticipant.cs @@ -23,15 +23,15 @@ public class MessageParticipant { public long ID { get; set; } public long MessageID { get; set; } - public Message Message { get; set; } + public Message? Message { get; set; } public long ParticipantID { get; set; } - public Participant Participant { get; set; } + public Participant? Participant { get; set; } public ParticipantField Field { get; set; } public int Order { get; set; } - public string Name { get; set; } + public string? Name { get; set; } } } Index: Models/MessageReference.cs ================================================================== --- Models/MessageReference.cs +++ Models/MessageReference.cs @@ -8,14 +8,13 @@ { public class MessageReference { public long ID { get; set; } public long MessageID { get; set; } - public Message Message { get; set; } + public Message? Message { get; set; } public bool InReplyTo { get; set; } public int Order { get; set; } - [Required] - public string ReferencedRfcMessageID { get; set; } + public string ReferencedRfcMessageID { get; set; } = ""; public long? ReferencedMessageID { get; set; } - public Message ReferencedMessage { get; set; } + public Message? ReferencedMessage { get; set; } } } Index: Models/Participant.cs ================================================================== --- Models/Participant.cs +++ Models/Participant.cs @@ -8,12 +8,12 @@ { public class Participant { public long ID { get; set; } [Column(TypeName = "TEXT COLLATE NOCASE")] - public string Address { get; set; } + public string? Address { get; set; } [Column(TypeName = "TEXT COLLATE NOCASE")] - public string Name { get; set; } + public string? Name { get; set; } - public ICollection Messages { get; set; } + public ICollection? Messages { get; internal set; } } } Index: Options.cs ================================================================== --- Options.cs +++ Options.cs @@ -1,24 +1,24 @@ using CommandLine; namespace MailJanitor { - public class Options + public class CommandLineOptions { [Value(0, MetaName = "", Required = true, HelpText = "User name (e-mail address).")] - public string EmailAddress { get; set; } + public string EmailAddress { get; set; } = ""; [Value(1, MetaName = "", Required = true, HelpText = "Password for the mail server.")] - public string Password { get; set; } + public string Password { get; set; } = ""; [Value(2, MetaName = "", Required = false, Default = "imap.gmail.com", HelpText = "The host name of the e-mail server. Defaults to imap.gmail.com.")] - public string MailServer { get; set; } + public string MailServer { get; set; } = ""; - [Value(3, MetaName = "", Required = false, Default = 993, + [Value(3, MetaName = "", Required = false, Default = 993, HelpText = "The port number of the IMAP e-mail server. Defaults to 993 (IMAP over SSL).")] - public short PortNumber { get; set; } + public short PortNumber { get; set; } = 993; [Option('v', "verbose", Required = false, HelpText = "Show more output.")] public bool Verbose { get; set; } [Option('c', "compact-database", Required = false, Default = false, HelpText = "Vacuum the database when done.")] Index: Program.cs ================================================================== --- Program.cs +++ Program.cs @@ -24,11 +24,11 @@ static int Main(string[] args) { ResultCode result = ResultCode.Exception; try { - Parser.Default.ParseArguments(args) + Parser.Default.ParseArguments(args) .WithParsed(o => { using (var tokenSource = new CancellationTokenSource()) { Console.CancelKeyPress += new ConsoleCancelEventHandler((sender, eventArgs) => @@ -41,11 +41,12 @@ mailFetcher.SynchronizeAccount(o.MailServer, o.PortNumber, new NetworkCredential(o.EmailAddress, o.Password), FetchFolder, o.VacuumDatabase).Wait(); - bool FetchFolder(IMailFolder mailFolder) + + static bool FetchFolder(IMailFolder mailFolder) { var offendingAttributes = FolderAttributes.Drafts | FolderAttributes.All | FolderAttributes.Archive | FolderAttributes.Trash Index: Repositories/AccountsRepository.cs ================================================================== --- Repositories/AccountsRepository.cs +++ Repositories/AccountsRepository.cs @@ -7,21 +7,25 @@ namespace MailJanitor.Repositories { public class AccountsRepository : Repository { - public AccountsRepository(MailJanitorContext db, CancellationToken cancellationToken = default(CancellationToken)) : base(db, cancellationToken) + public AccountsRepository(MailJanitorContext db, CancellationToken cancellationToken = default) : base(db, cancellationToken) { } public async Task GetOrCreateAsync(string host, short port, string userName) { - Account account = await _set + // Prevent problems with case-sensitivity + host = host.ToLowerInvariant(); + userName = userName.ToLowerInvariant(); + + Account? account = await _set .Include(a => a.Folders) - .SingleOrDefaultAsync(a => a.Host.Equals(host, StringComparison.InvariantCultureIgnoreCase) - && a.Port == port - && a.UserName.Equals(userName, StringComparison.InvariantCultureIgnoreCase), + .SingleOrDefaultAsync(a => a.Host == host + && a.Port == port + && a.UserName == userName, _cancellationToken); if (account == null) { account = new Account { Index: Repositories/FolderMessagesRepository.cs ================================================================== --- Repositories/FolderMessagesRepository.cs +++ Repositories/FolderMessagesRepository.cs @@ -7,23 +7,29 @@ namespace MailJanitor.Repositories { public class FolderMessagesRepository : Repository { - public FolderMessagesRepository(MailJanitorContext db, CancellationToken cancellationToken = default(CancellationToken)) : base(db, cancellationToken) + internal FolderMessagesRepository(MailJanitorContext db, CancellationToken cancellationToken = default) : base(db, cancellationToken) { } /// /// Retrieves the highest UID in the given . /// - public Task GetMaxUIDAsync(Folder folder) + public async Task GetMaxUIDAsync(Folder folder) { - return _db.FolderMessages - .Where(fm => fm.Folder == folder) - .DefaultIfEmpty() - .MaxAsync(fm => fm == null ? 0 : fm.UID, _cancellationToken); + var folderMessages = _db.FolderMessages + .Where(fm => fm.Folder == folder); + if (await folderMessages.AnyAsync()) + { + return await folderMessages.MaxAsync(fm => fm.UID, _cancellationToken); + } + else + { + return uint.MinValue; + } } /// /// Retrieves a list of s and s matching the given /// in the given . @@ -34,22 +40,22 @@ /// a tuple with the corresponding and . These can be /// if not present in the database. public async Task> GetIDsByGlobalIDAsync(long dbFolderID, IEnumerable globalIDs) { return await(from dbMessage in _db.Messages - where globalIDs.Contains(dbMessage.GlobalID.Value) + where globalIDs.Contains(dbMessage.GlobalID ?? 0) join folderMsg in _set.Where(folderMsg => folderMsg.FolderID == dbFolderID) on dbMessage.ID equals folderMsg.MessageID into folderMsgs from folderMsg in folderMsgs.DefaultIfEmpty() select new { - globalID = dbMessage.GlobalID.Value, + globalID = dbMessage.GlobalID ?? 0, folderMsgID = folderMsg == null ? null : (long?)folderMsg.ID, messageID = dbMessage == null ? null : (long?)dbMessage.ID, }) .ToDictionaryAsync(idSet => idSet.globalID, idSet => (idSet.folderMsgID, idSet.messageID), _cancellationToken); } } } Index: Repositories/FoldersRepository.cs ================================================================== --- Repositories/FoldersRepository.cs +++ Repositories/FoldersRepository.cs @@ -9,27 +9,32 @@ namespace MailJanitor.Repositories { public class FoldersRepository : Repository { - public FoldersRepository(MailJanitorContext db, CancellationToken cancellationToken = default(CancellationToken)) : base(db, cancellationToken) + internal FoldersRepository(MailJanitorContext db, CancellationToken cancellationToken = default) : base(db, cancellationToken) { } public async Task GetOrCreateAsync(IMailFolder mailFolder, Account account) { + if (account is null) + { + throw new ArgumentNullException(nameof(account)); + } + if (_db.Entry(account).State == EntityState.Detached) account = _db.Accounts.Find(account.ID); // Make sure we've got an entity from the current DbContext // If the mail folder has a parent, make sure that exists first - Folder dbParentFolder = null; + Folder? dbParentFolder = null; if (mailFolder.ParentFolder != null && !string.IsNullOrEmpty(mailFolder.ParentFolder.Name)) { dbParentFolder = await GetOrCreateAsync(mailFolder.ParentFolder, account); } - Folder dbFolder = await _set + Folder? dbFolder = await _set .Where(f => f.AccountID == account.ID && f.Name == mailFolder.Name && f.ParentFolderID == (dbParentFolder == null ? (long?)null : dbParentFolder.ID)) .Include(f => f.ParentFolder) .SingleOrDefaultAsync(_cancellationToken); if (dbFolder == null) { @@ -69,10 +74,15 @@ return dbFolder; } public Task GetFullNameAsync(Folder folder) { + if (folder is null) + { + throw new ArgumentNullException(nameof(folder)); + } + return GetFullNameAsync(folder.ID); } public async Task GetFullNameAsync(long id) { return (await GetFullNamesAsync(new long[] { id })).First(); @@ -104,10 +114,10 @@ FROM FullNames WHERE ParentID IS NULL ORDER BY CASE ID {string.Join("\n", ids.Select((id, index) => $"WHEN {id} THEN {index}"))} END ;"; #pragma warning disable EF1000 // There's no risk of SQL injection with this query. - return await _set.FromSql(sql).Select(f => f.Name).ToListAsync(); + return await _set.FromSqlRaw(sql).Select(f => f.Name).ToListAsync(); #pragma warning restore EF1000 } } } Index: Repositories/KeywordsRepository.cs ================================================================== --- Repositories/KeywordsRepository.cs +++ Repositories/KeywordsRepository.cs @@ -3,10 +3,10 @@ namespace MailJanitor.Repositories { public class KeywordsRepository : Repository { - public KeywordsRepository(MailJanitorContext db, CancellationToken cancellationToken = default(CancellationToken)) : base(db, cancellationToken) + internal KeywordsRepository(MailJanitorContext db, CancellationToken cancellationToken = default) : base(db, cancellationToken) { } } } Index: Repositories/MessageReferencesRepository.cs ================================================================== --- Repositories/MessageReferencesRepository.cs +++ Repositories/MessageReferencesRepository.cs @@ -3,10 +3,10 @@ namespace MailJanitor.Repositories { public class MessageReferencesRepository : Repository { - public MessageReferencesRepository(MailJanitorContext db, CancellationToken cancellationToken = default(CancellationToken)) : base(db, cancellationToken) + internal MessageReferencesRepository(MailJanitorContext db, CancellationToken cancellationToken = default) : base(db, cancellationToken) { } } } Index: Repositories/MessagesRepository.cs ================================================================== --- Repositories/MessagesRepository.cs +++ Repositories/MessagesRepository.cs @@ -3,10 +3,10 @@ namespace MailJanitor.Repositories { public class MessagesRepository : Repository { - public MessagesRepository(MailJanitorContext db, CancellationToken cancellationToken = default(CancellationToken)) : base(db, cancellationToken) + internal MessagesRepository(MailJanitorContext db, CancellationToken cancellationToken = default) : base(db, cancellationToken) { } } } Index: Repositories/ParticipantsRepository.cs ================================================================== --- Repositories/ParticipantsRepository.cs +++ Repositories/ParticipantsRepository.cs @@ -3,10 +3,10 @@ namespace MailJanitor.Repositories { public class ParticipantsRepository : Repository { - public ParticipantsRepository(MailJanitorContext db, CancellationToken cancellationToken = default(CancellationToken)) : base(db, cancellationToken) + internal ParticipantsRepository(MailJanitorContext db, CancellationToken cancellationToken = default) : base(db, cancellationToken) { } } } Index: Repositories/Repository.cs ================================================================== --- Repositories/Repository.cs +++ Repositories/Repository.cs @@ -1,8 +1,9 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Linq.Expressions; using System.Threading; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; @@ -14,52 +15,54 @@ { protected readonly MailJanitorContext _db; protected readonly DbSet _set; protected readonly CancellationToken _cancellationToken; - public Repository(MailJanitorContext db, CancellationToken cancellationToken) + internal Repository(MailJanitorContext db, CancellationToken cancellationToken) { - _db = db; + _db = db ?? throw new ArgumentNullException(nameof(db)); _set = _db.Set(); _cancellationToken = cancellationToken; } public Task GetByIdAsync(long id) { - return _set.FindAsync(new object[] { id }, _cancellationToken); + return _set.FindAsync(new object[] { id }, _cancellationToken).AsTask(); } public Task SingleOrDefaultAsync(Expression> where) { return _set.SingleOrDefaultAsync(where, _cancellationToken); } - public IEnumerable GetList(Expression> where = null) + public IEnumerable GetList(Expression>? where = null) { IQueryable result = _set; if (where != null) result = result.Where(where); return result.AsEnumerable(); } public IEnumerable GetList(Expression> where, Expression> orderBy, - IComparer keyComparer = null) + IComparer? keyComparer = null) { return _set .Where(where) .OrderBy(orderBy, keyComparer ?? Comparer.Default) .AsEnumerable(); } public Task> GetDictionaryAsync(Func keySelector, - IEqualityComparer keyComparer = null) + IEqualityComparer? keyComparer = null) + where TKey: notnull { - return GetDictionaryAsync((Expression>)null, keySelector, keyComparer); + return GetDictionaryAsync((Expression>?)null, keySelector, keyComparer); } - public Task> GetDictionaryAsync(Expression> where, + public Task> GetDictionaryAsync(Expression>? where, Func keySelector, - IEqualityComparer keyComparer = null) + IEqualityComparer? keyComparer = null) + where TKey : notnull { IQueryable result = _set; if (where != null) result = result.Where(where); return result.ToDictionaryAsync(keySelector, @@ -66,53 +69,55 @@ keyComparer ?? EqualityComparer.Default, _cancellationToken); } public Task> GetDictionaryAsync(Func keySelector, Func valueSelector, - IEqualityComparer keyComparer = null) + IEqualityComparer? keyComparer = null) + where TKey : notnull { return GetDictionaryAsync(null, keySelector, valueSelector, keyComparer); } - public Task> GetDictionaryAsync(Expression> where, + public Task> GetDictionaryAsync(Expression>? where, Func keySelector, Func valueSelector, - IEqualityComparer keyComparer = null) + IEqualityComparer? keyComparer = null) + where TKey : notnull { IQueryable result = _set; if (where != null) result = result.Where(where); - return result.ToDictionaryAsync(keySelector, - valueSelector, - keyComparer ?? EqualityComparer.Default, - _cancellationToken); + return result.ToDictionaryAsync(keySelector, + valueSelector, + keyComparer ?? EqualityComparer.Default, + _cancellationToken); } public ILookup GetLookup(Func keySelector, - IEqualityComparer keyComparer = null) + IEqualityComparer? keyComparer = null) { - return GetLookup((Expression>)null, keySelector, keyComparer); + return GetLookup((Expression>?)null, keySelector, keyComparer); } - public ILookup GetLookup(Expression> where, + public ILookup GetLookup(Expression>? where, Func keySelector, - IEqualityComparer keyComparer = null) + IEqualityComparer? keyComparer = null) { IQueryable result = _set; if (where != null) result = result.Where(where); return result.ToLookup(keySelector, keyComparer ?? EqualityComparer.Default); } public ILookup GetLookup(Func keySelector, Func valueSelector, - IEqualityComparer keyComparer = null) + IEqualityComparer? keyComparer = null) { return GetLookup(null, keySelector, valueSelector, keyComparer); } - public ILookup GetLookup(Expression> where, + public ILookup GetLookup(Expression>? where, Func keySelector, Func valueSelector, - IEqualityComparer keyComparer = null) + IEqualityComparer? keyComparer = null) { IQueryable result = _set; if (where != null) result = result.Where(where); return result.ToLookup(keySelector, @@ -120,19 +125,19 @@ keyComparer ?? EqualityComparer.Default); } public IEnumerable Select(Expression> where, Expression> valueExpression, - Expression> valueFilter = null, - IComparer valueSorter = null) + Expression>? valueFilter = null, + IComparer? valueSorter = null) { return Select(valueExpression, where, valueFilter, valueSorter); } public IEnumerable Select(Expression> valueExpression, - Expression> entityFilter = null, - Expression> valueFilter = null, - IComparer valueSorter = null) + Expression>? entityFilter = null, + Expression>? valueFilter = null, + IComparer? valueSorter = null) { IQueryable list = _set; if (entityFilter != null) list = list.Where(entityFilter); IQueryable result = list.Select(valueExpression); @@ -156,10 +161,15 @@ { Update((IEnumerable)entities); } public void Update(IEnumerable entities) { + if (entities is null) + { + throw new ArgumentNullException(nameof(entities)); + } + foreach (T entity in entities) { _db.Entry(entity).State = EntityState.Modified; } } @@ -182,11 +192,11 @@ IEnumerable ids = _set.Where(predicate).Select(idSelector).AsEnumerable(); return DeleteByIDs(ids); } public int DeleteByID(params TKey[] ids) { - return DeleteByIDs((IEnumerable)ids); + return DeleteByIDs(ids); } public virtual int DeleteByIDs(IEnumerable ids) { if (typeof(long).IsAssignableFrom(typeof(TKey))) { @@ -196,18 +206,18 @@ { IProperty key = keyProperties[0]; if (typeof(long).IsAssignableFrom(key.ClrType)) { // Ensure we've got Int64s, and nothing else - var numericIDs = ids.Select(id => Convert.ToInt64(id)); + var numericIDs = ids.Select(id => Convert.ToInt64(id, CultureInfo.InvariantCulture)); // Prepare the SQL statement - string tableName = entityType.Relational().TableName; - string keyColumn = key.Relational().ColumnName; + string tableName = entityType.GetTableName(); + string keyColumn = key.GetColumnName(); string sql = $"DELETE FROM [{tableName}] WHERE [{keyColumn}] IN ({string.Join(", ", numericIDs)})"; #pragma warning disable EF1000 // SQL command has been checked for SQL injection vectors, and found safe. - int numDeleted = _db.Database.ExecuteSqlCommand(sql); + int numDeleted = _db.Database.ExecuteSqlRaw(sql); #pragma warning restore EF1000 // Detach all corresponding entries var entries = _db.ChangeTracker.Entries() .Where(e => e.Metadata == entityType) Index: UnitOfWork.cs ================================================================== --- UnitOfWork.cs +++ UnitOfWork.cs @@ -12,11 +12,11 @@ private readonly MailJanitorContext _db; private readonly CancellationToken _cancellationToken; public MailJanitorContext DB { get => _db; } - public UnitOfWork(CancellationToken cancellationToken = default(CancellationToken)) + public UnitOfWork(CancellationToken cancellationToken = default) { _cancellationToken = cancellationToken; _db = new MailJanitorContext(); _db.ChangeTracker.LazyLoadingEnabled = false; Accounts = new AccountsRepository(_db, _cancellationToken); @@ -34,15 +34,19 @@ public MessagesRepository Messages { get; private set; } public ParticipantsRepository Participants { get; private set; } public KeywordsRepository Keywords { get; private set; } public MessageReferencesRepository MessageReferences { get; private set; } - public void LoadProperty(T entity, Expression>> propertyExpression) where T : class where TProperty : class + public void LoadProperty(T entity, Expression>> propertyExpression) + where T : class + where TProperty : class { - _db.Entry(entity).Collection(propertyExpression); + _db.Entry(entity).Collection(propertyExpression); } - public void LoadProperty(T entity, Expression> propertyExpression) where T : class where TProperty : class + public void LoadProperty(T entity, Expression> propertyExpression) + where T : class + where TProperty : class { _db.Entry(entity).Reference(propertyExpression); } public Task SaveChangesAsync() @@ -63,16 +67,10 @@ // dispose managed state (managed objects). _db.Dispose(); } // set large fields to null. - Accounts = null; - Folders = null; - FolderMessages = null; - Messages = null; - Participants = null; - Keywords = null; _disposedValue = true; } } @@ -79,10 +77,11 @@ // This code added to correctly implement the disposable pattern. public void Dispose() { // Do not change this code. Put cleanup code in Dispose(bool disposing) above. Dispose(true); + GC.SuppressFinalize(this); } #endregion } } Index: Utilities/ConsoleExtensions.cs ================================================================== --- Utilities/ConsoleExtensions.cs +++ Utilities/ConsoleExtensions.cs @@ -3,11 +3,11 @@ using System.Diagnostics; using System.Threading.Tasks; namespace MC.Utilities { - public static class Con + internal static class Con { public static readonly Dictionary LevelColors = new Dictionary { { TraceLevel.Off, ConsoleColor.Black }, { TraceLevel.Verbose, ConsoleColor.DarkGray }, @@ -41,11 +41,11 @@ if (level.HasFlag(TraceLevel.Error | TraceLevel.Warning)) WriteErr(text, color); else Write(text, color); } - + public static void WriteLine(string text, ConsoleColor color) { Write(text + Environment.NewLine, color); } public static void Write(string text, ConsoleColor color) @@ -106,11 +106,11 @@ if (level.HasFlag(TraceLevel.Error | TraceLevel.Warning)) await WriteErrAsync(text, color); else await WriteAsync(text, color); } - + public static async Task WriteLineAsync(string text, ConsoleColor color) { await WriteAsync(text + Environment.NewLine, color); } public static async Task WriteLineAsync(Exception ex, ConsoleColor color) @@ -148,7 +148,7 @@ { Console.ForegroundColor = oldColor; } } - } + } } Index: mailjanitor.code-workspace ================================================================== --- mailjanitor.code-workspace +++ mailjanitor.code-workspace @@ -2,7 +2,11 @@ "folders": [ { "path": "." } ], - "settings": {} + "settings": { + "files.exclude": { + "**/obj": true + } + } }