using System.Globalization; using MimeKit; using OfficeIMO.Email; using OfficeIMO.Email.Store; using MaksIT.Results; using MaksIT.PostClient.Shared; namespace MaksIT.PostClient.Client; public sealed class PstMailSession : IMailSession { private const int FolderScan = 1_000_000; private readonly Lock _gate = new(); private readonly MailSessionGate _io = new(); private readonly Dictionary> _items = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _listOffset = new(StringComparer.OrdinalIgnoreCase); private EmailStoreSession? _store; private string _path = ""; public string StorePath { get { lock (_gate) return _path; } } public string? LastNotice { get; private set; } public event Action? StorePathChanged; public bool IsConnected { get { lock (_gate) return _store is not null; } } public bool SupportsFolders => true; public Task ConnectAsync( MailboxAccount account, string password, CancellationToken cancellationToken = default) { _ = password; ArgumentNullException.ThrowIfNull(account); var path = account.DataFile; if (string.IsNullOrWhiteSpace(path)) return Task.FromResult(Result.BadRequest("Outlook data file path is required.")); if (!File.Exists(path)) return Task.FromResult(Result.BadRequest("Outlook data file not found: " + path)); return _io.RunAsync(() => { cancellationToken.ThrowIfCancellationRequested(); DisposeStore(); LastNotice = null; try { if (OutlookPst.IsAnsi(path)) { var copy = OutlookPst.UnicodeCopyPath(path); if (!File.Exists(copy)) { return Task.FromResult(ConvertToUnicodePst( path, copy, cancellationToken, "ANSI Outlook data files are copied to " + Path.GetFileName(copy) + ".")); } path = copy; NotifyPath(path, "Reading the Unicode copy " + Path.GetFileName(copy) + "."); } OpenStore(path); return Task.FromResult(Result.Ok()); } catch (Exception ex) { return Task.FromResult(Result.UnprocessableEntity(WriteError(ex))); } }, cancellationToken); } public Task>> ListFoldersAsync( CancellationToken cancellationToken = default) => _io.RunAsync(() => { cancellationToken.ThrowIfCancellationRequested(); var store = RequireStore(); if (store is null) return Task.FromResult(Result>.UnprocessableEntity(null, "Not connected.")); try { var folders = new List(); foreach (var folder in store.FolderCatalog.All) { if (!IncludeFolder(folder)) continue; var full = PathOf(store.FolderCatalog, folder); if (string.IsNullOrWhiteSpace(full)) continue; folders.Add(new MailFolderInfo { FullName = full, Name = folder.Name.Trim(), Total = folder.ItemCount ?? 0 }); } return Task.FromResult(Result>.Ok( MailFolderCatalog.Normalize(folders, MailFolderLayout.Store))); } catch (Exception ex) { return Task.FromResult(Result>.UnprocessableEntity(null, ex.Message)); } }, cancellationToken); public Task> ListMessagesAsync( string folder, IReadOnlySet? knownIds = null, CancellationToken cancellationToken = default, int itemBudget = 0) => _io.RunAsync( () => Task.FromResult(ListMessagesCore(folder, knownIds, cancellationToken, itemBudget)), cancellationToken); public Task> GetMessageAsync( string folder, uint id, CancellationToken cancellationToken = default) => _io.RunAsync(() => GetMessageCoreAsync(folder, id, cancellationToken), cancellationToken, interactive: true); public Task> GetQuotaAsync(CancellationToken cancellationToken = default) { _ = cancellationToken; return Task.FromResult(Result.Ok(null)); } public Task> AppendAsync( string folder, byte[] eml, CancellationToken cancellationToken = default) => _io.RunAsync(() => AppendCoreAsync(folder, eml, cancellationToken), cancellationToken); public Task> SendAsync(MailSendRequest request, CancellationToken cancellationToken = default) { _ = request; _ = cancellationToken; return Task.FromResult(Result.UnprocessableEntity(null, "This Outlook data file has no SMTP. Send from an IMAP mailbox.")); } public Task MoveMessagesAsync( string fromFolder, IReadOnlyList ids, string toFolder, CancellationToken cancellationToken = default) => _io.RunAsync(() => MutateAsync(fromFolder, ids, toFolder, cancellationToken, (tx, source, dest, itemIds) => { if (string.IsNullOrEmpty(dest)) return Task.FromResult(Result.UnprocessableEntity("Folder not found.")); foreach (var itemId in itemIds) tx.MoveItem(itemId, dest, cancellationToken); return Task.FromResult(Result.Ok()); }), cancellationToken, interactive: true); public Task SetMessageFlagsAsync( string folder, IReadOnlyList ids, MailFlagUpdate update, CancellationToken cancellationToken = default) => _io.RunAsync(() => SetFlagsCoreAsync(folder, ids, update, cancellationToken), cancellationToken, interactive: true); public Task CreateFolderAsync( string name, string? parentFolder, CancellationToken cancellationToken = default) => _io.RunAsync(() => CreateFolderCoreAsync(name, parentFolder, cancellationToken), cancellationToken, interactive: true); public Task RenameFolderAsync( string folder, string? parentFolder, string name, CancellationToken cancellationToken = default) => _io.RunAsync(() => RenameFolderCoreAsync(folder, parentFolder, name, cancellationToken), cancellationToken); public Task DeleteFolderAsync(string folder, CancellationToken cancellationToken = default) => _io.RunAsync(() => DeleteFolderCoreAsync(folder, cancellationToken), cancellationToken); public Task EmptyFolderAsync( string folder, string? trashFolder, CancellationToken cancellationToken = default) => _io.RunAsync(() => EmptyFolderCoreAsync(folder, trashFolder, cancellationToken), cancellationToken, interactive: true); public Task SetFolderSeenAsync( string folder, bool seen, CancellationToken cancellationToken = default) => _io.RunAsync(() => SetFolderSeenCoreAsync(folder, seen, cancellationToken), cancellationToken); public async ValueTask DisposeAsync() { await _io.RunAsync(() => { DisposeStore(); return Task.CompletedTask; }, CancellationToken.None).ConfigureAwait(false); _io.Dispose(); } private Result ListMessagesCore( string folder, IReadOnlySet? knownIds, CancellationToken cancellationToken, int itemBudget) { var store = RequireStore(); if (store is null) return Result.UnprocessableEntity(null, "Not connected."); var node = FindFolder(store, folder); if (node is null) return Result.UnprocessableEntity(null, "Folder not found."); try { var chunk = itemBudget > 0; var offset = 0; var map = new Dictionary(); lock (_gate) { if (chunk) { _listOffset.TryGetValue(folder, out offset); if (_items.TryGetValue(folder, out var previous)) { foreach (var pair in previous) map[pair.Key] = pair.Value; } } else _listOffset.Remove(folder); } var headers = new List(); var present = chunk ? null : new List(); var known = knownIds ?? (IReadOnlySet)new HashSet(); var seen = 0; var taken = 0; var incomplete = false; foreach (var reference in store.EnumerateItems(node.Key, false, false, FolderScan, cancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); if (reference.IsAssociated || reference.IsOrphaned) continue; var summary = reference.Summary; if (!IsMail(summary?.MessageClass, DisplaySubject(summary))) continue; if (seen < offset) { seen++; continue; } if (chunk && taken >= itemBudget) { incomplete = true; break; } seen++; var itemId = ItemId(reference); var uid = UidFromItemId(itemId, map); map[uid] = itemId; present?.Add(uid); var listedFrom = AddressText(summary?.From, summary?.Sender); if (!known.Contains(uid) || listedFrom.Length == 0) { headers.Add(ToHeader( folder, uid, summary, listedFrom.Length > 0 ? listedFrom : SenderOf(store, reference, summary, cancellationToken))); } taken++; } lock (_gate) { _items[folder] = map; if (incomplete) _listOffset[folder] = seen; else _listOffset.Remove(folder); } return Result.Ok(new MailFolderSync { Headers = headers, Present = incomplete ? null : present ?? [.. map.Keys], Incomplete = incomplete }); } catch (Exception ex) { return Result.UnprocessableEntity(null, ex.Message); } } private async Task> GetMessageCoreAsync( string folder, uint id, CancellationToken cancellationToken) { var store = RequireStore(); if (store is null) return Result.UnprocessableEntity(null, "Not connected."); if (!TryReadItem(store, folder, id, cancellationToken, out var item, out var reference) || item is null || reference is null) return Result.UnprocessableEntity(null, "Message not found."); try { var eml = ToEml(item.Document); using var stream = new MemoryStream(eml); var mime = await MimeMessage.LoadAsync(stream, cancellationToken).ConfigureAwait(false); RestoreFrom(mime, item.Document); mime.Subject = PstSubject.Display( mime.Subject, item.Document.MessageMetadata?.NormalizedSubject, item.Document.MessageMetadata?.SubjectPrefix); var seen = item.Document.MessageMetadata?.IsRead ?? reference.Summary?.IsRead ?? true; var flagged = item.Document.MessageMetadata?.FollowUp?.Status == OutlookFollowUpStatus.Flagged; return Result.Ok( await MimeBody.FromMimeAsync(folder, id, mime, cancellationToken, seen, flagged).ConfigureAwait(false)); } catch (Exception ex) { return Result.UnprocessableEntity(null, ex.Message); } } private async Task> AppendCoreAsync( string folder, byte[] eml, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(folder) || eml is null || eml.Length == 0) return Result.BadRequest(null, "Folder and message are required."); EmailDocument document; MimeMessage mime; try { document = EmailDocument.Load(eml); using var stream = new MemoryStream(eml, writable: false); mime = await MimeMessage.LoadAsync(stream, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { return Result.UnprocessableEntity(null, ex.Message); } FillAddresses(document, mime); var ready = await MutateAsync(tx => { var folderId = FindMutationFolder(tx, folder); if (folderId is null) return Task.FromResult(Result.UnprocessableEntity("Folder not found.")); tx.AddItem(folderId, document, false, cancellationToken); return Task.FromResult(Result.Ok()); }, cancellationToken).ConfigureAwait(false); if (!ready.IsSuccess) return Result.UnprocessableEntity(null, string.Join(" ", ready.Messages)); var listed = ListMessagesCore(folder, null, cancellationToken, 0); if (!listed.IsSuccess || listed.Value is null) return Result.UnprocessableEntity(null, string.Join(" ", listed.Messages)); var wanted = PstSubject.Display(mime.Subject); MailMessageHeader? match = null; foreach (var header in listed.Value.Headers) { if (!header.Subject.Equals(wanted, StringComparison.OrdinalIgnoreCase) && !header.Subject.Equals(mime.Subject ?? "", StringComparison.OrdinalIgnoreCase)) continue; if (match is null || header.Date >= match.Date) match = header; } var uid = match?.Id ?? listed.Value.Present?.LastOrDefault() ?? 0; if (uid == 0) return Result.UnprocessableEntity(null, "Message was written but could not be read back."); return Result.Ok(uid); } private Task SetFlagsCoreAsync( string folder, IReadOnlyList ids, MailFlagUpdate update, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(update); if (ids.Count == 0) return Task.FromResult(Result.BadRequest("Select at least one message.")); return MutateAsync(folder, ids, null, cancellationToken, (tx, _, _, itemIds) => { foreach (var itemId in itemIds) { if (update.Deleted) { tx.DeleteItem(itemId, cancellationToken); continue; } var patch = new EmailStoreItemPatch(); if (update.Seen is bool seen) patch.SetReadState(seen); if (update.Flagged == true) patch.SetFollowUp("Follow up"); if (update.Flagged == false) patch.ClearFollowUp(); if (update.Priority == MailPriority.High) patch.SetImportance(EmailMessageImportance.High); else if (update.Priority == MailPriority.Low) patch.SetImportance(EmailMessageImportance.Low); else if (update.Priority == MailPriority.Normal) patch.SetImportance(EmailMessageImportance.Normal); if (!patch.IsEmpty) tx.PatchItem(itemId, patch, cancellationToken); } return Task.FromResult(Result.Ok()); }); } private Task CreateFolderCoreAsync( string name, string? parentFolder, CancellationToken cancellationToken) { var leaf = name.Trim(); if (leaf.Length == 0) return Task.FromResult(Result.BadRequest("Folder name is required.")); if (leaf.IndexOfAny(['/', '\\']) >= 0) return Task.FromResult(Result.BadRequest("Folder name cannot contain slashes.")); return MutateAsync(tx => { var parentId = string.IsNullOrWhiteSpace(parentFolder) ? tx.RootFolderId : FindMutationFolder(tx, parentFolder); if (parentId is null) return Task.FromResult(Result.UnprocessableEntity("Folder not found.")); tx.CreateFolder(leaf, parentId, OutlookPst.MailContainer); return Task.FromResult(Result.Ok()); }, cancellationToken); } private Task RenameFolderCoreAsync( string folder, string? parentFolder, string name, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(folder)) return Task.FromResult(Result.BadRequest("Folder is required.")); if (MailFolderRole.IsSystemKind(MailFolderRole.Kind(null, folder))) return Task.FromResult(Result.BadRequest("System folders cannot be moved.")); var leaf = string.IsNullOrWhiteSpace(name) ? MailFolderPath.Leaf(folder) : name.Trim(); if (leaf.Length == 0) return Task.FromResult(Result.BadRequest("Folder name is required.")); if (leaf.IndexOfAny(['/', '\\']) >= 0) return Task.FromResult(Result.BadRequest("Folder name cannot contain slashes.")); if (MailFolderPath.IsSelfOrUnder(parentFolder, folder)) return Task.FromResult(Result.BadRequest("A folder cannot be moved into itself.")); return MutateAsync(tx => { var folderId = FindMutationFolder(tx, folder); if (folderId is null) return Task.FromResult(Result.UnprocessableEntity("Folder not found.")); var parentId = string.IsNullOrWhiteSpace(parentFolder) ? tx.RootFolderId : FindMutationFolder(tx, parentFolder); if (parentId is null) return Task.FromResult(Result.UnprocessableEntity("Folder not found.")); var byId = tx.Folders.ToDictionary(row => row.Id, StringComparer.Ordinal); if (!byId.TryGetValue(folderId, out var node)) return Task.FromResult(Result.UnprocessableEntity("Folder not found.")); if (parentId.Equals(folderId, StringComparison.Ordinal) || IsMutationAncestor(byId, parentId, folderId)) return Task.FromResult(Result.BadRequest("A folder cannot be moved into itself.")); if (!string.Equals(node.ParentId, parentId, StringComparison.Ordinal)) tx.MoveFolder(folderId, parentId); if (!node.Name.Equals(leaf, StringComparison.Ordinal)) tx.RenameFolder(folderId, leaf); return Task.FromResult(Result.Ok()); }, cancellationToken); } private static bool IsMutationAncestor( IReadOnlyDictionary byId, string? nodeId, string ancestorId) { var current = nodeId; for (var i = 0; i < 64 && !string.IsNullOrEmpty(current); i++) { if (current.Equals(ancestorId, StringComparison.Ordinal)) return true; if (!byId.TryGetValue(current, out var node)) break; current = node.ParentId; } return false; } private Task DeleteFolderCoreAsync(string folder, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(folder)) return Task.FromResult(Result.BadRequest("Folder is required.")); if (MailFolderRole.IsSystemKind(MailFolderRole.Kind(null, folder))) return Task.FromResult(Result.BadRequest("System folders cannot be deleted.")); return MutateAsync(tx => { var folderId = FindMutationFolder(tx, folder); if (folderId is null) return Task.FromResult(Result.UnprocessableEntity("Folder not found.")); tx.DeleteFolder(folderId, true, cancellationToken); return Task.FromResult(Result.Ok()); }, cancellationToken); } private Task EmptyFolderCoreAsync( string folder, string? trashFolder, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(folder)) return Task.FromResult(Result.BadRequest("Folder is required.")); return MutateAsync(tx => { var folderId = FindMutationFolder(tx, folder); if (folderId is null) return Task.FromResult(Result.UnprocessableEntity("Folder not found.")); var toTrash = !string.IsNullOrWhiteSpace(trashFolder) && !folder.Equals(trashFolder, StringComparison.OrdinalIgnoreCase); var trashId = toTrash ? FindMutationFolder(tx, trashFolder!) : null; foreach (var reference in tx.EnumerateItems()) { if (reference.IsAssociated || !string.Equals(FolderId(reference), folderId, StringComparison.Ordinal)) continue; if (trashId is not null) tx.MoveItem(ItemId(reference), trashId, cancellationToken); else tx.DeleteItem(ItemId(reference), cancellationToken); } return Task.FromResult(Result.Ok()); }, cancellationToken); } private Task SetFolderSeenCoreAsync( string folder, bool seen, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(folder)) return Task.FromResult(Result.BadRequest("Folder is required.")); return MutateAsync(tx => { var folderId = FindMutationFolder(tx, folder); if (folderId is null) return Task.FromResult(Result.UnprocessableEntity("Folder not found.")); var patch = new EmailStoreItemPatch(); patch.SetReadState(seen); foreach (var reference in tx.EnumerateItems()) { if (reference.IsAssociated || !string.Equals(FolderId(reference), folderId, StringComparison.Ordinal)) continue; tx.PatchItem(ItemId(reference), patch, cancellationToken); } return Task.FromResult(Result.Ok()); }, cancellationToken); } private async Task MutateAsync( string fromFolder, IReadOnlyList ids, string? toFolder, CancellationToken cancellationToken, Func, Task> work) { if (string.IsNullOrWhiteSpace(fromFolder)) return Result.BadRequest("Folder is required."); if (ids.Count == 0) return Result.BadRequest("Select at least one message."); var itemIds = new List(); return await MutateAsync(tx => { if (itemIds.Count == 0) return Task.FromResult(Result.UnprocessableEntity("Message not found.")); var source = FindMutationFolder(tx, fromFolder); if (source is null) return Task.FromResult(Result.UnprocessableEntity("Folder not found.")); var dest = toFolder is null ? null : FindMutationFolder(tx, toFolder); return work(tx, source, dest, itemIds); }, cancellationToken, () => { EnsureListed(fromFolder, cancellationToken); itemIds.Clear(); foreach (var id in ids) { var itemId = LookupItem(fromFolder, id); if (itemId is not null) itemIds.Add(itemId); } }).ConfigureAwait(false); } private Task MutateAsync( Func> work, CancellationToken cancellationToken) => MutateAsync(work, cancellationToken, null); private async Task MutateAsync( Func> work, CancellationToken cancellationToken, Action? beforeClose) { var converted = false; for (var attempt = 0; attempt < 2; attempt++) { var prepared = PrepareWritable(cancellationToken); if (!prepared.IsSuccess) return prepared; beforeClose?.Invoke(); var path = StorePath; DisposeStore(); try { using var tx = EmailStorePstMutationTransaction.Open(path, MutationOptions(), cancellationToken); var result = await work(tx).ConfigureAwait(false); if (!result.IsSuccess) { OpenStore(path); return result; } await tx.CommitAsync(cancellationToken).ConfigureAwait(false); OpenStore(path); return Result.Ok(); } catch (Exception ex) when (!converted && NeedsUnicodeCopy(ex)) { converted = true; var copy = ConvertToUnicodePst(path, cancellationToken); if (!copy.IsSuccess) return copy; } catch (Exception ex) { try { OpenStore(path); } catch { // The original path may no longer open after a failed exclusive lock. } return Result.UnprocessableEntity(WriteError(ex)); } } return Result.UnprocessableEntity("Could not write this Outlook data file."); } private Result PrepareWritable(CancellationToken cancellationToken) { var store = RequireStore(); if (store is null) return Result.UnprocessableEntity("Not connected."); if (store.IsPstPasswordProtected) return Result.UnprocessableEntity("Password-protected PST files cannot be written."); if (store.Format == EmailStoreFormat.Ost || OutlookPst.LooksLikeOst(_path)) return ConvertToUnicodePst(_path, cancellationToken); return Result.Ok(); } private Result ConvertToUnicodePst(string source, CancellationToken cancellationToken) { var dest = OutlookPst.SiblingUnicodePstPath(source); return ConvertToUnicodePst( source, dest, cancellationToken, "Writes go to " + Path.GetFileName(dest) + ". OST files cannot be updated in place."); } private Result ConvertToUnicodePst( string source, string dest, CancellationToken cancellationToken, string notice) { DisposeStore(); try { EmailStoreConverter.ConvertToPst( source, dest, conversionOptions: new EmailStorePstConversionOptions( failOnDataLoss: false, continueOnItemError: true, includeAssociatedItems: true, includeOrphanedItems: true, verifyAfterWrite: false), cancellationToken: cancellationToken); } catch (Exception ex) { try { OpenStore(source); } catch { } return Result.UnprocessableEntity(WriteError(ex)); } NotifyPath(dest, notice); try { OpenStore(dest); return Result.Ok(); } catch (Exception ex) { return Result.UnprocessableEntity(WriteError(ex)); } } private void NotifyPath(string path, string notice) { lock (_gate) _path = path; LastNotice = notice; StorePathChanged?.Invoke(path); } private void OpenStore(string path) { var store = EmailStoreSession.Open(path); lock (_gate) { _store = store; _path = path; _items.Clear(); _listOffset.Clear(); } } private EmailStoreSession? RequireStore() { lock (_gate) return _store; } private void DisposeStore() { EmailStoreSession? store; lock (_gate) { store = _store; _store = null; _items.Clear(); _listOffset.Clear(); } store?.Dispose(); } private void EnsureListed(string folder, CancellationToken cancellationToken) { lock (_gate) { if (_items.ContainsKey(folder)) return; } ListMessagesCore(folder, null, cancellationToken, 0); } private string? LookupItem(string folder, uint id) { lock (_gate) return _items.TryGetValue(folder, out var map) && map.TryGetValue(id, out var itemId) ? itemId : null; } private void RememberItem(string folder, uint id, string itemId) { lock (_gate) { if (!_items.TryGetValue(folder, out var map)) { map = new Dictionary(); _items[folder] = map; } map[id] = itemId; } } private bool TryReadItem( EmailStoreSession store, string folder, uint id, CancellationToken cancellationToken, out EmailStoreItem? item, out EmailStoreItemReference? reference) { item = null; reference = null; var mapped = LookupItem(folder, id); var found = !string.IsNullOrWhiteSpace(mapped) ? FindReference(store, folder, mapped, cancellationToken) : null; found ??= FindReferenceByUid(store, folder, id, cancellationToken); if (found is null) return false; try { item = store.ReadItem( found, new EmailStoreItemReadOptions(EmailStoreItemReadParts.All), cancellationToken); if (item?.Document is null) return false; reference = found; RememberItem(folder, id, ItemId(found)); return true; } catch (OperationCanceledException) { throw; } catch { item = null; reference = null; return false; } } private EmailStoreItemReference? FindReference( EmailStoreSession store, string folder, string itemId, CancellationToken cancellationToken) { var node = FindFolder(store, folder); if (node is null) return null; foreach (var reference in store.EnumerateItems(node.Key, false, false, FolderScan, cancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); if (ItemId(reference) == itemId) return reference; } return null; } private static EmailStoreItemReference? FindReferenceByUid( EmailStoreSession store, string folder, uint id, CancellationToken cancellationToken) { var node = FindFolder(store, folder); if (node is null) return null; var map = new Dictionary(); foreach (var reference in store.EnumerateItems(node.Key, false, false, FolderScan, cancellationToken)) { cancellationToken.ThrowIfCancellationRequested(); if (reference.IsAssociated || reference.IsOrphaned) continue; if (!IsMail(reference.Summary?.MessageClass, DisplaySubject(reference.Summary))) continue; var itemId = ItemId(reference); var uid = UidFromItemId(itemId, map); map[uid] = itemId; if (uid == id) return reference; } return null; } private static EmailStoreFolderInfo? FindFolder(EmailStoreSession store, string wanted) { EmailStoreFolderInfo? leaf = null; var leaves = 0; foreach (var folder in store.FolderCatalog.All) { if (!IncludeFolder(folder)) continue; var full = PathOf(store.FolderCatalog, folder); if (full.Equals(wanted, StringComparison.OrdinalIgnoreCase)) return folder; if (folder.Name.Equals(wanted, StringComparison.OrdinalIgnoreCase) || Path.GetFileName(full.Replace('\\', '/')).Equals(wanted, StringComparison.OrdinalIgnoreCase)) { leaf = folder; leaves++; } } return leaves == 1 ? leaf : null; } private static string? FindMutationFolder(EmailStorePstMutationTransaction tx, string wanted) { var byId = tx.Folders.ToDictionary(folder => folder.Id, StringComparer.Ordinal); string? leaf = null; var leaves = 0; foreach (var folder in tx.Folders) { if (!IncludeMutationFolder(folder)) continue; var full = MutationPath(byId, folder); if (full.Equals(wanted, StringComparison.OrdinalIgnoreCase)) return folder.Id; if (folder.Name.Equals(wanted, StringComparison.OrdinalIgnoreCase) || Path.GetFileName(full.Replace('\\', '/')).Equals(wanted, StringComparison.OrdinalIgnoreCase)) { leaf = folder.Id; leaves++; } } return leaves == 1 ? leaf : null; } private static string MutationPath( IReadOnlyDictionary byId, EmailStorePstMutationFolder folder) { var parts = new List(); var current = folder; for (var i = 0; i < 64 && current is not null; i++) { if (IncludeMutationFolder(current) && !string.IsNullOrWhiteSpace(current.Name)) parts.Add(current.Name.Trim()); if (string.IsNullOrEmpty(current.ParentId) || !byId.TryGetValue(current.ParentId, out current)) break; } parts.Reverse(); return string.Join("/", parts); } private static bool IncludeFolder(EmailStoreFolderInfo folder) => !folder.IsSearchFolder && !IsRootLike(folder.SpecialFolderKind, folder.Name) && !string.IsNullOrWhiteSpace(folder.Name); private static bool IncludeMutationFolder(EmailStorePstMutationFolder folder) => !folder.IsSearchFolder && !IsRootLike(folder.SpecialFolderKind, folder.Name) && !string.IsNullOrWhiteSpace(folder.Name); private static bool IsRootLike(EmailStoreSpecialFolderKind kind, string? name) { if (kind is EmailStoreSpecialFolderKind.Root or EmailStoreSpecialFolderKind.IpmSubtree or EmailStoreSpecialFolderKind.SearchRoot or EmailStoreSpecialFolderKind.CommonViews or EmailStoreSpecialFolderKind.PersonalViews or EmailStoreSpecialFolderKind.Reminders or EmailStoreSpecialFolderKind.ToDo) return true; return name is not null && (name.Equals("Root", StringComparison.OrdinalIgnoreCase) || name.Equals("IPM_SUBTREE", StringComparison.OrdinalIgnoreCase) || name.Equals("Top of Personal Folders", StringComparison.OrdinalIgnoreCase)); } private static string PathOf(EmailStoreFolderCatalog catalog, EmailStoreFolderInfo folder) { var parts = new List(); foreach (var part in catalog.GetPath(folder.Key)) { if (IsRootLike(part.SpecialFolderKind, part.Name) || string.IsNullOrWhiteSpace(part.Name)) continue; parts.Add(part.Name.Trim()); } return parts.Count == 0 ? folder.Name.Trim() : string.Join("/", parts); } private static bool IsMail(string? messageClass, string? subject) { var klass = messageClass ?? ""; var title = subject ?? ""; if (klass.StartsWith("IPM.Rule", StringComparison.OrdinalIgnoreCase) || klass.StartsWith("IPM.Configuration", StringComparison.OrdinalIgnoreCase) || title.StartsWith("IPM.Rule", StringComparison.OrdinalIgnoreCase) || title.StartsWith("IPM.Configuration", StringComparison.OrdinalIgnoreCase)) return false; return true; } private static uint UidFromItemId(string itemId, Dictionary used) { var uid = ParseItemUid(itemId); while (used.TryGetValue(uid, out var existing) && !existing.Equals(itemId, StringComparison.Ordinal)) uid++; if (uid == 0) uid = 1; return uid; } private static uint ParseItemUid(string itemId) { if (uint.TryParse(itemId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) && n != 0) return n; if (itemId.StartsWith("0x", StringComparison.OrdinalIgnoreCase) && uint.TryParse(itemId.AsSpan(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out n) && n != 0) return n; unchecked { uint hash = 2166136261; foreach (var b in System.Text.Encoding.UTF8.GetBytes(itemId)) { hash ^= b; hash *= 16777619; } return hash == 0 ? 1 : hash; } } private static void FillAddresses(EmailDocument document, MimeMessage mime) { var from = MailboxOf(mime.From.Mailboxes.FirstOrDefault()); var sender = MailboxOf(mime.Sender) ?? from; if (from is not null) document.From = from; if (sender is not null) document.Sender = sender; WriteSenderMapi(document, from ?? sender); EnsureFromHeader(document, mime); } private static void WriteSenderMapi(EmailDocument document, EmailAddress? address) { if (address is null || string.IsNullOrWhiteSpace(address.Address)) return; var smtp = address.Address.Trim(); var name = string.IsNullOrWhiteSpace(address.DisplayName) ? smtp : address.DisplayName.Trim(); WriteSenderTag(document.Mapi, name, smtp); WriteSenderTag(document.MapiWritePatch, name, smtp); } private static void WriteSenderTag(MapiPropertyBag mapi, string name, string smtp) { mapi.Set(MapiKnownProperties.PidTag.SenderName, name); mapi.Set(MapiKnownProperties.PidTag.SenderAddressType, "SMTP"); mapi.Set(MapiKnownProperties.PidTag.SenderEmailAddress, smtp); mapi.Set(MapiKnownProperties.PidTag.SenderSmtpAddress, smtp); mapi.Set(MapiKnownProperties.PidTag.SentRepresentingName, name); mapi.Set(MapiKnownProperties.PidTag.SentRepresentingAddressType, "SMTP"); mapi.Set(MapiKnownProperties.PidTag.SentRepresentingEmailAddress, smtp); mapi.Set(MapiKnownProperties.PidTag.SentRepresentingSmtpAddress, smtp); } private static void WriteSenderTag(MapiPropertyPatch patch, string name, string smtp) { patch.Set(MapiKnownProperties.PidTag.SenderName, name); patch.Set(MapiKnownProperties.PidTag.SenderAddressType, "SMTP"); patch.Set(MapiKnownProperties.PidTag.SenderEmailAddress, smtp); patch.Set(MapiKnownProperties.PidTag.SenderSmtpAddress, smtp); patch.Set(MapiKnownProperties.PidTag.SentRepresentingName, name); patch.Set(MapiKnownProperties.PidTag.SentRepresentingAddressType, "SMTP"); patch.Set(MapiKnownProperties.PidTag.SentRepresentingEmailAddress, smtp); patch.Set(MapiKnownProperties.PidTag.SentRepresentingSmtpAddress, smtp); } private static void EnsureFromHeader(EmailDocument document, MimeMessage mime) { var line = mime.From.ToString(); if (string.IsNullOrWhiteSpace(line)) return; foreach (var header in document.Headers) { if (header.Name.Equals("From", StringComparison.OrdinalIgnoreCase)) return; } document.Headers.Add(new EmailHeader("From", line, line)); } private static EmailAddress? MailboxOf(MailboxAddress? mailbox) { if (mailbox is null || string.IsNullOrWhiteSpace(mailbox.Address)) return null; var smtp = mailbox.Address.Trim(); var name = string.IsNullOrWhiteSpace(mailbox.Name) ? smtp : mailbox.Name.Trim(); return new EmailAddress(smtp, name, mailbox.ToString()) { AddressType = "SMTP" }; } private static MailMessageHeader ToHeader( string folder, uint id, EmailStoreItemSummary? summary, string? from = null) { var date = summary?.ReceivedAt ?? summary?.SentAt ?? DateTimeOffset.MinValue; return MimeBody.ToListHeader( folder, id, DisplaySubject(summary), string.IsNullOrWhiteSpace(from) ? AddressText(summary?.From, summary?.Sender) : from, date, summary?.IsRead ?? true, false, summary?.HasAttachments ?? false, new EnvelopeInfo(), messageId: summary?.MessageId, inReplyTo: summary?.InReplyToId); } private static string SenderOf( EmailStoreSession store, EmailStoreItemReference reference, EmailStoreItemSummary? summary, CancellationToken cancellationToken) { var text = AddressText(summary?.From, summary?.Sender); if (text.Length > 0) return text; try { var item = store.ReadItem( reference, new EmailStoreItemReadOptions(EmailStoreItemReadParts.Metadata | EmailStoreItemReadParts.Recipients), cancellationToken); text = AddressText(item.Document.From, item.Document.Sender); if (text.Length > 0) return text; foreach (var header in item.Document.Headers) { if (header.Name.Equals("From", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(header.Value)) return header.Value; } var mapi = item.Document.Mapi; var smtp = mapi.GetValueOrDefault(MapiKnownProperties.PidTag.SenderSmtpAddress) ?? mapi.GetValueOrDefault(MapiKnownProperties.PidTag.SenderEmailAddress) ?? mapi.GetValueOrDefault(MapiKnownProperties.PidTag.SentRepresentingSmtpAddress) ?? mapi.GetValueOrDefault(MapiKnownProperties.PidTag.SentRepresentingEmailAddress); if (!string.IsNullOrWhiteSpace(smtp)) return smtp; } catch { } return ""; } private static void RestoreFrom(MimeMessage mime, EmailDocument document) { if (mime.From.Count > 0) return; var address = document.From ?? document.Sender; if (address is not null && !string.IsNullOrWhiteSpace(address.Address)) { var display = address.DisplayName ?? ""; if (display.Equals(address.Address, StringComparison.OrdinalIgnoreCase)) display = ""; mime.From.Add(new MailboxAddress(display, address.Address)); return; } foreach (var header in document.Headers) { if (!header.Name.Equals("From", StringComparison.OrdinalIgnoreCase) || string.IsNullOrWhiteSpace(header.Value)) continue; try { mime.From.AddRange(InternetAddressList.Parse(header.Value)); } catch { } return; } } private static string AddressText(params EmailAddress?[] addresses) { foreach (var address in addresses) { if (address is null) continue; var smtp = address.Address?.Trim() ?? ""; var name = address.DisplayName?.Trim() ?? ""; if (smtp.Length > 0 && name.Length > 0 && !name.Equals(smtp, StringComparison.OrdinalIgnoreCase)) return name + " <" + smtp + ">"; var text = smtp.Length > 0 ? smtp : name.Length > 0 ? name : address.RawValue ?? ""; if (!string.IsNullOrWhiteSpace(text)) return text; } return ""; } private static string DisplaySubject(EmailStoreItemSummary? summary) => PstSubject.Display(summary?.Subject, summary?.NormalizedSubject); private static byte[] ToEml(EmailDocument document) { document.Subject = PstSubject.Display( document.Subject, document.MessageMetadata?.NormalizedSubject, document.MessageMetadata?.SubjectPrefix); try { return document.ToBytes(EmailFileFormat.Eml); } catch { using var stream = new MemoryStream(); new EmailDocumentWriter().Write(document, stream, EmailFileFormat.Eml); return stream.ToArray(); } } private static string ItemId(EmailStoreItemReference reference) => reference.Key.Value; private static string FolderId(EmailStoreItemReference reference) => reference.FolderKey.Value; private static EmailStorePstMutationOptions MutationOptions() => new( failOnDataLoss: false, verifyAfterWrite: false, maxFolderCount: 100_000, maxItemCount: 1_000_000); private static bool NeedsUnicodeCopy(Exception ex) { var text = ex.Message; return text.Contains("ANSI", StringComparison.OrdinalIgnoreCase) || text.Contains("OST", StringComparison.OrdinalIgnoreCase) || text.Contains("Unicode", StringComparison.OrdinalIgnoreCase); } private static string WriteError(Exception ex) { var message = ex.Message; if (message.Contains("lock", StringComparison.OrdinalIgnoreCase) || message.Contains("being used", StringComparison.OrdinalIgnoreCase) || ex is IOException) return message + " Close Outlook or any other program that has this file open."; return message; } }