diff --git a/MailKit/Caching/BinaryMessageSummaryIndex.cs b/MailKit/Caching/BinaryMessageSummaryIndex.cs new file mode 100644 index 0000000000..dea5eef87c --- /dev/null +++ b/MailKit/Caching/BinaryMessageSummaryIndex.cs @@ -0,0 +1,1013 @@ +// +// BinaryMessageSummaryIndex.cs +// +// Author: Jeffrey Stedfast +// +// Copyright (c) 2013-2024 .NET Foundation and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Diagnostics; +using System.Collections.Generic; + +using MimeKit; +using MimeKit.Utils; +using MailKit.Search; + +namespace MailKit.Caching { + public class BinaryMessageSummaryIndex : IMessageSummaryIndex + { + static readonly FormatOptions formatOptions; + const string TempIndexFileName = "messages.tmp"; + const string IndexFileName = "messages.index"; + const int ExpectedVersion = 1; + + string baseCacheDir, cacheDir; + List messages; + IndexHeader header; + + static BinaryMessageSummaryIndex () + { + formatOptions = FormatOptions.Default.Clone (); + formatOptions.NewLineFormat = NewLineFormat.Dos; + } + + public BinaryMessageSummaryIndex (string baseCacheDir, string folderFullName) + { + this.baseCacheDir = baseCacheDir; + this.cacheDir = Path.Combine (baseCacheDir, EncodeFolderName (folderFullName)); + } + + /// + /// Encode a folder specifier so that it can be used as part of a file system temp. + /// + /// The folder. + /// An encoded specifier that is safe to be used in a file system temp. + static string EncodeFolderName (string fullName) + { + var builder = new StringBuilder (); + + for (int i = 0; i < fullName.Length; i++) { + switch (fullName[i]) { + case '%': builder.Append ("%25"); break; + case '/': builder.Append ("%2F"); break; + case ':': builder.Append ("%3A"); break; + case '\\': builder.Append ("%5C"); break; + default: builder.Append (fullName[i]); break; + } + } + + return builder.ToString (); + } + + class IndexHeader + { + public int Version; + + public uint UidNext; + public uint UidValidity; + public ulong HighestModSeq; + } + + public uint? UidNext { get { return header?.UidNext; } } + + public uint? UidValidity { get { return header?.UidValidity; } } + + public ulong? HighestModSeq { get { return header?.HighestModSeq; } } + + public int Count { get { return messages?.Count ?? 0; } } + + public bool IsDirty { get; private set; } + + static DateTimeOffset? LoadDateTimeOffset (BinaryReader reader) + { + var ticks = reader.ReadInt64 (); + + if (ticks == 0) + return null; + + var offset = reader.ReadInt32 (); + + return new DateTimeOffset (ticks, TimeSpan.FromSeconds (offset)); + } + + static void SaveDateTimeOffset (BinaryWriter writer, DateTimeOffset? dateTime) + { + if (dateTime.HasValue) { + writer.Write (dateTime.Value.Ticks); + writer.Write ((int) dateTime.Value.Offset.TotalSeconds); + } else { + writer.Write (0L); + } + } + + static void LoadInternetAddressList (BinaryReader reader, InternetAddressList list) + { + var value = reader.ReadString (); + + if (string.IsNullOrEmpty (value)) + return; + + list.AddRange (InternetAddressList.Parse (value)); + } + + static void SaveInternetAddressList (BinaryWriter writer, InternetAddressList list) + { + writer.Write (list.ToString ()); + } + + static Envelope LoadEnvelope (BinaryReader reader) + { + var envelope = new Envelope (); + + envelope.Date = LoadDateTimeOffset (reader); + envelope.Subject = reader.ReadString (); + LoadInternetAddressList (reader, envelope.From); + LoadInternetAddressList (reader, envelope.Sender); + LoadInternetAddressList (reader, envelope.ReplyTo); + LoadInternetAddressList (reader, envelope.To); + LoadInternetAddressList (reader, envelope.Cc); + LoadInternetAddressList (reader, envelope.Bcc); + envelope.InReplyTo = reader.ReadString (); + envelope.MessageId = reader.ReadString (); + + return envelope; + } + + static void SaveEnvelope (BinaryWriter writer, Envelope envelope) + { + SaveDateTimeOffset (writer, envelope.Date); + writer.Write (envelope.Subject); + SaveInternetAddressList (writer, envelope.From); + SaveInternetAddressList (writer, envelope.Sender); + SaveInternetAddressList (writer, envelope.ReplyTo); + SaveInternetAddressList (writer, envelope.To); + SaveInternetAddressList (writer, envelope.Cc); + SaveInternetAddressList (writer, envelope.Bcc); + writer.Write (envelope.InReplyTo); + writer.Write (envelope.MessageId); + } + + static string[] LoadStrings (BinaryReader reader) + { + int n = reader.ReadInt32 (); + var array = new string[n]; + + for (int i = 0; i < n; i++) + array[i] = reader.ReadString (); + + return array; + } + + static void SaveStrings (BinaryWriter writer, string[] array) + { + if (array == null) { + writer.Write (0); + return; + } + + writer.Write (array.Length); + for (int i = 0; i < array.Length; i++) + writer.Write (array[i] ?? string.Empty); + } + + static Uri LoadUri (BinaryReader reader) + { + var uri = reader.ReadString (); + + if (string.IsNullOrEmpty (uri)) + return null; + + return new Uri (uri); + } + + static void SaveUri (BinaryWriter writer, Uri uri) + { + if (uri != null) + writer.Write (uri.ToString ()); + else + writer.Write (string.Empty); + } + + static void LoadBodyPartBasic (BinaryReader reader, BodyPartBasic basic) + { + basic.PartSpecifier = reader.ReadString (); + basic.ContentType = ContentType.Parse (reader.ReadString ()); + basic.ContentDisposition = ContentDisposition.Parse (reader.ReadString ()); + basic.ContentTransferEncoding = reader.ReadString (); + basic.ContentDescription = reader.ReadString (); + basic.ContentId = reader.ReadString (); + basic.ContentMd5 = reader.ReadString (); + basic.ContentLanguage = LoadStrings (reader); + basic.ContentLocation = LoadUri (reader); + basic.Octets = reader.ReadUInt32 (); + } + + static void SaveBodyPartBasic (BinaryWriter writer, BodyPartBasic basic) + { + writer.Write (basic.PartSpecifier); + writer.Write (basic.ContentType.ToString ()); + writer.Write (basic.ContentDisposition.ToString ()); + writer.Write (basic.ContentTransferEncoding); + writer.Write (basic.ContentDescription); + writer.Write (basic.ContentId); + writer.Write (basic.ContentMd5); + SaveStrings (writer, basic.ContentLanguage); + SaveUri (writer, basic.ContentLocation); + writer.Write (basic.Octets); + } + + static void LoadBodyPartText (BinaryReader reader, BodyPartText text) + { + LoadBodyPartBasic (reader, text); + text.Lines = reader.ReadUInt32 (); + } + + static void SaveBodyPartText (BinaryWriter writer, BodyPartText text) + { + SaveBodyPartBasic (writer, text); + writer.Write (text.Lines); + } + + static void LoadBodyPartMessage (BinaryReader reader, BodyPartMessage rfc822) + { + LoadBodyPartBasic (reader, rfc822); + rfc822.Envelope = LoadEnvelope (reader); + rfc822.Body = LoadBodyStructure (reader); + rfc822.Lines = reader.ReadUInt32 (); + } + + static void SaveBodyPartMessage (BinaryWriter writer, BodyPartMessage rfc822) + { + SaveBodyPartBasic (writer, rfc822); + SaveEnvelope (writer, rfc822.Envelope); + SaveBodyStructure (writer, rfc822.Body); + writer.Write (rfc822.Lines); + } + + static void LoadBodyPartMultipart (BinaryReader reader, BodyPartMultipart multipart) + { + multipart.PartSpecifier = reader.ReadString (); + multipart.ContentType = ContentType.Parse (reader.ReadString ()); + multipart.ContentDisposition = ContentDisposition.Parse (reader.ReadString ()); + multipart.ContentLanguage = LoadStrings (reader); + multipart.ContentLocation = LoadUri (reader); + + int n = reader.ReadInt32 (); + for (int i = 0; i < n; i++) + multipart.BodyParts.Add (LoadBodyStructure (reader)); + } + + static void SaveBodyPartMultipart (BinaryWriter writer, BodyPartMultipart multipart) + { + writer.Write (multipart.PartSpecifier); + writer.Write (multipart.ContentType.ToString ()); + writer.Write (multipart.ContentDisposition.ToString ()); + SaveStrings (writer, multipart.ContentLanguage); + SaveUri (writer, multipart.ContentLocation); + + writer.Write (multipart.BodyParts.Count); + foreach (var part in multipart.BodyParts) + SaveBodyStructure (writer, part); + } + + enum BodyPartType + { + Basic, + Text, + Message, + Multipart + } + + static BodyPart LoadBodyStructure (BinaryReader reader) + { + var type = (BodyPartType) reader.ReadInt32 (); + + switch (type) { + case BodyPartType.Basic: + var basic = new BodyPartBasic (); + LoadBodyPartBasic (reader, basic); + return basic; + case BodyPartType.Text: + var text = new BodyPartText (); + LoadBodyPartText (reader, text); + return text; + case BodyPartType.Message: + var rfc822 = new BodyPartMessage (); + LoadBodyPartMessage (reader, rfc822); + return rfc822; + case BodyPartType.Multipart: + var multipart = new BodyPartMultipart (); + LoadBodyPartMultipart (reader, multipart); + return multipart; + default: + throw new NotSupportedException (); + } + } + + static void SaveBodyStructure (BinaryWriter writer, BodyPart body) + { + if (body is BodyPartText text) { + writer.Write ((int) BodyPartType.Text); + SaveBodyPartText (writer, text); + } else if (body is BodyPartMessage rfc822) { + writer.Write ((int) BodyPartType.Message); + SaveBodyPartMessage (writer, rfc822); + } else if (body is BodyPartMultipart multipart) { + writer.Write ((int) BodyPartType.Multipart); + SaveBodyPartMultipart (writer, multipart); + } else { + var basic = (BodyPartBasic) body; + writer.Write ((int) BodyPartType.Basic); + SaveBodyPartBasic (writer, basic); + } + } + + static List LoadAnnotations (BinaryReader reader) + { + var annotations = new List (); + var n = reader.ReadInt32 (); + + for (int i = 0; i < n; i++) { + var path = reader.ReadString (); + var entry = AnnotationEntry.Parse (path); + var annotation = new Annotation (entry); + + annotations.Add (annotation); + + var nattrs = reader.ReadInt32 (); + for (int j = 0; j < nattrs; j++) { + var specifier = reader.ReadString (); + var value = reader.ReadString (); + + var attribute = new AnnotationAttribute (specifier); + annotation.Properties[attribute] = value; + } + } + + return annotations; + } + + static void SaveAnnotations (BinaryWriter writer, IReadOnlyList annotations) + { + writer.Write (annotations.Count); + + foreach (var annotation in annotations) { + writer.Write (annotation.Entry.Entry); + writer.Write (annotation.Properties.Count); + + foreach (var attribute in annotation.Properties) { + writer.Write (attribute.Key.Specifier); + writer.Write (attribute.Value); + } + } + } + + static MessageSummary LoadMessageSummary (BinaryReader reader, ref byte[] buffer, uint uidValidity, int index, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested (); + + var summary = new MessageSummary (index); + var fields = (MessageSummaryItems) reader.ReadInt32 (); + string references = null; + + summary.Fields = fields; + + if ((fields & MessageSummaryItems.UniqueId) != 0) + summary.UniqueId = new UniqueId (uidValidity, reader.ReadUInt32 ()); + + if ((fields & MessageSummaryItems.ModSeq) != 0) + summary.ModSeq = reader.ReadUInt64 (); + + if ((fields & MessageSummaryItems.Flags) != 0) { + summary.Flags = (MessageFlags) reader.ReadUInt32 (); + + int n = reader.ReadInt32 (); + for (int i = 0; i < n; i++) + ((HashSet) summary.Keywords).Add (reader.ReadString ()); + } + + if ((fields & MessageSummaryItems.InternalDate) != 0) + summary.InternalDate = LoadDateTimeOffset (reader); + + if ((fields & MessageSummaryItems.Size) != 0) + summary.Size = reader.ReadUInt32 (); + + if ((fields & MessageSummaryItems.Envelope) != 0) + summary.Envelope = LoadEnvelope (reader); + + if ((fields & MessageSummaryItems.Headers) != 0) { + int n = reader.ReadInt32 (); + + if (buffer.Length < n) + Array.Resize (ref buffer, n); + + reader.Read (buffer, 0, n); + + using (var stream = new MemoryStream (buffer, 0, n, false)) + summary.Headers = HeaderList.Load (stream, cancellationToken); + + if ((fields & MessageSummaryItems.References) != 0) { + references = summary.Headers[HeaderId.References]; + summary.References = new MessageIdList (); + } + } else if ((fields & MessageSummaryItems.References) != 0) { + summary.References = new MessageIdList (); + references = reader.ReadString (); + } + + if (references != null) { + foreach (var msgid in MimeUtils.EnumerateReferences (references)) + summary.References.Add (msgid); + } + + if ((fields & (MessageSummaryItems.Body | MessageSummaryItems.BodyStructure)) != 0) + summary.Body = LoadBodyStructure (reader); + + if ((fields & MessageSummaryItems.PreviewText) != 0) + summary.PreviewText = reader.ReadString (); + + // GMail fields + if ((fields & MessageSummaryItems.GMailLabels) != 0) { + int n = reader.ReadInt32 (); + + for (int i = 0; i < n; i++) + summary.GMailLabels.Add (reader.ReadString ()); + } + + if ((fields & MessageSummaryItems.GMailMessageId) != 0) + summary.GMailMessageId = reader.ReadUInt64 (); + + if ((fields & MessageSummaryItems.GMailThreadId) != 0) + summary.GMailThreadId = reader.ReadUInt64 (); + + // Uncommon fields + if ((fields & MessageSummaryItems.Annotations) != 0) + summary.Annotations = LoadAnnotations (reader); + + if ((fields & MessageSummaryItems.EmailId) != 0) + summary.EmailId = reader.ReadString (); + + if ((fields & MessageSummaryItems.ThreadId) != 0) + summary.ThreadId = reader.ReadString (); + + if ((fields & MessageSummaryItems.SaveDate) != 0) + summary.SaveDate = LoadDateTimeOffset (reader); + + return summary; + } + + static void SaveMessageSummary (BinaryWriter writer, MemoryStream memory, IMessageSummary summary, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested (); + + writer.Write ((int) summary.Fields); + + if ((summary.Fields & MessageSummaryItems.UniqueId) != 0) + writer.Write (summary.UniqueId.Id); + + if ((summary.Fields & MessageSummaryItems.ModSeq) != 0) + writer.Write (summary.ModSeq ?? 0); + + if ((summary.Fields & MessageSummaryItems.Flags) != 0) { + writer.Write ((uint) summary.Flags); + + writer.Write (summary.Keywords.Count); + foreach (var keyword in summary.Keywords) + writer.Write (keyword); + } + + if ((summary.Fields & MessageSummaryItems.InternalDate) != 0) + SaveDateTimeOffset (writer, summary.InternalDate); + + if ((summary.Fields & MessageSummaryItems.Size) != 0) + writer.Write (summary.Size ?? 0); + + if ((summary.Fields & MessageSummaryItems.Envelope) != 0) + SaveEnvelope (writer, summary.Envelope); + + if ((summary.Fields & MessageSummaryItems.Headers) != 0) { + memory.Position = 0; + summary.Headers.WriteTo (formatOptions, memory); + + var buffer = memory.GetBuffer (); + int length = (int) memory.Position; + + writer.Write (length); + writer.Write (buffer, 0, length); + } else if ((summary.Fields & MessageSummaryItems.References) != 0) { + writer.Write (summary.References.ToString ()); + } + + if ((summary.Fields & (MessageSummaryItems.Body | MessageSummaryItems.BodyStructure)) != 0) + SaveBodyStructure (writer, summary.Body); + + if ((summary.Fields & MessageSummaryItems.PreviewText) != 0) + writer.Write (summary.PreviewText); + + // GMail fields + if ((summary.Fields & MessageSummaryItems.GMailLabels) != 0) { + writer.Write (summary.GMailLabels.Count); + + foreach (var label in summary.GMailLabels) + writer.Write (label); + } + + if ((summary.Fields & MessageSummaryItems.GMailMessageId) != 0) + writer.Write (summary.GMailMessageId ?? 0); + + if ((summary.Fields & MessageSummaryItems.GMailThreadId) != 0) + writer.Write (summary.GMailThreadId ?? 0); + + // Uncommon fields + if ((summary.Fields & MessageSummaryItems.Annotations) != 0) + SaveAnnotations (writer, summary.Annotations); + + if ((summary.Fields & MessageSummaryItems.EmailId) != 0) + writer.Write (summary.EmailId); + + if ((summary.Fields & MessageSummaryItems.ThreadId) != 0) + writer.Write (summary.ThreadId); + + if ((summary.Fields & MessageSummaryItems.SaveDate) != 0) + SaveDateTimeOffset (writer, summary.SaveDate); + } + + public void Load (CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested (); + + var path = Path.Combine (cacheDir, IndexFileName); + + using (var stream = File.OpenRead (path)) { + using (var reader = new BinaryReader (stream, Encoding.UTF8, true)) { + var buffer = new byte[4096]; + + header = new IndexHeader (); + + try { + header.Version = reader.ReadInt32 (); + + if (header.Version != ExpectedVersion) + throw new FormatException ("Unexpected database version."); + + header.UidNext = reader.ReadUInt32 (); + header.UidValidity = reader.ReadUInt32 (); + header.HighestModSeq = reader.ReadUInt64 (); + + cancellationToken.ThrowIfCancellationRequested (); + + int count = reader.ReadInt32 (); + messages = new List (count); + for (int i = 0; i < count; i++) { + var message = LoadMessageSummary (reader, ref buffer, header.UidValidity, i, cancellationToken); + messages.Add (message); + } + } catch (Exception) { + messages = null; + header = null; + throw; + } + } + } + } + + public void Save (CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested (); + + bool dirExists = Directory.Exists (cacheDir); + + if (!dirExists) + Directory.CreateDirectory (cacheDir); + + var temp = Path.Combine (cacheDir, TempIndexFileName); + + try { + using (var stream = File.Create (temp, 4096)) { + using (var writer = new BinaryWriter (stream, Encoding.UTF8, true)) { + writer.Write (ExpectedVersion); + + writer.Write (header.UidNext); + writer.Write (header.UidValidity); + writer.Write (header.HighestModSeq); + + writer.Write (messages.Count); + + using (var memory = new MemoryStream ()) { + for (int i = 0; i < messages.Count; i++) + SaveMessageSummary (writer, memory, messages[i], cancellationToken); + } + } + } + + var path = Path.Combine (cacheDir, IndexFileName); + + if (File.Exists (path)) + File.Replace (temp, path, null); + else + File.Move (temp, path); + } catch (Exception) { + if (File.Exists (temp)) + File.Delete (temp); + + if (!dirExists) + Directory.Delete (cacheDir); + + throw; + } + } + + public void OnUidValidityChanged (uint uidValidity) + { + if (header is null || header.UidValidity == uidValidity) + return; + + header.UidValidity = uidValidity; + messages.Clear (); + IsDirty = true; + + // Delete the summary index file if it exists because it is no longer valid. + var path = Path.Combine (cacheDir, IndexFileName); + + if (File.Exists (path)) + File.Delete (path); + } + + public void OnUidNextChanged (uint nextUid) + { + if (header is null || header.UidNext == nextUid) + return; + + header.UidNext = nextUid; + IsDirty = true; + } + + public void OnHighestModSeqChanged (ulong highestModSeq) + { + if (header is null || header.HighestModSeq == highestModSeq) + return; + + header.HighestModSeq = highestModSeq; + IsDirty = true; + } + + public void Rename (string newFullName) + { + if (newFullName == null) + throw new ArgumentNullException (nameof (newFullName)); + + var newCacheDir = Path.Combine (baseCacheDir, EncodeFolderName (newFullName)); + + if (Directory.Exists (cacheDir)) + Directory.Move (cacheDir, newCacheDir); + + cacheDir = newCacheDir; + } + + public void Delete () + { + header = null; + messages = null; + IsDirty = false; + + Directory.Delete (cacheDir, true); + } + + void Update (int index, IMessageSummary updated) + { + var message = messages[index]; + + if (message.UniqueId.IsValid && updated.UniqueId.IsValid) + Debug.Assert (message.UniqueId.Id == updated.UniqueId.Id); + + message.Fields |= updated.Fields; + + if ((updated.Fields & MessageSummaryItems.UniqueId) != 0) + message.UniqueId = updated.UniqueId; + + if ((updated.Fields & MessageSummaryItems.ModSeq) != 0) + message.ModSeq = updated.ModSeq; + + if ((updated.Fields & MessageSummaryItems.Flags) != 0) { + message.Fields |= MessageSummaryItems.Flags; + message.Flags = updated.Flags; + + var keywords = (HashSet) message.Keywords; + + keywords.Clear (); + foreach (var keyword in updated.Keywords) + keywords.Add (keyword); + } + + if ((updated.Fields & MessageSummaryItems.InternalDate) != 0) + message.InternalDate = updated.InternalDate; + + if ((updated.Fields & MessageSummaryItems.Size) != 0) + message.Size = updated.Size; + + if ((updated.Fields & MessageSummaryItems.Envelope) != 0) + message.Envelope = updated.Envelope; + + if ((updated.Fields & MessageSummaryItems.Headers) != 0) + message.Headers = updated.Headers; + + if ((updated.Fields & MessageSummaryItems.References) != 0) + message.References = updated.References; + + if ((updated.Fields & (MessageSummaryItems.Body | MessageSummaryItems.BodyStructure)) != 0) + message.Body = updated.Body; + + if ((updated.Fields & MessageSummaryItems.PreviewText) != 0) + message.PreviewText = updated.PreviewText; + + // GMail fields + if ((updated.Fields & MessageSummaryItems.GMailLabels) != 0) { + message.GMailLabels.Clear (); + foreach (var label in updated.GMailLabels) + message.GMailLabels.Add (label); + } + + if ((updated.Fields & MessageSummaryItems.GMailMessageId) != 0) + message.GMailMessageId = updated.GMailMessageId; + + if ((updated.Fields & MessageSummaryItems.GMailThreadId) != 0) + message.GMailThreadId = updated.GMailThreadId; + + // Uncommon fields + if ((updated.Fields & MessageSummaryItems.Annotations) != 0) + message.Annotations = updated.Annotations; + + if ((updated.Fields & MessageSummaryItems.EmailId) != 0) + message.EmailId = updated.EmailId; + + if ((updated.Fields & MessageSummaryItems.ThreadId) != 0) + message.ThreadId = updated.ThreadId; + + if ((updated.Fields & MessageSummaryItems.SaveDate) != 0) + message.SaveDate = updated.SaveDate; + } + + public void AddOrUpdate (IMessageSummary message) + { + int index = message.Index; + + if (index >= messages.Count) { + // backfill any missing message summaries that we haven't received yet +#if NET6_0_OR_GREATER + messages.EnsureCapacity (index + 1); +#endif + for (int i = messages.Count; i < index; i++) + messages.Add (new MessageSummary (i)); + + messages.Add (new MessageSummary (index)); + } + + Update (index, message); + IsDirty = true; + } + + public void Expunge (int index) + { + if (index < 0) + throw new ArgumentOutOfRangeException (nameof (index)); + + if (messages == null) + throw new InvalidOperationException (); + + if (index >= messages.Count) + return; + + messages.RemoveAt (index); + IsDirty = true; + } + + static SortOrder GetSortOrder (IEnumerable uids) + { + if (uids is UniqueIdSet @set) + return @set.SortOrder; + + if (uids is UniqueIdRange range) + return range.Start.Id <= range.End.Id ? SortOrder.Ascending : SortOrder.Descending; + + return SortOrder.None; + } + + IEnumerable GetAscendingIndexes (IEnumerable uids) + { + int index = 0; + + foreach (var uid in uids) { + while (index < messages.Count) { + if (messages[index].UniqueId.IsValid) { + if (messages[index].UniqueId.Id == uid.Id) { + yield return index; + break; + } else if (messages[index].UniqueId.Id > uid.Id) { + break; + } + } + + index++; + } + + if (index >= messages.Count) + break; + } + } + + IEnumerable GetDescendingIndexes (IEnumerable uids) + { + int index = messages.Count - 1; + + foreach (var uid in uids) { + while (index >= 0) { + if (messages[index].UniqueId.IsValid) { + if (messages[index].UniqueId.Id == uid.Id) { + yield return index; + break; + } else if (messages[index].UniqueId.Id < uid.Id) { + break; + } + } + + index--; + } + + if (index < 0) + break; + } + } + + IEnumerable GetUnorderedIndexes (IEnumerable uids) + { + var map = new Dictionary (); + int index = 0; + + foreach (var uid in uids) { + if (map.TryGetValue (uid.Id, out var idx)) { + yield return idx; + continue; + } + + while (index < messages.Count) { + if (messages[index].UniqueId.IsValid) { + map.Add (messages[index].UniqueId.Id, index); + + if (messages[index].UniqueId.Id == uid.Id) + yield return index; + } + + index++; + } + } + } + + IEnumerable GetIndexes (IEnumerable uids, out SortOrder order) + { + order = GetSortOrder (uids); + + switch (order) { + case SortOrder.Ascending: return GetAscendingIndexes (uids); + case SortOrder.Descending: return GetDescendingIndexes (uids); + default: return GetUnorderedIndexes (uids); + } + } + + public void Expunge (IEnumerable uids) + { + if (uids == null) + throw new ArgumentNullException (nameof (uids)); + + if (messages == null) + throw new InvalidOperationException (); + + if (messages.Count == 0) + return; + + var indexes = GetIndexes (uids, out var order).ToArray (); + + // Note: We always want to remove indexes in descending order to avoid shifting indexes. + switch (order) { + case SortOrder.Ascending: + for (int i = indexes.Length - 1; i >= 0; i--) + messages.RemoveAt (indexes[i]); + break; + case SortOrder.Descending: + for (int i = 0; i < indexes.Length; i++) + messages.RemoveAt (indexes[i]); + break; + default: + Array.Sort (indexes); + goto case SortOrder.Ascending; + } + } + + public IList Fetch (int min, int max, IFetchRequest request) + { + if (min < 0) + throw new ArgumentOutOfRangeException (nameof (min)); + + if (max != -1 && max < min) + throw new ArgumentOutOfRangeException (nameof (max)); + + if (request == null) + throw new ArgumentNullException (nameof (request)); + + if (messages == null) + throw new InvalidOperationException (); + + if (max == -1) + max = messages.Count - 1; + + var summaries = new List ((max - min) + 1); + var changedSince = request.ChangedSince; + + for (int index = min; index <= max; index++) { + if (!changedSince.HasValue || (messages[index].ModSeq.HasValue && messages[index].ModSeq > changedSince.Value)) + summaries.Add (messages[index]); + } + + return summaries; + } + + public IList Fetch (IList indexes, IFetchRequest request) + { + if (indexes == null) + throw new ArgumentNullException (nameof (indexes)); + + if (request == null) + throw new ArgumentNullException (nameof (request)); + + if (messages == null) + throw new InvalidOperationException (); + + if (indexes.Count == 0) + return Array.Empty (); + + var summaries = new List (indexes.Count); + var changedSince = request.ChangedSince; + + foreach (int index in indexes) { + if (index < 0 || index >= messages.Count) + throw new ArgumentOutOfRangeException (nameof (indexes)); + + if (!changedSince.HasValue || (messages[index].ModSeq.HasValue && messages[index].ModSeq > changedSince.Value)) + summaries.Add (messages[index]); + } + + return summaries; + } + + public IList Fetch (IList uids, IFetchRequest request) + { + if (uids == null) + throw new ArgumentNullException (nameof (uids)); + + if (request == null) + throw new ArgumentNullException (nameof (request)); + + if (messages == null) + throw new InvalidOperationException (); + + if (uids.Count == 0) + return Array.Empty (); + + var summaries = new List (uids.Count); + var changedSince = request.ChangedSince; + + foreach (var index in GetIndexes (uids, out _)) { + if (!changedSince.HasValue || (messages[index].ModSeq.HasValue && messages[index].ModSeq > changedSince.Value)) + summaries.Add (messages[index]); + } + + return summaries; + } + + public void Dispose () + { + } + } +} diff --git a/MailKit/Caching/DbConnectionExtensions.cs b/MailKit/Caching/DbConnectionExtensions.cs new file mode 100644 index 0000000000..dce674a541 --- /dev/null +++ b/MailKit/Caching/DbConnectionExtensions.cs @@ -0,0 +1,148 @@ +// +// DbConnectionExtensions.cs +// +// Author: Jeffrey Stedfast +// +// Copyright (c) 2013-2024 .NET Foundation and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +using System; +using System.Data; +using System.Text; +using System.Threading; +using System.Data.Common; +using System.Threading.Tasks; + +namespace MailKit.Caching { + static class DbConnectionExtensions + { + static void Build (StringBuilder command, DataTable table, DataColumn column, ref int primaryKeys, bool addColumn) + { + command.Append (column.ColumnName); + command.Append (' '); + + if (column.DataType == typeof (long) || column.DataType == typeof (int) || column.DataType == typeof (bool)) { + command.Append ("INTEGER"); + } else if (column.DataType == typeof (byte[])) { + command.Append ("BLOB"); + } else if (column.DataType == typeof (DateTime)) { + command.Append ("DATE"); + } else if (column.DataType == typeof (string)) { + command.Append ("TEXT"); + } else { + throw new NotImplementedException (); + } + + bool isPrimaryKey = false; + if (table != null && table.PrimaryKey != null && primaryKeys < table.PrimaryKey.Length) { + for (int i = 0; i < table.PrimaryKey.Length; i++) { + if (column == table.PrimaryKey[i]) { + command.Append (" PRIMARY KEY"); + isPrimaryKey = true; + primaryKeys++; + break; + } + } + } + + if (column.AutoIncrement) + command.Append (" AUTOINCREMENT"); + + if (column.Unique && !isPrimaryKey) + command.Append (" UNIQUE"); + + // Note: Normally we'd want to include NOT NULL, but we can't *add* new columns with the NOT NULL restriction + if (!addColumn && !column.AllowDBNull) + command.Append (" NOT NULL"); + } + + static string GetCreateTableCommand (DataTable table) + { + var command = new StringBuilder ("CREATE TABLE IF NOT EXISTS "); + int primaryKeys = 0; + + command.Append (table.TableName); + command.Append ('('); + + foreach (DataColumn column in table.Columns) { + Build (command, table, column, ref primaryKeys, false); + command.Append (", "); + } + + if (table.Columns.Count > 0) + command.Length -= 2; + + command.Append (')'); + + return command.ToString (); + } + + public static void CreateTable (this DbConnection connection, DataTable table) + { + using (var command = connection.CreateCommand ()) { + command.CommandText = GetCreateTableCommand (table); + command.CommandType = CommandType.Text; + command.ExecuteNonQuery (); + } + } + + public static async Task CreateTableAsync (this DbConnection connection, DataTable table, CancellationToken cancellationToken) + { + using (var command = connection.CreateCommand ()) { + command.CommandText = GetCreateTableCommand (table); + command.CommandType = CommandType.Text; + + await command.ExecuteNonQueryAsync (cancellationToken).ConfigureAwait (false); + } + } + + static string GetAddColumnCommand (DataTable table, DataColumn column) + { + var command = new StringBuilder ("ALTER TABLE "); + int primaryKeys = table.PrimaryKey?.Length ?? 0; + + command.Append (table.TableName); + command.Append (" ADD COLUMN "); + Build (command, table, column, ref primaryKeys, true); + + return command.ToString (); + } + + public static void AddTableColumn (this DbConnection connection, DataTable table, DataColumn column) + { + using (var command = connection.CreateCommand ()) { + command.CommandText = GetAddColumnCommand (table, column); + command.CommandType = CommandType.Text; + command.ExecuteNonQuery (); + } + } + + public static async Task AddTableColumnAsync (this DbConnection connection, DataTable table, DataColumn column, CancellationToken cancellationToken) + { + using (var command = connection.CreateCommand ()) { + command.CommandText = GetAddColumnCommand (table, column); + command.CommandType = CommandType.Text; + + await command.ExecuteNonQueryAsync (cancellationToken).ConfigureAwait (false); + } + } + } +} diff --git a/MailKit/Caching/DbDataReaderExensions.cs b/MailKit/Caching/DbDataReaderExensions.cs new file mode 100644 index 0000000000..3a3288b128 --- /dev/null +++ b/MailKit/Caching/DbDataReaderExensions.cs @@ -0,0 +1,82 @@ +// +// DbDataReaderExtensions.cs +// +// Author: Jeffrey Stedfast +// +// Copyright (c) 2013-2024 .NET Foundation and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +using System.Data.Common; + +using MimeKit; +using MimeKit.Utils; + +namespace MailKit.Caching { + static class DbDataReaderExensions + { + public static BodyPart GetBodyStructure (this DbDataReader reader, int ordinal) + { + var text = reader.GetString (ordinal); + + if (string.IsNullOrEmpty (text)) + return null; + + BodyPart.TryParse (text, out var body); + + return body; + } + + public static InternetAddressList GetInternetAddressList (this DbDataReader reader, int ordinal) + { + var text = reader.GetString (ordinal); + + return InternetAddressList.Parse (text ?? string.Empty); + } + + public static MessageFlags GetMessageFlags (this DbDataReader reader, int ordinal) + { + return (MessageFlags) reader.GetInt32 (ordinal); + } + + public static MessageIdList GetReferences (this DbDataReader reader, int ordinal) + { + var text = reader.GetString (ordinal); + var references = new MessageIdList (); + + if (!string.IsNullOrEmpty (text)) { + foreach (var msgid in MimeUtils.EnumerateReferences (text)) + references.Add (msgid); + } + + return references; + } + + public static ulong GetUInt64 (this DbDataReader reader, int ordinal) + { + return (ulong) reader.GetInt64 (ordinal); + } + + public static UniqueId GetUniqueId (this DbDataReader reader, int ordinal, uint uidValidity) + { + return new UniqueId (uidValidity, (uint) reader.GetInt64 (ordinal)); + } + } +} diff --git a/MailKit/Caching/IMessageSummaryIndex.cs b/MailKit/Caching/IMessageSummaryIndex.cs new file mode 100644 index 0000000000..ee5dbbfcbf --- /dev/null +++ b/MailKit/Caching/IMessageSummaryIndex.cs @@ -0,0 +1,62 @@ +// +// IMessageSummaryIndex.cs +// +// Author: Jeffrey Stedfast +// +// Copyright (c) 2013-2024 .NET Foundation and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +using System; +using System.Threading; +using System.Collections.Generic; + +namespace MailKit.Caching { + public interface IMessageSummaryIndex : IDisposable + { + // FIXME: Does it make sense to have UIDVALIDITY, UIDNEXT, and HIGHESTMODSEQ values on this interface? Or some other interface? + uint? UidValidity { get; } + uint? UidNext { get; } + ulong? HighestModSeq { get; } + + int Count { get; } + + bool IsDirty { get; } + + void Load (CancellationToken cancellationToken = default); + void Save (CancellationToken cancellationToken = default); + + void OnUidValidityChanged (uint uidValidity); + void OnUidNextChanged (uint nextUid); + void OnHighestModSeqChanged (ulong highestModSeq); + + void Rename (string newFullName); + void Delete (); + + void AddOrUpdate (IMessageSummary message); + + void Expunge (int index); + void Expunge (IEnumerable uids); + + IList Fetch (int min, int max, IFetchRequest request); + IList Fetch (IList indexes, IFetchRequest request); + IList Fetch (IList uids, IFetchRequest request); + } +} diff --git a/MailKit/Caching/SqlMessageSummaryIndex.cs b/MailKit/Caching/SqlMessageSummaryIndex.cs new file mode 100644 index 0000000000..778b52ed5a --- /dev/null +++ b/MailKit/Caching/SqlMessageSummaryIndex.cs @@ -0,0 +1,1145 @@ +// +// SqlMessageSummaryIndex.cs +// +// Author: Jeffrey Stedfast +// +// Copyright (c) 2013-2024 .NET Foundation and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +using System; +using System.IO; +using System.Text; +using System.Data; +using System.Threading; +using System.Data.Common; +using System.Collections.Generic; + +using MimeKit; + +using MailKit.Search; + +#if NET5_0_OR_GREATER +using IReadOnlySetOfStrings = System.Collections.Generic.IReadOnlySet; +#else +using IReadOnlySetOfStrings = System.Collections.Generic.ISet; +#endif + +namespace MailKit.Caching { + public class SqlMessageSummaryIndex : IMessageSummaryIndex + { + static readonly DateTime InvalidDateTime = new DateTime (0, DateTimeKind.Utc); + + static readonly DataTable[] DataTables; + static readonly DataTable MessageTable; + static readonly DataTable KeywordsTable; + static readonly DataTable GMailLabelsTable; + //static readonly DataTable AnnotationsTable; + static readonly DataTable StatusTable; + + static SqlMessageSummaryIndex () + { + MessageTable = CreateMessageTable (); + KeywordsTable = CreateKeywordsTable (); + GMailLabelsTable = CreateGMailLabelsTable (); + //AnnotationsTable = CreateAnnotationsTable (); + StatusTable = CreateStatusTable (); + + DataTables = new DataTable[] { + StatusTable, MessageTable, KeywordsTable, GMailLabelsTable /*, AnnotationsTable */ + }; + } + + static DataTable CreateMessageTable () + { + var table = new DataTable ("MESSAGES"); + table.Columns.Add (new DataColumn ("UID", typeof (long)) { AllowDBNull = false, Unique = true }); + table.Columns.Add (new DataColumn ("FETCHED", typeof (int)) { AllowDBNull = false }); + table.Columns.Add (new DataColumn ("INTERNALDATE", typeof (DateTime)) { AllowDBNull = true }); + table.Columns.Add (new DataColumn ("INTERNALTIMEZONE", typeof (long)) { AllowDBNull = true }); + table.Columns.Add (new DataColumn ("SIZE", typeof (long)) { AllowDBNull = true }); + table.Columns.Add (new DataColumn ("FLAGS", typeof (int)) { AllowDBNull = true }); + table.Columns.Add (new DataColumn ("MODSEQ", typeof (long)) { AllowDBNull = true }); + + // ENVELOPE + table.Columns.Add (new DataColumn ("DATE", typeof (DateTime)) { AllowDBNull = true }); + table.Columns.Add (new DataColumn ("TIMEZONE", typeof (long)) { AllowDBNull = true }); + table.Columns.Add (new DataColumn ("SUBJECT", typeof (string)) { AllowDBNull = true }); + table.Columns.Add (new DataColumn ("FROM", typeof (string)) { AllowDBNull = true }); + table.Columns.Add (new DataColumn ("SENDER", typeof (string)) { AllowDBNull = true }); + table.Columns.Add (new DataColumn ("REPLYTO", typeof (string)) { AllowDBNull = true }); + table.Columns.Add (new DataColumn ("TO", typeof (string)) { AllowDBNull = true }); + table.Columns.Add (new DataColumn ("CC", typeof (string)) { AllowDBNull = true }); + table.Columns.Add (new DataColumn ("BCC", typeof (string)) { AllowDBNull = true }); + table.Columns.Add (new DataColumn ("INREPLYTO", typeof (string)) { AllowDBNull = true }); + table.Columns.Add (new DataColumn ("MESSAGEID", typeof (string)) { AllowDBNull = true }); + + // REFERENCES + table.Columns.Add (new DataColumn ("REFERENCES", typeof (string)) { AllowDBNull = true }); + + // BODYSTRUCTURE + table.Columns.Add (new DataColumn ("BODYSTRUCTURE", typeof (string)) { AllowDBNull = true }); + + // PREVIEWTEXT + table.Columns.Add (new DataColumn ("PREVIEWTEXT", typeof (string)) { AllowDBNull = true }); + + // GMail-specific features + table.Columns.Add (new DataColumn ("XGMMSGID", typeof (long)) { AllowDBNull = true }); + table.Columns.Add (new DataColumn ("XGMTHRID", typeof (long)) { AllowDBNull = true }); + + // OBJECTID extension + table.Columns.Add (new DataColumn ("EMAILID", typeof (string)) { AllowDBNull = true }); + table.Columns.Add (new DataColumn ("THREADID", typeof (string)) { AllowDBNull = true }); + + // SAVEDATE extension + //table.Columns.Add(new DataColumn("SAVEDATE", typeof (DateTime)) { AllowDBNull = true }); + //table.Columns.Add(new DataColumn("SAVEDATETIMEZONE", typeof (long)) { AllowDBNull = true }); + + // Set the UID as the primary key + table.PrimaryKey = new DataColumn[] { table.Columns[0] }; + + return table; + } + + static DataTable CreateKeywordsTable () + { + var table = new DataTable ("KEYWORDS"); + table.Columns.Add (new DataColumn ("ROWID", typeof (int)) { AutoIncrement = true }); + table.Columns.Add (new DataColumn ("UID", typeof (long)) { AllowDBNull = false }); + table.Columns.Add (new DataColumn ("KEYWORD", typeof (string)) { AllowDBNull = false }); + table.PrimaryKey = new DataColumn[] { table.Columns[0] }; + + return table; + } + + static DataTable CreateGMailLabelsTable () + { + var table = new DataTable ("XGMLABELS"); + table.Columns.Add (new DataColumn ("ROWID", typeof (int)) { AutoIncrement = true }); + table.Columns.Add (new DataColumn ("UID", typeof (long)) { AllowDBNull = false }); + table.Columns.Add (new DataColumn ("KEYWORD", typeof (string)) { AllowDBNull = false }); + table.PrimaryKey = new DataColumn[] { table.Columns[0] }; + + return table; + } + + static DataTable CreateStatusTable () + { + var table = new DataTable ("STATUS"); + table.Columns.Add (new DataColumn ("ROWID", typeof (int)) { AllowDBNull = false, Unique = true }); + table.Columns.Add (new DataColumn ("UIDVALIDITY", typeof (long)) { AllowDBNull = false }); + table.Columns.Add (new DataColumn ("UIDNEXT", typeof (long)) { AllowDBNull = true }); + table.Columns.Add (new DataColumn ("HIGHESTMODSEQ", typeof (long)) { AllowDBNull = true }); + + //table.Columns.Add (new DataColumn ("COUNT", typeof (long)) { AllowDBNull = false }); + //table.Columns.Add (new DataColumn ("RECENT", typeof (long)) { AllowDBNull = false }); + //table.Columns.Add (new DataColumn ("UNREAD", typeof (long)) { AllowDBNull = false }); + //table.Columns.Add (new DataColumn ("SIZE", typeof (long)) { AllowDBNull = false }); + + //table.Columns.Add (new DataColumn ("APPENDLIMIT", typeof (long)) { AllowDBNull = true }); + //table.Columns.Add (new DataColumn ("MAILBOXID", typeof (string)) { AllowDBNull = true }); + + table.PrimaryKey = new DataColumn[] { table.Columns[0] }; + + return table; + } + + /// + /// Encode a folder specifier so that it can be used as part of a file system temp. + /// + /// The folder. + /// An encoded specifier that is safe to be used in a file system temp. + static string EncodeFolderName (string fullName) + { + var builder = new StringBuilder (); + + for (int i = 0; i < fullName.Length; i++) { + switch (fullName[i]) { + case '%': builder.Append ("%25"); break; + case '/': builder.Append ("%2F"); break; + case ':': builder.Append ("%3A"); break; + case '\\': builder.Append ("%5C"); break; + default: builder.Append (fullName[i]); break; + } + } + + return builder.ToString (); + } + + const string IndexFileName = "index.sqlite"; + string baseCacheDir, cacheDir; + SQLiteConnection sqlite; + + public SqlMessageSummaryIndex (string baseCacheDir, string folderFullName) + { + this.baseCacheDir = baseCacheDir; + this.cacheDir = Path.Combine (baseCacheDir, EncodeFolderName (folderFullName)); + } + + public ulong? HighestModSeq { + get; private set; + } + + public uint? UidNext { + get; private set; + } + + public uint? UidValidity { + get; private set; + } + + public int Count { + get; private set; + } + + public bool IsDirty { + get; private set; + } + + void Open () + { + if (!Directory.Exists (cacheDir)) + Directory.CreateDirectory (cacheDir); + + var path = Path.Combine (cacheDir, IndexFileName); + var builder = new SQLiteConnectionStringBuilder { + DateTimeFormat = SQLiteDateFormats.ISO8601, + DataSource = path + }; + + sqlite = new SQLiteConnection (builder.ConnectionString); + + sqlite.Open (); + } + + void Close () + { + if (sqlite != null) { + sqlite.Close (); + sqlite.Dispose (); + sqlite = null; + } + } + + DbCommand CreateLoadStatusCommand () + { + var command = sqlite.CreateCommand (); + command.CommandText = $"SELECT * FROM {StatusTable.TableName} WHERE ROWID = @ROWID LIMIT 1"; + command.Parameters.AddWithValue ("@ROWID", 0); + command.CommandType = CommandType.Text; + return command; + } + + void ReadStatus (DbDataReader reader) + { + for (int i = 0; i < reader.FieldCount; i++) { + switch (reader.GetName (i)) { + case "UIDVALIDITY": + UidValidity = (uint) reader.GetInt64 (i); + break; + case "UIDNEXT": + if (!reader.IsDBNull (i)) + UidNext = (uint) reader.GetInt64 (i); + else + UidNext = null; + break; + case "HIGHESTMODSEQ": + if (!reader.IsDBNull (i)) + HighestModSeq = (ulong) reader.GetInt64 (i); + else + HighestModSeq = null; + break; + } + } + } + + bool LoadStatus () + { + using (var command = CreateLoadStatusCommand ()) { + using (var reader = command.ExecuteReader ()) { + if (!reader.Read ()) + return false; + + ReadStatus (reader); + + return true; + } + } + } + + public void Load (CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested (); + + if (sqlite != null) + return; + + Open (); + + foreach (var dataTable in DataTables) + sqlite.CreateTable (dataTable); + + if (LoadStatus ()) + return; + + SaveStatus (); + } + + DbCommand CreateSaveStatusCommand () + { + var command = sqlite.CreateCommand (); + command.Parameters.AddWithValue ("@ROWID", 0); + command.Parameters.AddWithValue ("@UIDVALIDITY", (long) UidValidity); + command.Parameters.AddWithValue ("@UIDNEXT", UidNext.HasValue ? (object) UidNext.Value : null); + command.Parameters.AddWithValue ("@HIGHESTMODSEQ", HighestModSeq.HasValue ? (object) HighestModSeq.Value : null); + + command.CommandText = $"INSERT OR REPLACE INTO {StatusTable.TableName} (ROWID, UIDVALIDITY, UIDNEXT, HIGHESTMODSEQ) VALUES(@ROWID, @UIDVALIDITY, @UIDNEXT, @HIGHESTMODSEQ)"; + command.CommandType = CommandType.Text; + + return command; + } + + void SaveStatus () + { + using (var command = CreateSaveStatusCommand ()) + command.ExecuteNonQuery (); + } + + public void Save (CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested (); + + bool dirExists = Directory.Exists (cacheDir); + + if (!dirExists) + Directory.CreateDirectory (cacheDir); + + SaveStatus (); + } + + void DropTable (string tableName) + { + using (var command = sqlite.CreateCommand ()) { + command.CommandText = $"DROP TABLE IF EXISTS {tableName}"; + command.CommandType = CommandType.Text; + + command.ExecuteNonQuery (); + } + } + + void Clear () + { + // TODO: clear message files as well (once that gets implemented) + using (var transaction = sqlite.BeginTransaction ()) { + DropTable (MessageTable.TableName); + DropTable (KeywordsTable.TableName); + DropTable (GMailLabelsTable.TableName); + + sqlite.CreateTable (MessageTable); + sqlite.CreateTable (KeywordsTable); + sqlite.CreateTable (GMailLabelsTable); + + transaction.Commit (); + } + } + + public void OnUidValidityChanged (uint uidValidity) + { + if (UidValidity == uidValidity) + return; + + Clear (); + + UidValidity = uidValidity; + SaveStatus (); + } + + public void OnUidNextChanged (uint nextUid) + { + if (UidNext == nextUid) + return; + + UidNext = nextUid; + SaveStatus (); + } + + public void OnHighestModSeqChanged (ulong highestModSeq) + { + if (HighestModSeq == highestModSeq) + return; + + HighestModSeq = highestModSeq; + SaveStatus (); + } + + public void Rename (string newFullName) + { + if (newFullName == null) + throw new ArgumentNullException (nameof (newFullName)); + + var reopen = sqlite != null; + + Close (); + + var newCacheDir = Path.Combine (baseCacheDir, EncodeFolderName (newFullName)); + + if (Directory.Exists (cacheDir)) + Directory.Move (cacheDir, newCacheDir); + + cacheDir = newCacheDir; + + if (reopen) + Open (); + } + + public void Delete () + { + Close (); + + IsDirty = false; + + Directory.Delete (cacheDir, true); + } + + bool TryGetUniqueId (int index, out UniqueId uid) + { + using (var command = sqlite.CreateCommand ()) { + command.Parameters.AddWithValue ("@INDEX", (long) index); + + command.CommandText = $"SELECT UID FROM {MessageTable.TableName} ORDER BY UID LIMIT 1 OFFSET @INDEX"; + command.CommandType = CommandType.Text; + + using (var reader = command.ExecuteReader (CommandBehavior.SingleRow)) { + if (reader.Read ()) { + int column = reader.GetOrdinal ("UID"); + + if (column != -1) { + uid = new UniqueId ((uint) reader.GetInt64 (column)); + return true; + } + } + + uid = UniqueId.Invalid; + + return false; + } + } + } + + public IList GetAllCachedUids (CancellationToken cancellationToken = default) + { + using (var command = sqlite.CreateCommand ()) { + command.CommandText = $"SELECT UID FROM {MessageTable.TableName}"; + command.CommandType = CommandType.Text; + + using (var reader = command.ExecuteReader ()) { + var uids = new UniqueIdSet (SortOrder.Ascending); + + while (reader.Read ()) { + int index = reader.GetOrdinal ("UID"); + var uid = (uint) reader.GetInt64 (index); + + uids.Add (new UniqueId (uid)); + } + + return uids; + } + } + } + + /// + /// Get a list of all of the UIDs in the cache where the summary information is incomplete. + /// + /// The summary information that is desired. + /// The cancellation token. + /// The list of all UIDs that do not have all of the desired summary information. + public IList GetIncompleteCachedUids (MessageSummaryItems desiredItems, CancellationToken cancellationToken = default) + { + using (var command = sqlite.CreateCommand ()) { + command.CommandText = $"SELECT UID FROM {MessageTable.TableName} WHERE FETCHED & FIELDS != @FIELDS"; + command.Parameters.AddWithValue ("@FIELDS", (int) desiredItems); + command.CommandType = CommandType.Text; + + using (var reader = command.ExecuteReader ()) { + var uids = new UniqueIdSet (SortOrder.Ascending); + + while (reader.Read (cancellationToken)) { + int index = reader.GetOrdinal ("UID"); + var uid = (uint) reader.GetInt64 (index); + + uids.Add (new UniqueId (uid)); + } + + return uids; + } + } + } + + public void Insert (UniqueId uid) + { + using (var command = sqlite.CreateCommand ()) { + command.CommandText = $"INSERT INTO {MessageTable.TableName} OR IGNORE (UID, FETCHED) VALUES(@UID, @FETCHED)"; + command.Parameters.AddWithValue ("@FETCHED", (int) MessageSummaryItems.UniqueId); + command.Parameters.AddWithValue ("@UID", (long) uid.Id); + command.CommandType = CommandType.Text; + command.ExecuteNonQuery (); + } + } + + object GetValue (UniqueId uid, IMessageSummary message, string columnName) + { + switch (columnName) { + case "UID": + return (long) uid.Id; + case "INTERNALDATE": + return message.InternalDate?.ToUniversalTime ().DateTime; + case "INTERNALTIMEZONE": + return message.InternalDate?.Offset.Ticks; + case "SIZE": + if (message.Size.HasValue) + return (long) message.Size.Value; + return null; + case "FLAGS": + if (message.Flags.HasValue) + return (long) message.Flags.Value; + return null; + case "MODSEQ": + if (message.ModSeq.HasValue) + return (long) message.ModSeq.Value; + return null; + case "DATE": + return message.Envelope?.Date?.ToUniversalTime ().DateTime; + case "TIMEZONE": + return message.Envelope?.Date?.Offset.Ticks; + case "SUBJECT": + return message.Envelope?.Subject; + case "FROM": + return message.Envelope?.From.ToString (); + case "SENDER": + return message.Envelope?.Sender.ToString (); + case "REPLYTO": + return message.Envelope?.ReplyTo.ToString (); + case "TO": + return message.Envelope?.To.ToString (); + case "CC": + return message.Envelope?.Cc.ToString (); + case "BCC": + return message.Envelope?.Bcc.ToString (); + case "INREPLYTO": + return message.Envelope?.InReplyTo; + case "MESSAGEID": + return message.Envelope?.MessageId; + case "REFERENCES": + return message.References?.ToString (); + case "BODYSTRUCTURE": + return message.Body?.ToString (); + case "PREVIEWTEXT": + return message.PreviewText; + case "XGMMSGID": + if (message.GMailMessageId.HasValue) + return (long) message.GMailMessageId.Value; + return null; + case "XGMTHRID": + if (message.GMailThreadId.HasValue) + return (long) message.GMailThreadId.Value; + return null; + case "EMAILID": + return message.EmailId; + case "THREADID": + return message.ThreadId; + //case "SAVEDATE": + // if (message.SaveDate.HasValue) + // return message.SaveDate.Value.ToUniversalTime().DateTime; + // return null; + //case "SAVEDATETIMEZONE": + // if (message.SaveDate.HasValue) + // return message.SaveDate.Value.Offset.Ticks; + // return null; + default: + return null; + } + } + + void UpdateKeywords (UniqueId uid, IReadOnlySetOfStrings keywords) + { + var oldKeywords = new HashSet (StringComparer.OrdinalIgnoreCase); + + LoadKeywords (uid, oldKeywords); + + using (var transaction = sqlite.BeginTransaction ()) { + try { + foreach (var keyword in oldKeywords) { + if (keywords.Contains (keyword)) + continue; + + using (var command = sqlite.CreateCommand ()) { + command.CommandText = $"DELETE FROM {KeywordsTable.TableName} WHERE UID = @UID AND KEYWORD = @KEYWORD"; + command.Parameters.AddWithValue ("@UID", (long) uid.Id); + command.Parameters.AddWithValue ("@KEYWORD", keyword); + command.CommandType = CommandType.Text; + + command.ExecuteNonQuery (); + } + } + + foreach (var keyword in keywords) { + if (oldKeywords.Contains (keyword)) + continue; + + using (var command = sqlite.CreateCommand ()) { + command.CommandText = $"INSERT INTO {KeywordsTable.TableName} (UID, KEYWORD) VALUES(@UID, @KEYWORD)"; + command.Parameters.AddWithValue ("@UID", (long) uid.Id); + command.Parameters.AddWithValue ("@KEYWORD", keyword); + command.CommandType = CommandType.Text; + + command.ExecuteNonQuery (); + } + } + + transaction.Commit (); + } catch { + transaction.Rollback (); + throw; + } + } + } + + void UpdateXGMLabels (UniqueId uid, IReadOnlySetOfStrings labels) + { + var oldLabels = new HashSet (StringComparer.OrdinalIgnoreCase); + + LoadXGMLabels (uid, oldLabels); + + using (var transaction = sqlite.BeginTransaction ()) { + try { + foreach (var label in oldLabels) { + if (labels.Contains (label)) + continue; + + using (var command = sqlite.CreateCommand ()) { + command.CommandText = $"DELETE FROM {GMailLabelsTable.TableName} WHERE UID = @UID AND LABEL = @LABEL"; + command.Parameters.AddWithValue ("@UID", (long) uid.Id); + command.Parameters.AddWithValue ("@LABEL", label); + command.CommandType = CommandType.Text; + + command.ExecuteNonQuery (); + } + } + + foreach (var label in labels) { + if (oldLabels.Contains (label)) + continue; + + using (var command = sqlite.CreateCommand ()) { + command.CommandText = $"INSERT INTO {GMailLabelsTable.TableName} (UID, LABEL) VALUES(@UID, @LABEL)"; + command.Parameters.AddWithValue ("@UID", (long) uid.Id); + command.Parameters.AddWithValue ("@LABEL", label); + command.CommandType = CommandType.Text; + + command.ExecuteNonQuery (); + } + } + + transaction.Commit (); + } catch { + transaction.Rollback (); + throw; + } + } + } + + void AddOrUpdate (UniqueId uid, IMessageSummary message) + { + using (var transaction = sqlite.BeginTransaction ()) { + try { + using (var command = sqlite.CreateCommand ()) { + var columns = GetMessageTableColumns (message.Fields & ~MessageSummaryItems.UniqueId); + var builder = new StringBuilder ($"INSERT INTO {MessageTable.TableName} (UID, FETCHED"); + + for (int i = 0; i < columns.Count; i++) { + builder.Append (", "); + builder.Append (columns[i]); + } + + builder.Append (") VALUES(@UID, @FETCHED"); + command.Parameters.AddWithValue ("@UID", (long) uid.Id); + command.Parameters.AddWithValue ("@FETCHED", (int) message.Fields); + + for (int i = 0; i < columns.Count; i++) { + var value = GetValue (uid, message, columns[i]); + var variable = "@" + columns[i]; + + builder.Append (", "); + builder.Append (variable); + command.Parameters.AddWithValue (variable, value); + } + + builder.Append (") ON CONFLICT(UID) DO UPDATE SET FETCHED = FETCHED | @FETCHED"); + + for (int i = 0; i < columns.Count; i++) + builder.AppendFormat (", {0} = @{0}", columns[i]); + + command.CommandText = builder.ToString (); + command.CommandType = CommandType.Text; + + command.ExecuteNonQuery (); + } + + if ((message.Fields & MessageSummaryItems.Flags) != 0) + UpdateKeywords (uid, message.Keywords); + + if ((message.Fields & MessageSummaryItems.GMailLabels) != 0) { + var labels = new HashSet (message.GMailLabels); + + UpdateXGMLabels (uid, labels); + } + + transaction.Commit (); + } catch { + transaction.Rollback (); + throw; + } + } + } + + public void AddOrUpdate (IMessageSummary message) + { + UniqueId uid; + + if (message.UniqueId.IsValid) + uid = message.UniqueId; + else if (!TryGetUniqueId (message.Index, out uid)) + return; + + AddOrUpdate (uid, message); + IsDirty = true; + } + + DbCommand CreateExpungeMessageCommand (UniqueId uid) + { + var command = sqlite.CreateCommand (); + command.CommandText = $"DELETE FROM {MessageTable.TableName} WHERE UID = @UID"; + command.Parameters.AddWithValue ("@UID", (long) uid.Id); + command.CommandType = CommandType.Text; + return command; + } + + DbCommand CreateExpungeKeywordsCommand (UniqueId uid) + { + var command = sqlite.CreateCommand (); + command.CommandText = $"DELETE FROM {KeywordsTable.TableName} WHERE UID = @UID"; + command.Parameters.AddWithValue ("@UID", (long) uid.Id); + command.CommandType = CommandType.Text; + return command; + } + + DbCommand CreateExpungeXGMLabelsCommand (UniqueId uid) + { + var command = sqlite.CreateCommand (); + command.CommandText = $"DELETE FROM {GMailLabelsTable.TableName} WHERE UID = @UID"; + command.Parameters.AddWithValue ("@UID", (long) uid.Id); + command.CommandType = CommandType.Text; + return command; + } + + void Expunge (UniqueId uid, CancellationToken cancellationToken = default) + { + using (var transaction = sqlite.BeginTransaction ()) { + try { + using (var command = CreateExpungeMessageCommand (uid)) + command.ExecuteNonQuery (); + + using (var command = CreateExpungeKeywordsCommand (uid)) + command.ExecuteNonQuery (); + + using (var command = CreateExpungeXGMLabelsCommand (uid)) + command.ExecuteNonQuery (); + + transaction.Commit (); + } catch { + transaction.Rollback (); + throw; + } + } + } + + public void Expunge (int index) + { + if (TryGetUniqueId (index, out var uid)) + Expunge (uid); + } + + public void Expunge (IEnumerable uids) + { + foreach (var uid in uids) + Expunge (uid); + } + + static List GetMessageTableColumns (MessageSummaryItems items) + { + var columns = new List (); + + if ((items & MessageSummaryItems.UniqueId) != 0) + columns.Add ("UID"); + if ((items & MessageSummaryItems.InternalDate) != 0) { + columns.Add ("INTERNALDATE"); + columns.Add ("INTERNALTIMEZONE"); + } + if ((items & MessageSummaryItems.Size) != 0) + columns.Add ("SIZE"); + if ((items & MessageSummaryItems.Flags) != 0) + columns.Add ("FLAGS"); + if ((items & MessageSummaryItems.ModSeq) != 0) + columns.Add ("MODSEQ"); + if ((items & MessageSummaryItems.Envelope) != 0) { + columns.Add ("DATE"); + columns.Add ("TIMEZONE"); + columns.Add ("SUBJECT"); + columns.Add ("FROM"); + columns.Add ("SENDER"); + columns.Add ("REPLYTO"); + columns.Add ("TO"); + columns.Add ("CC"); + columns.Add ("BCC"); + columns.Add ("INREPLYTO"); + columns.Add ("MESSAGEID"); + } + if ((items & MessageSummaryItems.References) != 0) + columns.Add ("REFERENCES"); + if ((items & (MessageSummaryItems.BodyStructure | MessageSummaryItems.Body)) != 0) + columns.Add ("BODYSTRUCTURE"); + if ((items & MessageSummaryItems.PreviewText) != 0) + columns.Add ("PREVIEWTEXT"); + if ((items & MessageSummaryItems.GMailMessageId) != 0) + columns.Add ("XGMMSGID"); + if ((items & MessageSummaryItems.GMailThreadId) != 0) + columns.Add ("XGMTHRID"); + if ((items & MessageSummaryItems.EmailId) != 0) + columns.Add ("EMAILID"); + if ((items & MessageSummaryItems.ThreadId) != 0) + columns.Add ("THREADID"); + //if ((items & MessageSummaryItems.SaveDate) != 0) { + // columns.Add("SAVEDATE"); + // columns.Add("SAVEDATETIMEZONE"); + //} + + return columns; + } + + static DateTimeOffset GetDateTimeOffset (DateTime utc, long timeZone) + { + var dateTime = new DateTime (utc.Ticks, DateTimeKind.Unspecified); + var offset = new TimeSpan (timeZone); + + dateTime = dateTime.Add (offset); + + return new DateTimeOffset (dateTime, offset); + } + + static void LoadInternetAddressList (InternetAddressList list, DbDataReader reader, int column) + { + try { + var addresses = reader.GetInternetAddressList (column); + list.AddRange (addresses); + addresses.Clear (); + } catch { + } + } + + void LoadMessages (List messages, MessageSummaryItems items, DbDataReader reader, int startIndex) + { + int index = startIndex; + + while (reader.Read ()) { + var message = new MessageSummary (index++); + var internalDate = InvalidDateTime; + //var saveDate = InvalidDateTime; + long internalTimeZone = -1; + //long saveDateTimeZone = -1; + var date = InvalidDateTime; + long timeZone = -1; + + messages.Add (message); + + if ((items & MessageSummaryItems.Envelope) != 0) + message.Envelope = new Envelope (); + + for (int i = 0; i < reader.FieldCount; i++) { + if (reader.IsDBNull (i)) + continue; + + switch (reader.GetName (i)) { + case "UID": + message.UniqueId = reader.GetUniqueId (i, UidValidity!.Value); + break; + case "INTERNALDATE": + internalDate = reader.GetDateTime (i); + break; + case "INTERNALTIMEZONE": + internalTimeZone = reader.GetInt64 (i); + break; + case "SIZE": + message.Size = (uint) reader.GetInt64 (i); + break; + case "FLAGS": + message.Flags = reader.GetMessageFlags (i); + break; + case "MODSEQ": + message.ModSeq = reader.GetUInt64 (i); + break; + case "DATE": + date = reader.GetDateTime (i); + break; + case "TIMEZONE": + timeZone = reader.GetInt64 (i); + break; + case "SUBJECT": + message.Envelope.Subject = reader.GetString (i); + break; + case "FROM": + LoadInternetAddressList (message.Envelope.From, reader, i); + break; + case "SENDER": + LoadInternetAddressList (message.Envelope.Sender, reader, i); + break; + case "REPLYTO": + LoadInternetAddressList (message.Envelope.ReplyTo, reader, i); + break; + case "TO": + LoadInternetAddressList (message.Envelope.To, reader, i); + break; + case "CC": + LoadInternetAddressList (message.Envelope.Cc, reader, i); + break; + case "BCC": + LoadInternetAddressList (message.Envelope.Bcc, reader, i); + break; + case "INREPLYTO": + message.Envelope.InReplyTo = reader.GetString (i); + break; + case "MESSAGEID": + message.Envelope.MessageId = reader.GetString (i); + break; + case "REFERENCES": + message.References = reader.GetReferences (i); + break; + case "BODYSTRUCTURE": + message.Body = reader.GetBodyStructure (i); + break; + case "PREVIEWTEXT": + message.PreviewText = reader.GetString (i); + break; + case "XGMMSGID": + message.GMailMessageId = reader.GetUInt64 (i); + break; + case "XGMTHRID": + message.GMailThreadId = reader.GetUInt64 (i); + break; + case "EMAILID": + message.EmailId = reader.GetString (i); + break; + case "THREADID": + message.ThreadId = reader.GetString (i); + break; + //case "SAVEDATE": + // saveDate = reader.GetDateTime(i); + // break; + //case "SAVEDATETIMEZONE": + // saveDateTimeZone = reader.GetInt64(i); + // break; + } + } + + if (internalDate != InvalidDateTime) + message.InternalDate = GetDateTimeOffset (internalDate, internalTimeZone); + + //if (saveDate != InvalidDateTime) + // message.SaveDate = GetDateTimeOffset(saveDate, saveDateTimeZone); + + if (date != InvalidDateTime) + message.Envelope.Date = GetDateTimeOffset (date, timeZone); + } + } + + void LoadKeywords (UniqueId uid, ISet keywords) + { + using (var command = sqlite.CreateCommand ()) { + command.CommandText = $"SELECT KEYWORD FROM {KeywordsTable.TableName} WHERE UID = @UID"; + command.Parameters.AddWithValue ("@UID", (long) uid.Id); + command.CommandType = CommandType.Text; + + using (var reader = command.ExecuteReader ()) { + while (reader.Read ()) { + var column = reader.GetOrdinal ("KEYWORD"); + + if (column != -1) + keywords.Add (reader.GetString (column)); + } + } + } + } + + void LoadXGMLabels (UniqueId uid, ISet labels) + { + using (var command = sqlite.CreateCommand ()) { + command.CommandText = $"SELECT LABEL FROM {GMailLabelsTable.TableName} WHERE UID = @UID"; + command.Parameters.AddWithValue ("@UID", (long) uid.Id); + command.CommandType = CommandType.Text; + + using (var reader = command.ExecuteReader ()) { + while (reader.Read ()) { + var column = reader.GetOrdinal ("LABEL"); + + if (column != -1) + labels.Add (reader.GetString (column)); + } + } + } + } + + static bool IsEmptyFetchRequest (IFetchRequest request) + { + return request.Items == MessageSummaryItems.None && (request.Headers == null || (request.Headers.Count == 0 && !request.Headers.Exclude)); + } + + public IList Fetch (int min, int max, IFetchRequest request) + { + if (min < 0) + throw new ArgumentOutOfRangeException (nameof (min)); + + if (max != -1 && max < min) + throw new ArgumentOutOfRangeException (nameof (max)); + + if (request == null) + throw new ArgumentNullException (nameof (request)); + + if (Count == 0 || IsEmptyFetchRequest (request)) + return Array.Empty (); + + int capacity = Math.Max (max < 0 || max > Count ? Count : max, min) - min; + var messages = new List (capacity); + var items = request.Items; + + if ((items & (MessageSummaryItems.Flags /*| MessageSummaryItems.Annotations*/)) != 0) + items |= MessageSummaryItems.UniqueId; + + using (var command = sqlite.CreateCommand ()) { + var columns = GetMessageTableColumns (items); + var builder = new StringBuilder ("SELECT "); + + if (columns.Count > 0) { + foreach (var column in columns) + builder = builder.Append (column).Append (", "); + + builder.Length -= 2; + } else { + builder.Append ("UID"); + } + + builder.Append ($"FROM {MessageTable.TableName} "); + + if (request.ChangedSince.HasValue) { + command.Parameters.AddWithValue ("@MODSEQ", request.ChangedSince.Value); + builder.Append ("WHERE MODSEQ > @MODSEQ "); + } + + builder.Append ("ORDER BY UID"); + + if (max != -1) { + command.Parameters.AddWithValue ("@LIMIT", capacity); + builder.Append (" LIMIT @LIMIT"); + } + + if (min > 0) { + command.Parameters.AddWithValue ("@OFFSET", min); + builder.Append (" OFFSET @OFFSET"); + } + + command.CommandText = builder.ToString (); + command.CommandType = CommandType.Text; + + using (var reader = command.ExecuteReader ()) + LoadMessages (messages, items, reader, min); + } + + if ((items & MessageSummaryItems.Flags) != 0) { + var keywords = new HashSet (); + + foreach (var message in messages) { + LoadKeywords (message.UniqueId, keywords); + + foreach (var keyword in keywords) + ((HashSet) message.Keywords).Add (keyword); + + keywords.Clear (); + } + } + + if ((items & MessageSummaryItems.GMailLabels) != 0) { + var labels = new HashSet (); + + foreach (var message in messages) { + LoadXGMLabels (message.UniqueId, labels); + + foreach (var label in labels) + message.GMailLabels.Add (label); + + labels.Clear (); + } + } + + return messages; + } + + public IList Fetch (IList indexes, IFetchRequest request) + { + if (indexes == null) + throw new ArgumentNullException (nameof (indexes)); + + if (request == null) + throw new ArgumentNullException (nameof (request)); + + if (indexes.Count == 0 || Count == 0 || IsEmptyFetchRequest (request)) + return Array.Empty (); + + return null; + } + + public IList Fetch (IList uids, IFetchRequest request) + { + if (uids == null) + throw new ArgumentNullException (nameof (uids)); + + if (request == null) + throw new ArgumentNullException (nameof (request)); + + if (uids.Count == 0 || Count == 0 || IsEmptyFetchRequest (request)) + return Array.Empty (); + + return null; + } + + public void Dispose () + { + if (sqlite != null) + Close (); + } + } +} diff --git a/MailKit/MailKit.csproj b/MailKit/MailKit.csproj index eb27bb1b97..8194656e9e 100644 --- a/MailKit/MailKit.csproj +++ b/MailKit/MailKit.csproj @@ -64,6 +64,11 @@ + + + + +